From b86e95f08e63c3969b26831be0901dfbf5627e99 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:43:14 +0200 Subject: [PATCH] feat: unified catalog endpoint and slug-based app navigation 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) --- .../server/app/controller/AppController.java | 49 ++-- .../app/controller/CatalogController.java | 266 ++++++++++++++++++ .../app/controller/DeploymentController.java | 45 +-- .../cameleer3/server/app/dto/CatalogApp.java | 25 ++ .../app/storage/PostgresAppRepository.java | 8 + .../server/core/runtime/AppRepository.java | 1 + .../server/core/runtime/AppService.java | 1 + ui/src/api/queries/admin/apps.ts | 9 +- ui/src/api/queries/catalog.ts | 46 ++- ui/src/components/LayoutShell.tsx | 26 +- ui/src/pages/Admin/AppConfigDetailPage.tsx | 10 +- ui/src/pages/AppsTab/AppsTab.tsx | 38 +-- ui/src/pages/Exchanges/ExchangeHeader.tsx | 6 +- ui/src/pages/Exchanges/ExchangesPage.tsx | 6 +- ui/src/pages/Routes/RouteDetail.tsx | 15 +- 15 files changed, 458 insertions(+), 93 deletions(-) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/CatalogController.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CatalogApp.java diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java index e3d3fed6..f5e6b42e 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java @@ -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 getApp(@PathVariable UUID appId) { + public ResponseEntity 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> listVersions(@PathVariable UUID appId) { - return ResponseEntity.ok(appService.listVersions(appId)); + public ResponseEntity> 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 uploadJar(@PathVariable UUID appId, + public ResponseEntity 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 deleteApp(@PathVariable UUID appId) { - appService.deleteApp(appId); - return ResponseEntity.noContent().build(); + public ResponseEntity 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 updateContainerConfig(@PathVariable UUID appId, + public ResponseEntity updateContainerConfig(@PathVariable String appSlug, @RequestBody Map 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(); } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/CatalogController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/CatalogController.java new file mode 100644 index 00000000..1e93cd6d --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/CatalogController.java @@ -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> 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 managedApps = env != null + ? appService.listByEnvironment(env.id()) + : appService.listAll(); + Map appsBySlug = managedApps.stream() + .collect(Collectors.toMap(App::slug, a -> a, (a, b) -> a)); + + // 3. Get active deployments for managed apps + Map 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 allAgents = registryService.findAll(); + if (environment != null && !environment.isBlank()) { + allAgents = allAgents.stream() + .filter(a -> environment.equals(a.environmentId())) + .toList(); + } + Map> agentsByApp = allAgents.stream() + .collect(Collectors.groupingBy(AgentInfo::applicationId, LinkedHashMap::new, Collectors.toList())); + + // 5. Collect routes per app from agents + Map> routesByApp = new LinkedHashMap<>(); + for (var entry : agentsByApp.entrySet()) { + Set 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 routeExchangeCounts = new LinkedHashMap<>(); + Map 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 allSlugs = new LinkedHashSet<>(appsBySlug.keySet()); + allSlugs.addAll(agentsByApp.keySet()); + allSlugs.addAll(routesByApp.keySet()); + + String envSlug = env != null ? env.slug() : ""; + List catalog = new ArrayList<>(); + + for (String slug : allSlugs) { + App app = appsBySlug.get(slug); + List agents = agentsByApp.getOrDefault(slug, List.of()); + Set routeIds = routesByApp.getOrDefault(slug, Set.of()); + List agentIds = agents.stream().map(AgentInfo::instanceId).toList(); + + // Routes + List 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 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 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 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"; + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java index 7ba86ad2..83e6fbcd 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java @@ -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> listDeployments(@PathVariable UUID appId) { - return ResponseEntity.ok(deploymentService.listByApp(appId)); + public ResponseEntity> 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 getDeployment(@PathVariable UUID appId, @PathVariable UUID deploymentId) { + public ResponseEntity 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 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 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 stop(@PathVariable UUID appId, @PathVariable UUID deploymentId) { + public ResponseEntity 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 promote(@PathVariable UUID appId, @PathVariable UUID deploymentId, + public ResponseEntity 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> getLogs(@PathVariable UUID appId, @PathVariable UUID deploymentId) { + public ResponseEntity> getLogs(@PathVariable String appSlug, @PathVariable UUID deploymentId) { try { Deployment deployment = deploymentService.getById(deploymentId); if (deployment.containerId() == null) { diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CatalogApp.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CatalogApp.java new file mode 100644 index 00000000..82f826ab --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CatalogApp.java @@ -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 routes, + @Schema(description = "Connected agent summaries") List 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 + ) {} +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppRepository.java index 1069e0f6..b963682f 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppRepository.java @@ -47,6 +47,14 @@ public class PostgresAppRepository implements AppRepository { return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); } + @Override + public Optional 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(); diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppRepository.java index 3e789c61..443ac0dd 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppRepository.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppRepository.java @@ -10,6 +10,7 @@ public interface AppRepository { List findAll(); Optional findById(UUID id); Optional findByEnvironmentIdAndSlug(UUID environmentId, String slug); + Optional findBySlug(String slug); UUID create(UUID environmentId, String slug, String displayName); void updateContainerConfig(UUID id, Map containerConfig); void delete(UUID id); diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java index 09d1b72b..e3e11c9b 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java @@ -29,6 +29,7 @@ public class AppService { public List listAll() { return appRepo.findAll(); } public List 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 listVersions(UUID appId) { return versionRepo.findByAppId(appId); } public void updateContainerConfig(UUID id, Map containerConfig) { diff --git a/ui/src/api/queries/admin/apps.ts b/ui/src/api/queries/admin/apps.ts index 792dd170..9a309229 100644 --- a/ui/src/api/queries/admin/apps.ts +++ b/ui/src/api/queries/admin/apps.ts @@ -92,9 +92,12 @@ export function useCreateApp() { export function useDeleteApp() { const qc = useQueryClient(); return useMutation({ - mutationFn: (id: string) => - appFetch(`/${id}`, { method: 'DELETE' }), - onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }), + mutationFn: (slug: string) => + appFetch(`/${slug}`, { method: 'DELETE' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['apps'] }); + qc.invalidateQueries({ queryKey: ['catalog'] }); + }, }); } diff --git a/ui/src/api/queries/catalog.ts b/ui/src/api/queries/catalog.ts index bb74abbd..1ce859e5 100644 --- a/ui/src/api/queries/catalog.ts +++ b/ui/src/api/queries/catalog.ts @@ -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; }, placeholderData: (prev) => prev, refetchInterval, diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index a6275a1e..423fee78 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -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)) diff --git a/ui/src/pages/Admin/AppConfigDetailPage.tsx b/ui/src/pages/Admin/AppConfigDetailPage.tsx index 908670b9..5df825f7 100644 --- a/ui/src/pages/Admin/AppConfigDetailPage.tsx +++ b/ui/src/pages/Admin/AppConfigDetailPage.tsx @@ -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 | null>(null); @@ -83,9 +83,9 @@ export default function AppConfigDetailPage() { const [routeRecordingDraft, setRouteRecordingDraft] = useState>({}); // 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]); diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index 474886ff..e06dd8bb 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -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
- navigate(`/apps/${row.id}`)} /> + navigate(`/apps/${row.slug}`)} /> ); } @@ -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 }); } diff --git a/ui/src/pages/Exchanges/ExchangeHeader.tsx b/ui/src/pages/Exchanges/ExchangeHeader.tsx index da53cac6..c5b2166e 100644 --- a/ui/src/pages/Exchanges/ExchangeHeader.tsx +++ b/ui/src/pages/Exchanges/ExchangeHeader.tsx @@ -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'; diff --git a/ui/src/pages/Exchanges/ExchangesPage.tsx b/ui/src/pages/Exchanges/ExchangesPage.tsx index e046f48f..29d13fae 100644 --- a/ui/src/pages/Exchanges/ExchangesPage.tsx +++ b/ui/src/pages/Exchanges/ExchangesPage.tsx @@ -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'; } diff --git a/ui/src/pages/Routes/RouteDetail.tsx b/ui/src/pages/Routes/RouteDetail.tsx index 85405b38..c0af7b37 100644 --- a/ui/src/pages/Routes/RouteDetail.tsx +++ b/ui/src/pages/Routes/RouteDetail.tsx @@ -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], );