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();