feat: unified catalog endpoint and slug-based app navigation
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 1m7s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s
SonarQube / sonarqube (push) Successful in 3m47s

Consolidate route catalog (agent-driven) and apps table (deployment-
driven) into a single GET /api/v1/catalog?environment={slug} endpoint.
Apps table is authoritative; agent data enriches with live health,
routes, and metrics. Unmanaged apps (agents without App record) appear
with managed=false.

- Add CatalogController merging App records + agent registry + ClickHouse
- Add CatalogApp DTO with deployment summary, managed flag, health
- Change AppController and DeploymentController to accept slugs (not UUIDs)
- Add AppRepository.findBySlug() and AppService.getBySlug()
- Replace useRouteCatalog() with useCatalog() across all UI components
- Navigate to /apps/{slug} instead of /apps/{UUID}
- Update sidebar, search, and all catalog lookups to use slug

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-08 23:43:14 +02:00
parent 0720053523
commit b86e95f08e
15 changed files with 458 additions and 93 deletions

View File

@@ -27,6 +27,7 @@ import java.util.UUID;
/**
* App CRUD and JAR upload endpoints.
* All app-scoped endpoints accept the app slug (not UUID) as path variable.
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}.
*/
@RestController
@@ -51,13 +52,13 @@ public class AppController {
return ResponseEntity.ok(appService.listAll());
}
@GetMapping("/{appId}")
@Operation(summary = "Get app by ID")
@GetMapping("/{appSlug}")
@Operation(summary = "Get app by slug")
@ApiResponse(responseCode = "200", description = "App found")
@ApiResponse(responseCode = "404", description = "App not found")
public ResponseEntity<App> getApp(@PathVariable UUID appId) {
public ResponseEntity<App> getApp(@PathVariable String appSlug) {
try {
return ResponseEntity.ok(appService.getById(appId));
return ResponseEntity.ok(appService.getBySlug(appSlug));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
@@ -76,44 +77,56 @@ public class AppController {
}
}
@GetMapping("/{appId}/versions")
@GetMapping("/{appSlug}/versions")
@Operation(summary = "List app versions")
@ApiResponse(responseCode = "200", description = "Version list returned")
public ResponseEntity<List<AppVersion>> listVersions(@PathVariable UUID appId) {
return ResponseEntity.ok(appService.listVersions(appId));
public ResponseEntity<List<AppVersion>> listVersions(@PathVariable String appSlug) {
try {
App app = appService.getBySlug(appSlug);
return ResponseEntity.ok(appService.listVersions(app.id()));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping(value = "/{appId}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PostMapping(value = "/{appSlug}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "Upload a JAR for a new app version")
@ApiResponse(responseCode = "201", description = "JAR uploaded and version created")
@ApiResponse(responseCode = "404", description = "App not found")
public ResponseEntity<AppVersion> uploadJar(@PathVariable UUID appId,
public ResponseEntity<AppVersion> uploadJar(@PathVariable String appSlug,
@RequestParam("file") MultipartFile file) throws IOException {
try {
AppVersion version = appService.uploadJar(appId, file.getOriginalFilename(), file.getInputStream(), file.getSize());
App app = appService.getBySlug(appSlug);
AppVersion version = appService.uploadJar(app.id(), file.getOriginalFilename(), file.getInputStream(), file.getSize());
return ResponseEntity.status(201).body(version);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{appId}")
@DeleteMapping("/{appSlug}")
@Operation(summary = "Delete an app")
@ApiResponse(responseCode = "204", description = "App deleted")
public ResponseEntity<Void> deleteApp(@PathVariable UUID appId) {
appService.deleteApp(appId);
return ResponseEntity.noContent().build();
public ResponseEntity<Void> deleteApp(@PathVariable String appSlug) {
try {
App app = appService.getBySlug(appSlug);
appService.deleteApp(app.id());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PutMapping("/{appId}/container-config")
@PutMapping("/{appSlug}/container-config")
@Operation(summary = "Update container config for an app")
@ApiResponse(responseCode = "200", description = "Container config updated")
@ApiResponse(responseCode = "404", description = "App not found")
public ResponseEntity<App> updateContainerConfig(@PathVariable UUID appId,
public ResponseEntity<App> updateContainerConfig(@PathVariable String appSlug,
@RequestBody Map<String, Object> containerConfig) {
try {
appService.updateContainerConfig(appId, containerConfig);
return ResponseEntity.ok(appService.getById(appId));
App app = appService.getBySlug(appSlug);
appService.updateContainerConfig(app.id(), containerConfig);
return ResponseEntity.ok(appService.getById(app.id()));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}

View File

@@ -0,0 +1,266 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.AgentSummary;
import com.cameleer3.server.app.dto.CatalogApp;
import com.cameleer3.server.app.dto.RouteSummary;
import com.cameleer3.common.graph.RouteGraph;
import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.agent.AgentState;
import com.cameleer3.server.core.agent.RouteStateRegistry;
import com.cameleer3.server.core.runtime.*;
import com.cameleer3.server.core.storage.DiagramStore;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
/**
* Unified catalog endpoint that merges App records (PostgreSQL) with live agent data
* and ClickHouse stats. Replaces the separate RouteCatalogController.
*/
@RestController
@RequestMapping("/api/v1/catalog")
@Tag(name = "Catalog", description = "Unified application catalog")
public class CatalogController {
private static final Logger log = LoggerFactory.getLogger(CatalogController.class);
private final AgentRegistryService registryService;
private final DiagramStore diagramStore;
private final JdbcTemplate jdbc;
private final RouteStateRegistry routeStateRegistry;
private final AppService appService;
private final EnvironmentService envService;
private final DeploymentRepository deploymentRepo;
public CatalogController(AgentRegistryService registryService,
DiagramStore diagramStore,
@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc,
RouteStateRegistry routeStateRegistry,
AppService appService,
EnvironmentService envService,
DeploymentRepository deploymentRepo) {
this.registryService = registryService;
this.diagramStore = diagramStore;
this.jdbc = jdbc;
this.routeStateRegistry = routeStateRegistry;
this.appService = appService;
this.envService = envService;
this.deploymentRepo = deploymentRepo;
}
@GetMapping
@Operation(summary = "Get unified catalog",
description = "Returns all applications (managed + unmanaged) with live agent data, routes, and deployment status")
@ApiResponse(responseCode = "200", description = "Catalog returned")
public ResponseEntity<List<CatalogApp>> getCatalog(
@RequestParam(required = false) String environment,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to) {
// 1. Resolve environment
Environment env = null;
if (environment != null && !environment.isBlank()) {
try {
env = envService.getBySlug(environment);
} catch (IllegalArgumentException e) {
return ResponseEntity.ok(List.of());
}
}
// 2. Get managed apps from PostgreSQL
List<App> managedApps = env != null
? appService.listByEnvironment(env.id())
: appService.listAll();
Map<String, App> appsBySlug = managedApps.stream()
.collect(Collectors.toMap(App::slug, a -> a, (a, b) -> a));
// 3. Get active deployments for managed apps
Map<UUID, Deployment> activeDeployments = new HashMap<>();
for (App app : managedApps) {
UUID envId = env != null ? env.id() : app.environmentId();
deploymentRepo.findActiveByAppIdAndEnvironmentId(app.id(), envId)
.ifPresent(d -> activeDeployments.put(app.id(), d));
}
// 4. Get agents, filter by environment
List<AgentInfo> allAgents = registryService.findAll();
if (environment != null && !environment.isBlank()) {
allAgents = allAgents.stream()
.filter(a -> environment.equals(a.environmentId()))
.toList();
}
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
.collect(Collectors.groupingBy(AgentInfo::applicationId, LinkedHashMap::new, Collectors.toList()));
// 5. Collect routes per app from agents
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
for (var entry : agentsByApp.entrySet()) {
Set<String> routes = new LinkedHashSet<>();
for (AgentInfo agent : entry.getValue()) {
if (agent.routeIds() != null) routes.addAll(agent.routeIds());
}
routesByApp.put(entry.getKey(), routes);
}
// 6. ClickHouse exchange counts
Instant now = Instant.now();
Instant rangeFrom = from != null ? Instant.parse(from) : now.minus(24, ChronoUnit.HOURS);
Instant rangeTo = to != null ? Instant.parse(to) : now;
Map<String, Long> routeExchangeCounts = new LinkedHashMap<>();
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
try {
String envFilter = (environment != null && !environment.isBlank())
? " AND environment = " + lit(environment) : "";
jdbc.query(
"SELECT application_id, route_id, countMerge(total_count) AS cnt, MAX(bucket) AS last_seen " +
"FROM stats_1m_route WHERE bucket >= " + lit(rangeFrom) + " AND bucket < " + lit(rangeTo) +
envFilter + " GROUP BY application_id, route_id",
rs -> {
String key = rs.getString("application_id") + "/" + rs.getString("route_id");
routeExchangeCounts.put(key, rs.getLong("cnt"));
Timestamp ts = rs.getTimestamp("last_seen");
if (ts != null) routeLastSeen.put(key, ts.toInstant());
});
} catch (Exception e) {
log.warn("Failed to query route exchange counts: {}", e.getMessage());
}
// Merge ClickHouse routes into routesByApp
for (var countEntry : routeExchangeCounts.entrySet()) {
String[] parts = countEntry.getKey().split("/", 2);
if (parts.length == 2) {
routesByApp.computeIfAbsent(parts[0], k -> new LinkedHashSet<>()).add(parts[1]);
}
}
// 7. Build unified catalog
Set<String> allSlugs = new LinkedHashSet<>(appsBySlug.keySet());
allSlugs.addAll(agentsByApp.keySet());
allSlugs.addAll(routesByApp.keySet());
String envSlug = env != null ? env.slug() : "";
List<CatalogApp> catalog = new ArrayList<>();
for (String slug : allSlugs) {
App app = appsBySlug.get(slug);
List<AgentInfo> agents = agentsByApp.getOrDefault(slug, List.of());
Set<String> routeIds = routesByApp.getOrDefault(slug, Set.of());
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
// Routes
List<RouteSummary> routeSummaries = routeIds.stream()
.map(routeId -> {
String key = slug + "/" + routeId;
long count = routeExchangeCounts.getOrDefault(key, 0L);
Instant lastSeen = routeLastSeen.get(key);
String fromUri = resolveFromEndpointUri(routeId, agentIds);
String state = routeStateRegistry.getState(slug, routeId).name().toLowerCase();
String routeState = "started".equals(state) ? null : state;
return new RouteSummary(routeId, count, lastSeen, fromUri, routeState);
})
.toList();
// Agent summaries
List<AgentSummary> agentSummaries = agents.stream()
.map(a -> new AgentSummary(a.instanceId(), a.displayName(), a.state().name().toLowerCase(), 0.0))
.toList();
// Health
String health = agents.isEmpty() ? "offline" : computeWorstHealth(agents);
// Total exchanges
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
// Deployment summary (managed apps only)
CatalogApp.DeploymentSummary deploymentSummary = null;
if (app != null) {
Deployment dep = activeDeployments.get(app.id());
if (dep != null) {
int healthy = 0, total = 0;
if (dep.replicaStates() != null) {
total = dep.replicaStates().size();
healthy = (int) dep.replicaStates().stream()
.filter(r -> "RUNNING".equals(r.get("status")))
.count();
}
// Get version number from app version
int version = 0;
try {
var versions = appService.listVersions(app.id());
version = versions.stream()
.filter(v -> v.id().equals(dep.appVersionId()))
.map(AppVersion::version)
.findFirst().orElse(0);
} catch (Exception ignored) {}
deploymentSummary = new CatalogApp.DeploymentSummary(
dep.status().name(),
healthy + "/" + total,
version
);
}
}
String displayName = app != null ? app.displayName() : slug;
String appEnvSlug = envSlug;
if (app != null && appEnvSlug.isEmpty()) {
try {
appEnvSlug = envService.getById(app.environmentId()).slug();
} catch (Exception ignored) {}
}
catalog.add(new CatalogApp(
slug, displayName, app != null, appEnvSlug,
health, agents.size(), routeSummaries, agentSummaries,
totalExchanges, deploymentSummary
));
}
return ResponseEntity.ok(catalog);
}
private String resolveFromEndpointUri(String routeId, List<String> agentIds) {
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
.flatMap(diagramStore::findByContentHash)
.map(RouteGraph::getRoot)
.map(root -> root.getEndpointUri())
.orElse(null);
}
private static String lit(Instant instant) {
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(java.time.ZoneOffset.UTC)
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
}
private static String lit(String value) {
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
}
private String computeWorstHealth(List<AgentInfo> agents) {
boolean hasDead = false;
boolean hasStale = false;
for (AgentInfo a : agents) {
if (a.state() == AgentState.DEAD) hasDead = true;
if (a.state() == AgentState.STALE) hasStale = true;
}
if (hasDead) return "dead";
if (hasStale) return "stale";
return "live";
}
}

View File

@@ -1,9 +1,7 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.runtime.DeploymentExecutor;
import com.cameleer3.server.core.runtime.Deployment;
import com.cameleer3.server.core.runtime.DeploymentService;
import com.cameleer3.server.core.runtime.RuntimeOrchestrator;
import com.cameleer3.server.core.runtime.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -22,10 +20,11 @@ import java.util.stream.Collectors;
/**
* Deployment management: deploy, stop, promote, and view logs.
* All app-scoped endpoints accept the app slug (not UUID) as path variable.
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}.
*/
@RestController
@RequestMapping("/api/v1/apps/{appId}/deployments")
@RequestMapping("/api/v1/apps/{appSlug}/deployments")
@Tag(name = "Deployment Management", description = "Deploy, stop, restart, promote, and view logs")
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
public class DeploymentController {
@@ -33,27 +32,35 @@ public class DeploymentController {
private final DeploymentService deploymentService;
private final DeploymentExecutor deploymentExecutor;
private final RuntimeOrchestrator orchestrator;
private final AppService appService;
public DeploymentController(DeploymentService deploymentService,
DeploymentExecutor deploymentExecutor,
RuntimeOrchestrator orchestrator) {
RuntimeOrchestrator orchestrator,
AppService appService) {
this.deploymentService = deploymentService;
this.deploymentExecutor = deploymentExecutor;
this.orchestrator = orchestrator;
this.appService = appService;
}
@GetMapping
@Operation(summary = "List deployments for an app")
@ApiResponse(responseCode = "200", description = "Deployment list returned")
public ResponseEntity<List<Deployment>> listDeployments(@PathVariable UUID appId) {
return ResponseEntity.ok(deploymentService.listByApp(appId));
public ResponseEntity<List<Deployment>> listDeployments(@PathVariable String appSlug) {
try {
App app = appService.getBySlug(appSlug);
return ResponseEntity.ok(deploymentService.listByApp(app.id()));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@GetMapping("/{deploymentId}")
@Operation(summary = "Get deployment by ID")
@ApiResponse(responseCode = "200", description = "Deployment found")
@ApiResponse(responseCode = "404", description = "Deployment not found")
public ResponseEntity<Deployment> getDeployment(@PathVariable UUID appId, @PathVariable UUID deploymentId) {
public ResponseEntity<Deployment> getDeployment(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
try {
return ResponseEntity.ok(deploymentService.getById(deploymentId));
} catch (IllegalArgumentException e) {
@@ -64,17 +71,22 @@ public class DeploymentController {
@PostMapping
@Operation(summary = "Create and start a new deployment")
@ApiResponse(responseCode = "202", description = "Deployment accepted and starting")
public ResponseEntity<Deployment> deploy(@PathVariable UUID appId, @RequestBody DeployRequest request) {
Deployment deployment = deploymentService.createDeployment(appId, request.appVersionId(), request.environmentId());
deploymentExecutor.executeAsync(deployment);
return ResponseEntity.accepted().body(deployment);
public ResponseEntity<Deployment> deploy(@PathVariable String appSlug, @RequestBody DeployRequest request) {
try {
App app = appService.getBySlug(appSlug);
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), request.environmentId());
deploymentExecutor.executeAsync(deployment);
return ResponseEntity.accepted().body(deployment);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/{deploymentId}/stop")
@Operation(summary = "Stop a running deployment")
@ApiResponse(responseCode = "200", description = "Deployment stopped")
@ApiResponse(responseCode = "404", description = "Deployment not found")
public ResponseEntity<Deployment> stop(@PathVariable UUID appId, @PathVariable UUID deploymentId) {
public ResponseEntity<Deployment> stop(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
try {
Deployment deployment = deploymentService.getById(deploymentId);
deploymentExecutor.stopDeployment(deployment);
@@ -88,11 +100,12 @@ public class DeploymentController {
@Operation(summary = "Promote deployment to a different environment")
@ApiResponse(responseCode = "202", description = "Promotion accepted and starting")
@ApiResponse(responseCode = "404", description = "Deployment not found")
public ResponseEntity<Deployment> promote(@PathVariable UUID appId, @PathVariable UUID deploymentId,
public ResponseEntity<Deployment> promote(@PathVariable String appSlug, @PathVariable UUID deploymentId,
@RequestBody PromoteRequest request) {
try {
App app = appService.getBySlug(appSlug);
Deployment source = deploymentService.getById(deploymentId);
Deployment promoted = deploymentService.promote(appId, source.appVersionId(), request.targetEnvironmentId());
Deployment promoted = deploymentService.promote(app.id(), source.appVersionId(), request.targetEnvironmentId());
deploymentExecutor.executeAsync(promoted);
return ResponseEntity.accepted().body(promoted);
} catch (IllegalArgumentException e) {
@@ -104,7 +117,7 @@ public class DeploymentController {
@Operation(summary = "Get container logs for a deployment")
@ApiResponse(responseCode = "200", description = "Logs returned")
@ApiResponse(responseCode = "404", description = "Deployment not found or no container")
public ResponseEntity<List<String>> getLogs(@PathVariable UUID appId, @PathVariable UUID deploymentId) {
public ResponseEntity<List<String>> getLogs(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
try {
Deployment deployment = deploymentService.getById(deploymentId);
if (deployment.containerId() == null) {

View File

@@ -0,0 +1,25 @@
package com.cameleer3.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
@Schema(description = "Unified catalog entry combining app records with live agent data")
public record CatalogApp(
@Schema(description = "Application slug (universal identifier)") String slug,
@Schema(description = "Display name") String displayName,
@Schema(description = "True if a managed App record exists in the database") boolean managed,
@Schema(description = "Environment slug") String environmentSlug,
@Schema(description = "Worst health state among agents: live, stale, dead, offline") String health,
@Schema(description = "Number of connected agents") int agentCount,
@Schema(description = "Live routes from agents") List<RouteSummary> routes,
@Schema(description = "Connected agent summaries") List<AgentSummary> agents,
@Schema(description = "Total exchange count from ClickHouse") long exchangeCount,
@Schema(description = "Active deployment info, null if no deployment") DeploymentSummary deployment
) {
public record DeploymentSummary(
String status,
String replicas,
int version
) {}
}

View File

@@ -47,6 +47,14 @@ public class PostgresAppRepository implements AppRepository {
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public Optional<App> findBySlug(String slug) {
var results = jdbc.query(
"SELECT id, environment_id, slug, display_name, container_config, created_at, updated_at FROM apps WHERE slug = ? LIMIT 1",
(rs, rowNum) -> mapRow(rs), slug);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public UUID create(UUID environmentId, String slug, String displayName) {
UUID id = UUID.randomUUID();

View File

@@ -10,6 +10,7 @@ public interface AppRepository {
List<App> findAll();
Optional<App> findById(UUID id);
Optional<App> findByEnvironmentIdAndSlug(UUID environmentId, String slug);
Optional<App> findBySlug(String slug);
UUID create(UUID environmentId, String slug, String displayName);
void updateContainerConfig(UUID id, Map<String, Object> containerConfig);
void delete(UUID id);

View File

@@ -29,6 +29,7 @@ public class AppService {
public List<App> listAll() { return appRepo.findAll(); }
public List<App> listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); }
public App getById(UUID id) { return appRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("App not found: " + id)); }
public App getBySlug(String slug) { return appRepo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("App not found: " + slug)); }
public List<AppVersion> listVersions(UUID appId) { return versionRepo.findByAppId(appId); }
public void updateContainerConfig(UUID id, Map<String, Object> containerConfig) {

View File

@@ -92,9 +92,12 @@ export function useCreateApp() {
export function useDeleteApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
appFetch<void>(`/${id}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
mutationFn: (slug: string) =>
appFetch<void>(`/${slug}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['apps'] });
qc.invalidateQueries({ queryKey: ['catalog'] });
},
});
}

View File

@@ -3,25 +3,57 @@ import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
import { useRefreshInterval } from './use-refresh-interval';
export function useRouteCatalog(from?: string, to?: string, environment?: string) {
export interface CatalogRoute {
routeId: string;
exchangeCount: number;
lastSeen: string | null;
fromEndpointUri: string | null;
routeState: string | null;
}
export interface CatalogAgent {
instanceId: string;
displayName: string;
state: string;
tps: number;
}
export interface DeploymentSummary {
status: string;
replicas: string;
version: number;
}
export interface CatalogApp {
slug: string;
displayName: string;
managed: boolean;
environmentSlug: string;
health: 'live' | 'stale' | 'dead' | 'offline';
agentCount: number;
routes: CatalogRoute[];
agents: CatalogAgent[];
exchangeCount: number;
deployment: DeploymentSummary | null;
}
export function useCatalog(environment?: string) {
const refetchInterval = useRefreshInterval(15_000);
return useQuery({
queryKey: ['routes', 'catalog', from, to, environment],
queryKey: ['catalog', environment],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
if (environment) params.set('environment', environment);
const qs = params.toString();
const res = await fetch(`${config.apiBaseUrl}/routes/catalog${qs ? `?${qs}` : ''}`, {
const res = await fetch(`${config.apiBaseUrl}/catalog${qs ? `?${qs}` : ''}`, {
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
},
});
if (!res.ok) throw new Error('Failed to load route catalog');
return res.json();
if (!res.ok) throw new Error('Failed to load catalog');
return res.json() as Promise<CatalogApp[]>;
},
placeholderData: (prev) => prev,
refetchInterval,

View File

@@ -21,7 +21,7 @@ import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User } f
import { AboutMeDialog } from './AboutMeDialog';
import css from './LayoutShell.module.css';
import { useQueryClient } from '@tanstack/react-query';
import { useRouteCatalog } from '../api/queries/catalog';
import { useCatalog } from '../api/queries/catalog';
import { useAgents } from '../api/queries/agents';
import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
import { useUsers, useGroups, useRoles } from '../api/queries/admin/rbac';
@@ -56,24 +56,26 @@ function buildSearchData(
const results: SearchResult[] = [];
for (const app of catalog) {
const slug = app.slug || app.appId;
const name = app.displayName || slug;
const liveAgents = (app.agents || []).filter((a: any) => a.status === 'live').length;
results.push({
id: app.appId,
id: slug,
category: 'application',
title: app.appId,
title: name,
badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToSearchColor(app.health) }],
meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`,
path: `/exchanges/${app.appId}`,
path: `/exchanges/${slug}`,
});
for (const route of (app.routes || [])) {
results.push({
id: `${app.appId}/${route.routeId}`,
id: `${slug}/${route.routeId}`,
category: 'route',
title: route.routeId,
badges: [{ label: app.appId }],
badges: [{ label: name }],
meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`,
path: `/exchanges/${app.appId}/${route.routeId}`,
path: `/exchanges/${slug}/${route.routeId}`,
});
}
}
@@ -288,7 +290,7 @@ function LayoutContent() {
const selectedEnv = useEnvironmentStore((s) => s.environment);
const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment);
const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString(), selectedEnv);
const { data: catalog } = useCatalog(selectedEnv);
const { data: allAgents } = useAgents(); // unfiltered — for environment discovery
const { data: agents } = useAgents(undefined, undefined, selectedEnv); // filtered — for sidebar/search
const { data: attributeKeys } = useAttributeKeys();
@@ -397,11 +399,11 @@ function LayoutContent() {
if (!catalog) return [];
const cmp = (a: string, b: string) => a.localeCompare(b);
return [...catalog]
.sort((a: any, b: any) => cmp(a.appId, b.appId))
.sort((a: any, b: any) => cmp(a.slug, b.slug))
.map((app: any) => ({
id: app.appId,
name: app.appId,
health: app.health as 'live' | 'stale' | 'dead',
id: app.slug,
name: app.displayName || app.slug,
health: (app.health === 'offline' ? 'dead' : app.health) as 'live' | 'stale' | 'dead',
exchangeCount: app.exchangeCount,
routes: [...(app.routes || [])]
.sort((a: any, b: any) => cmp(a.routeId, b.routeId))

View File

@@ -7,8 +7,8 @@ import {
import type { Column } from '@cameleer/design-system';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
import type { ApplicationConfig, TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
import { useRouteCatalog } from '../../api/queries/catalog';
import type { AppCatalogEntry, RouteSummary } from '../../api/types';
import { useCatalog } from '../../api/queries/catalog';
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
import styles from './AppConfigDetailPage.module.css';
type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto';
@@ -75,7 +75,7 @@ export default function AppConfigDetailPage() {
const { toast } = useToast();
const { data: config, isLoading } = useApplicationConfig(appId);
const updateConfig = useUpdateApplicationConfig();
const { data: catalog } = useRouteCatalog();
const { data: catalog } = useCatalog();
const [editing, setEditing] = useState(false);
const [form, setForm] = useState<Partial<ApplicationConfig> | null>(null);
@@ -83,9 +83,9 @@ export default function AppConfigDetailPage() {
const [routeRecordingDraft, setRouteRecordingDraft] = useState<Record<string, boolean>>({});
// Find routes for this application from the catalog
const appRoutes: RouteSummary[] = useMemo(() => {
const appRoutes: CatalogRoute[] = useMemo(() => {
if (!catalog || !appId) return [];
const entry = (catalog as AppCatalogEntry[]).find((e) => e.appId === appId);
const entry = (catalog as CatalogApp[]).find((e) => e.slug === appId);
return entry?.routes ?? [];
}, [catalog, appId]);

View File

@@ -32,8 +32,8 @@ import type { App, AppVersion, Deployment } from '../../api/queries/admin/apps';
import type { Environment } from '../../api/queries/admin/environments';
import { useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } from '../../api/queries/commands';
import type { ApplicationConfig, TapDefinition } from '../../api/queries/commands';
import { useRouteCatalog } from '../../api/queries/catalog';
import type { AppCatalogEntry, RouteSummary } from '../../api/types';
import { useCatalog } from '../../api/queries/catalog';
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
import { DeploymentProgress } from '../../components/DeploymentProgress';
import styles from './AppsTab.module.css';
@@ -122,7 +122,7 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde
<div className={styles.toolbar}>
<Button size="sm" variant="primary" onClick={() => navigate('/apps/new')}>+ Create App</Button>
</div>
<DataTable columns={columns} data={rows} onRowClick={(row) => navigate(`/apps/${row.id}`)} />
<DataTable columns={columns} data={rows} onRowClick={(row) => navigate(`/apps/${row.slug}`)} />
</div>
);
}
@@ -217,7 +217,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
// 2. Upload JAR
setStep('Uploading JAR...');
const version = await uploadJar.mutateAsync({ appId: app.id, file: file! });
const version = await uploadJar.mutateAsync({ appId: app.slug, file: file! });
// 3. Save container config
setStep('Saving configuration...');
@@ -234,7 +234,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
stripPathPrefix: stripPrefix,
sslOffloading: sslOffloading,
};
await updateContainerConfig.mutateAsync({ appId: app.id, config: containerConfig });
await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig });
// 4. Save agent config (will be pushed to agent on first connect)
setStep('Saving monitoring config...');
@@ -257,11 +257,11 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
// 5. Deploy (if requested)
if (deploy) {
setStep('Starting deployment...');
await createDeployment.mutateAsync({ appId: app.id, appVersionId: version.id, environmentId: envId });
await createDeployment.mutateAsync({ appId: app.slug, appVersionId: version.id, environmentId: envId });
}
toast({ title: deploy ? 'App created and deployed' : 'App created', description: name.trim(), variant: 'success' });
navigate(`/apps/${app.id}`);
navigate(`/apps/${app.slug}`);
} catch (e) {
toast({ title: 'Failed: ' + step, description: e instanceof Error ? e.message : 'Unknown error', variant: 'error', duration: 86_400_000 });
} finally {
@@ -476,13 +476,13 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
// DETAIL VIEW
// ═══════════════════════════════════════════════════════════════════
function AppDetailView({ appId, environments, selectedEnv }: { appId: string; environments: Environment[]; selectedEnv: string | undefined }) {
function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: string; environments: Environment[]; selectedEnv: string | undefined }) {
const { toast } = useToast();
const navigate = useNavigate();
const { data: allApps = [] } = useAllApps();
const app = useMemo(() => allApps.find((a) => a.id === appId), [allApps, appId]);
const { data: versions = [] } = useAppVersions(appId);
const { data: deployments = [] } = useDeployments(appId);
const app = useMemo(() => allApps.find((a) => a.slug === appSlug), [allApps, appSlug]);
const { data: versions = [] } = useAppVersions(appSlug);
const { data: deployments = [] } = useDeployments(appSlug);
const uploadJar = useUploadJar();
const createDeployment = useCreateDeployment();
const stopDeployment = useStopDeployment();
@@ -502,7 +502,7 @@ function AppDetailView({ appId, environments, selectedEnv }: { appId: string; en
const file = e.target.files?.[0];
if (!file) return;
try {
const v = await uploadJar.mutateAsync({ appId, file });
const v = await uploadJar.mutateAsync({ appId: appSlug, file });
toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' });
} catch { toast({ title: 'Upload failed', variant: 'error', duration: 86_400_000 }); }
if (fileInputRef.current) fileInputRef.current.value = '';
@@ -510,21 +510,21 @@ function AppDetailView({ appId, environments, selectedEnv }: { appId: string; en
async function handleDeploy(versionId: string, environmentId: string) {
try {
await createDeployment.mutateAsync({ appId, appVersionId: versionId, environmentId });
await createDeployment.mutateAsync({ appId: appSlug, appVersionId: versionId, environmentId });
toast({ title: 'Deployment started', variant: 'success' });
} catch { toast({ title: 'Deploy failed', variant: 'error', duration: 86_400_000 }); }
}
async function handleStop(deploymentId: string) {
try {
await stopDeployment.mutateAsync({ appId, deploymentId });
await stopDeployment.mutateAsync({ appId: appSlug, deploymentId });
toast({ title: 'Deployment stopped', variant: 'warning' });
} catch { toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 }); }
}
async function handleDelete() {
try {
await deleteApp.mutateAsync(appId);
await deleteApp.mutateAsync(appSlug);
toast({ title: 'App deleted', variant: 'warning' });
navigate('/apps');
} catch { toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 }); }
@@ -698,15 +698,15 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
const { data: agentConfig } = useApplicationConfig(app.slug);
const updateAgentConfig = useUpdateApplicationConfig();
const updateContainerConfig = useUpdateContainerConfig();
const { data: catalog } = useRouteCatalog();
const { data: catalog } = useCatalog();
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug);
const isProd = environment?.production ?? false;
const [editing, setEditing] = useState(false);
const [configTab, setConfigTab] = useState<'variables' | 'monitoring' | 'traces' | 'recording' | 'resources'>('variables');
const appRoutes: RouteSummary[] = useMemo(() => {
const appRoutes: CatalogRoute[] = useMemo(() => {
if (!catalog) return [];
const entry = (catalog as AppCatalogEntry[]).find((e) => e.appId === app.slug);
const entry = (catalog as CatalogApp[]).find((e) => e.slug === app.slug);
return entry?.routes ?? [];
}, [catalog, app.slug]);
@@ -819,7 +819,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
sslOffloading: sslOffloading,
};
try {
await updateContainerConfig.mutateAsync({ appId: app.id, config: containerConfig });
await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig });
toast({ title: 'Configuration saved', variant: 'success' });
setEditing(false);
} catch { toast({ title: 'Failed to save container config', variant: 'error', duration: 86_400_000 }); }

View File

@@ -4,7 +4,7 @@ import { GitBranch, Server, RotateCcw, FileText } from 'lucide-react';
import { StatusDot, MonoText, Badge, useGlobalFilters } from '@cameleer/design-system';
import { useCorrelationChain } from '../../api/queries/correlation';
import { useAgents } from '../../api/queries/agents';
import { useRouteCatalog } from '../../api/queries/catalog';
import { useCatalog } from '../../api/queries/catalog';
import { useCanControl } from '../../auth/auth-store';
import type { ExecutionDetail } from '../../components/ExecutionDiagram/types';
import { attributeBadgeColor } from '../../utils/attribute-color';
@@ -52,11 +52,11 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
const attrs = Object.entries(detail.attributes ?? {});
// Look up route state from catalog
const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString());
const { data: catalog } = useCatalog();
const routeState = useMemo(() => {
if (!catalog) return undefined;
for (const app of catalog as any[]) {
if (app.appId !== detail.applicationId) continue;
if (app.slug !== detail.applicationId) continue;
for (const route of app.routes || []) {
if (route.routeId === detail.routeId) {
return (route.routeState ?? 'started') as 'started' | 'stopped' | 'suspended';

View File

@@ -3,7 +3,7 @@ import { useNavigate, useLocation, useParams } from 'react-router';
import { useGlobalFilters, useToast } from '@cameleer/design-system';
import { useExecutionDetail } from '../../api/queries/executions';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useRouteCatalog } from '../../api/queries/catalog';
import { useCatalog } from '../../api/queries/catalog';
import { useAgents } from '../../api/queries/agents';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
import type { TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
@@ -150,7 +150,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
const { data: detail } = useExecutionDetail(exchangeId ?? null);
const diagramQuery = useDiagramByRoute(appId, routeId);
const { data: catalog } = useRouteCatalog(timeFrom, timeTo);
const { data: catalog } = useCatalog();
// Route state + capabilities for topology-only control bar
const { data: agents } = useAgents(undefined, appId);
@@ -166,7 +166,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
const routeState = useMemo(() => {
if (!catalog) return undefined;
for (const app of catalog as any[]) {
if (app.applicationId !== appId) continue;
if (app.slug !== appId) continue;
for (const r of app.routes || []) {
if (r.routeId === routeId) return (r.routeState ?? 'started') as 'started' | 'stopped' | 'suspended';
}

View File

@@ -26,13 +26,14 @@ import {
} from '@cameleer/design-system';
import type { KpiItem, Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useRouteCatalog } from '../../api/queries/catalog';
import { useCatalog } from '../../api/queries/catalog';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
import { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../../api/queries/executions';
import { useApplicationConfig, useUpdateApplicationConfig, useTestExpression } from '../../api/queries/commands';
import type { TapDefinition } from '../../api/queries/commands';
import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
import type { ExecutionSummary } from '../../api/types';
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
import { buildFlowSegments } from '../../utils/diagram-mapping';
import styles from './RouteDetail.module.css';
@@ -300,7 +301,7 @@ export default function RouteDetail() {
}, []);
// ── API queries ────────────────────────────────────────────────────────────
const { data: catalog } = useRouteCatalog();
const { data: catalog } = useCatalog();
const { data: diagram } = useDiagramByRoute(appId, routeId);
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId);
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
@@ -340,13 +341,13 @@ export default function RouteDetail() {
// ── Derived data ───────────────────────────────────────────────────────────
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
const appEntry: CatalogApp | undefined = useMemo(() =>
(catalog || []).find((e: CatalogApp) => e.slug === appId),
[catalog, appId],
);
const routeSummary: RouteSummary | undefined = useMemo(() =>
appEntry?.routes?.find((r: RouteSummary) => r.routeId === routeId),
const routeSummary: CatalogRoute | undefined = useMemo(() =>
appEntry?.routes?.find((r: CatalogRoute) => r.routeId === routeId),
[appEntry, routeId],
);