feat: unified catalog endpoint and slug-based app navigation
All checks were successful
All checks were successful
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 }); }
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user