diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentEventsController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentEventsController.java index 36214d7a..119aea41 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentEventsController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentEventsController.java @@ -1,7 +1,9 @@ package com.cameleer.server.app.controller; import com.cameleer.server.app.dto.AgentEventResponse; +import com.cameleer.server.app.web.EnvPath; import com.cameleer.server.core.agent.AgentEventService; +import com.cameleer.server.core.runtime.Environment; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; @@ -15,8 +17,8 @@ import java.time.Instant; import java.util.List; @RestController -@RequestMapping("/api/v1/agents/events-log") -@Tag(name = "Agent Events", description = "Agent lifecycle event log") +@RequestMapping("/api/v1/environments/{envSlug}/agents/events") +@Tag(name = "Agent Events", description = "Agent lifecycle event log (env-scoped)") public class AgentEventsController { private final AgentEventService agentEventService; @@ -26,13 +28,13 @@ public class AgentEventsController { } @GetMapping - @Operation(summary = "Query agent events", + @Operation(summary = "Query agent events in this environment", description = "Returns agent lifecycle events, optionally filtered by app and/or agent ID") @ApiResponse(responseCode = "200", description = "Events returned") public ResponseEntity> getEvents( + @EnvPath Environment env, @RequestParam(required = false) String appId, @RequestParam(required = false) String agentId, - @RequestParam(required = false) String environment, @RequestParam(required = false) String from, @RequestParam(required = false) String to, @RequestParam(defaultValue = "50") int limit) { @@ -40,7 +42,7 @@ public class AgentEventsController { Instant fromInstant = from != null ? Instant.parse(from) : null; Instant toInstant = to != null ? Instant.parse(to) : null; - var events = agentEventService.queryEvents(appId, agentId, environment, fromInstant, toInstant, limit) + var events = agentEventService.queryEvents(appId, agentId, env.slug(), fromInstant, toInstant, limit) .stream() .map(AgentEventResponse::from) .toList(); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentListController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentListController.java new file mode 100644 index 00000000..31962ac7 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentListController.java @@ -0,0 +1,163 @@ +package com.cameleer.server.app.controller; + +import com.cameleer.server.app.dto.AgentInstanceResponse; +import com.cameleer.server.app.dto.ErrorResponse; +import com.cameleer.server.app.web.EnvPath; +import com.cameleer.server.core.agent.AgentInfo; +import com.cameleer.server.core.agent.AgentRegistryService; +import com.cameleer.server.core.agent.AgentState; +import com.cameleer.server.core.runtime.Environment; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +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.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Read-only user-facing list of agents in an environment. Agent self-service + * endpoints (register/heartbeat/refresh/deregister/events/commands) remain + * flat at /api/v1/agents/... — those are JWT-authoritative and env is + * derived from the token. + */ +@RestController +@RequestMapping("/api/v1/environments/{envSlug}/agents") +@Tag(name = "Agent List", description = "List registered agents in an environment") +public class AgentListController { + + private static final Logger log = LoggerFactory.getLogger(AgentListController.class); + + private final AgentRegistryService registryService; + private final JdbcTemplate jdbc; + + public AgentListController(AgentRegistryService registryService, + @org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc) { + this.registryService = registryService; + this.jdbc = jdbc; + } + + @GetMapping + @Operation(summary = "List all agents in this environment", + description = "Returns registered agents with runtime metrics, optionally filtered by status and/or application") + @ApiResponse(responseCode = "200", description = "Agent list returned") + @ApiResponse(responseCode = "400", description = "Invalid status filter", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + public ResponseEntity> listAgents( + @EnvPath Environment env, + @RequestParam(required = false) String status, + @RequestParam(required = false) String application) { + List agents; + + if (status != null) { + try { + AgentState stateFilter = AgentState.valueOf(status.toUpperCase()); + agents = registryService.findByState(stateFilter); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } else { + agents = registryService.findAll(); + } + + // Filter by env (from path — always applied) + agents = agents.stream() + .filter(a -> env.slug().equals(a.environmentId())) + .toList(); + + if (application != null && !application.isBlank()) { + agents = agents.stream() + .filter(a -> application.equals(a.applicationId())) + .toList(); + } + + Map agentMetrics = queryAgentMetrics(); + Map cpuByInstance = queryAgentCpuUsage(); + final List finalAgents = agents; + + List response = finalAgents.stream() + .map(a -> { + AgentInstanceResponse dto = AgentInstanceResponse.from(a); + double[] m = agentMetrics.get(a.applicationId()); + if (m != null) { + long appAgentCount = finalAgents.stream() + .filter(ag -> ag.applicationId().equals(a.applicationId())).count(); + double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0; + double errorRate = m[1]; + int activeRoutes = (int) m[2]; + dto = dto.withMetrics(agentTps, errorRate, activeRoutes); + } + Double cpu = cpuByInstance.get(a.instanceId()); + if (cpu != null) { + dto = dto.withCpuUsage(cpu); + } + return dto; + }) + .toList(); + return ResponseEntity.ok(response); + } + + private Map queryAgentMetrics() { + Map result = new HashMap<>(); + Instant now = Instant.now(); + Instant from1m = now.minus(1, ChronoUnit.MINUTES); + try { + jdbc.query( + "SELECT application_id, " + + "uniqMerge(total_count) AS total, " + + "uniqIfMerge(failed_count) AS failed, " + + "COUNT(DISTINCT route_id) AS active_routes " + + "FROM stats_1m_route WHERE bucket >= " + lit(from1m) + " AND bucket < " + lit(now) + + " GROUP BY application_id", + rs -> { + long total = rs.getLong("total"); + long failed = rs.getLong("failed"); + double tps = total / 60.0; + double errorRate = total > 0 ? (double) failed / total : 0.0; + int activeRoutes = rs.getInt("active_routes"); + result.put(rs.getString("application_id"), new double[]{tps, errorRate, activeRoutes}); + }); + } catch (Exception e) { + log.debug("Could not query agent metrics: {}", e.getMessage()); + } + return result; + } + + private Map queryAgentCpuUsage() { + Map result = new HashMap<>(); + Instant now = Instant.now(); + Instant from2m = now.minus(2, ChronoUnit.MINUTES); + try { + jdbc.query( + "SELECT instance_id, avg(metric_value) AS cpu_avg " + + "FROM agent_metrics " + + "WHERE metric_name = 'process.cpu.usage.value'" + + " AND collected_at >= " + lit(from2m) + " AND collected_at < " + lit(now) + + " GROUP BY instance_id", + rs -> { + result.put(rs.getString("instance_id"), rs.getDouble("cpu_avg")); + }); + } catch (Exception e) { + log.debug("Could not query agent CPU usage: {}", e.getMessage()); + } + return result; + } + + 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)) + "'"; + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentMetricsController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentMetricsController.java index 640afde6..74b61683 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentMetricsController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentMetricsController.java @@ -2,8 +2,13 @@ package com.cameleer.server.app.controller; import com.cameleer.server.app.dto.AgentMetricsResponse; import com.cameleer.server.app.dto.MetricBucket; +import com.cameleer.server.app.web.EnvPath; +import com.cameleer.server.core.agent.AgentInfo; +import com.cameleer.server.core.agent.AgentRegistryService; +import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.storage.MetricsQueryStore; import com.cameleer.server.core.storage.model.MetricTimeSeries; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.time.Instant; @@ -12,17 +17,21 @@ import java.util.*; import java.util.stream.Collectors; @RestController -@RequestMapping("/api/v1/agents/{agentId}/metrics") +@RequestMapping("/api/v1/environments/{envSlug}/agents/{agentId}/metrics") public class AgentMetricsController { private final MetricsQueryStore metricsQueryStore; + private final AgentRegistryService registryService; - public AgentMetricsController(MetricsQueryStore metricsQueryStore) { + public AgentMetricsController(MetricsQueryStore metricsQueryStore, + AgentRegistryService registryService) { this.metricsQueryStore = metricsQueryStore; + this.registryService = registryService; } @GetMapping - public AgentMetricsResponse getMetrics( + public ResponseEntity getMetrics( + @EnvPath Environment env, @PathVariable String agentId, @RequestParam String names, @RequestParam(required = false) Instant from, @@ -30,6 +39,13 @@ public class AgentMetricsController { @RequestParam(defaultValue = "60") int buckets, @RequestParam(defaultValue = "gauge") String mode) { + // Defence in depth: if the agent is currently in the registry, reject + // requests that cross-env (path env doesn't match the agent's env). + AgentInfo agent = registryService.findById(agentId); + if (agent != null && !env.slug().equals(agent.environmentId())) { + return ResponseEntity.notFound().build(); + } + if (from == null) from = Instant.now().minus(1, ChronoUnit.HOURS); if (to == null) to = Instant.now(); @@ -48,6 +64,6 @@ public class AgentMetricsController { (a, b) -> a, LinkedHashMap::new)); - return new AgentMetricsResponse(result); + return ResponseEntity.ok(new AgentMetricsResponse(result)); } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java index 29f504a6..164f2274 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java @@ -321,123 +321,7 @@ public class AgentRegistrationController { return ResponseEntity.ok().build(); } - @GetMapping - @Operation(summary = "List all agents", - description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or application") - @ApiResponse(responseCode = "200", description = "Agent list returned") - @ApiResponse(responseCode = "400", description = "Invalid status filter", - content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - public ResponseEntity> listAgents( - @RequestParam(required = false) String status, - @RequestParam(required = false) String application, - @RequestParam(required = false) String environment) { - List agents; - - if (status != null) { - try { - AgentState stateFilter = AgentState.valueOf(status.toUpperCase()); - agents = registryService.findByState(stateFilter); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().build(); - } - } else { - agents = registryService.findAll(); - } - - // Apply application filter if specified - if (application != null && !application.isBlank()) { - agents = agents.stream() - .filter(a -> application.equals(a.applicationId())) - .toList(); - } - - // Apply environment filter if specified - if (environment != null && !environment.isBlank()) { - agents = agents.stream() - .filter(a -> environment.equals(a.environmentId())) - .toList(); - } - - // Enrich with runtime metrics from continuous aggregates - Map agentMetrics = queryAgentMetrics(); - Map cpuByInstance = queryAgentCpuUsage(); - final List finalAgents = agents; - - List response = finalAgents.stream() - .map(a -> { - AgentInstanceResponse dto = AgentInstanceResponse.from(a); - double[] m = agentMetrics.get(a.applicationId()); - if (m != null) { - long appAgentCount = finalAgents.stream() - .filter(ag -> ag.applicationId().equals(a.applicationId())).count(); - double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0; - double errorRate = m[1]; - int activeRoutes = (int) m[2]; - dto = dto.withMetrics(agentTps, errorRate, activeRoutes); - } - Double cpu = cpuByInstance.get(a.instanceId()); - if (cpu != null) { - dto = dto.withCpuUsage(cpu); - } - return dto; - }) - .toList(); - return ResponseEntity.ok(response); - } - - private Map queryAgentMetrics() { - Map result = new HashMap<>(); - Instant now = Instant.now(); - Instant from1m = now.minus(1, ChronoUnit.MINUTES); - try { - // Literal SQL — ClickHouse JDBC driver wraps prepared statements in sub-queries - // that strip AggregateFunction column types, breaking -Merge combinators - jdbc.query( - "SELECT application_id, " + - "uniqMerge(total_count) AS total, " + - "uniqIfMerge(failed_count) AS failed, " + - "COUNT(DISTINCT route_id) AS active_routes " + - "FROM stats_1m_route WHERE bucket >= " + lit(from1m) + " AND bucket < " + lit(now) + - " GROUP BY application_id", - rs -> { - long total = rs.getLong("total"); - long failed = rs.getLong("failed"); - double tps = total / 60.0; - double errorRate = total > 0 ? (double) failed / total : 0.0; - int activeRoutes = rs.getInt("active_routes"); - result.put(rs.getString("application_id"), new double[]{tps, errorRate, activeRoutes}); - }); - } catch (Exception e) { - log.debug("Could not query agent metrics: {}", e.getMessage()); - } - return result; - } - - /** Query average CPU usage per agent instance over the last 2 minutes. */ - private Map queryAgentCpuUsage() { - Map result = new HashMap<>(); - Instant now = Instant.now(); - Instant from2m = now.minus(2, ChronoUnit.MINUTES); - try { - jdbc.query( - "SELECT instance_id, avg(metric_value) AS cpu_avg " + - "FROM agent_metrics " + - "WHERE metric_name = 'process.cpu.usage.value'" + - " AND collected_at >= " + lit(from2m) + " AND collected_at < " + lit(now) + - " GROUP BY instance_id", - rs -> { - result.put(rs.getString("instance_id"), rs.getDouble("cpu_avg")); - }); - } catch (Exception e) { - log.debug("Could not query agent CPU usage: {}", e.getMessage()); - } - return result; - } - - /** Format an Instant as a ClickHouse DateTime literal. */ - 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)) + "'"; - } + // Agent list moved to AgentListController at /api/v1/environments/{envSlug}/agents. + // Agent register/refresh/heartbeat/deregister remain here at /api/v1/agents/** — + // these are JWT-authoritative and intentionally flat (env from token). } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DiagramRenderController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DiagramRenderController.java index 703025f1..f8d576dd 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DiagramRenderController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DiagramRenderController.java @@ -1,10 +1,12 @@ package com.cameleer.server.app.controller; import com.cameleer.common.graph.RouteGraph; +import com.cameleer.server.app.web.EnvPath; import com.cameleer.server.core.agent.AgentInfo; import com.cameleer.server.core.agent.AgentRegistryService; import com.cameleer.server.core.diagram.DiagramLayout; import com.cameleer.server.core.diagram.DiagramRenderer; +import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.storage.DiagramStore; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -16,7 +18,6 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -24,16 +25,16 @@ import java.util.List; import java.util.Optional; /** - * REST endpoint for rendering route diagrams. + * Diagram rendering and lookup. *

- * Supports content negotiation via Accept header: - *

    - *
  • {@code image/svg+xml} or default: returns SVG document
  • - *
  • {@code application/json}: returns JSON layout with node positions
  • - *
+ * Content-addressed rendering stays flat at /api/v1/diagrams/{contentHash}/render: + * the hash is globally unique, permalinks are valuable, and no env partitioning + * is possible or needed. + *

+ * By-app-and-route lookup is env-scoped at + * /api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram. */ @RestController -@RequestMapping("/api/v1/diagrams") @Tag(name = "Diagrams", description = "Diagram rendering endpoints") public class DiagramRenderController { @@ -51,9 +52,10 @@ public class DiagramRenderController { this.registryService = registryService; } - @GetMapping("/{contentHash}/render") - @Operation(summary = "Render a route diagram", - description = "Returns SVG (default) or JSON layout based on Accept header") + @GetMapping("/api/v1/diagrams/{contentHash}/render") + @Operation(summary = "Render a route diagram by content hash", + description = "Returns SVG (default) or JSON layout based on Accept header. " + + "Content hashes are globally unique, so this endpoint is intentionally flat (no env).") @ApiResponse(responseCode = "200", description = "Diagram rendered successfully", content = { @Content(mediaType = "image/svg+xml", schema = @Schema(type = "string")), @@ -73,9 +75,6 @@ public class DiagramRenderController { RouteGraph graph = graphOpt.get(); String accept = request.getHeader("Accept"); - // Return JSON only when the client explicitly requests application/json - // without also accepting everything (*/*). This means "application/json" - // must appear and wildcards must not dominate the preference. if (accept != null && isJsonPreferred(accept)) { DiagramLayout layout = diagramRenderer.layoutJson(graph, direction); return ResponseEntity.ok() @@ -83,25 +82,24 @@ public class DiagramRenderController { .body(layout); } - // Default to SVG for image/svg+xml, */* or no Accept header String svg = diagramRenderer.renderSvg(graph); return ResponseEntity.ok() .contentType(SVG_MEDIA_TYPE) .body(svg); } - @GetMapping - @Operation(summary = "Find diagram by application, environment, and route ID", - description = "Resolves (application, environment) to agent IDs and finds the latest diagram for the route. " - + "The environment filter prevents cross-env diagram leakage — without it a dev route could return a prod diagram (or vice versa).") + @GetMapping("/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram") + @Operation(summary = "Find the latest diagram for this app's route in this environment", + description = "Resolves agents in this env for this app, then looks up the latest diagram for the route " + + "they reported. Env scope prevents a dev route from returning a prod diagram.") @ApiResponse(responseCode = "200", description = "Diagram layout returned") - @ApiResponse(responseCode = "404", description = "No diagram found for the given application, environment, and route") - public ResponseEntity findByApplicationAndRoute( - @RequestParam String application, - @RequestParam String environment, - @RequestParam String routeId, + @ApiResponse(responseCode = "404", description = "No diagram found") + public ResponseEntity findByAppAndRoute( + @EnvPath Environment env, + @PathVariable String appSlug, + @PathVariable String routeId, @RequestParam(defaultValue = "LR") String direction) { - List agentIds = registryService.findByApplicationAndEnvironment(application, environment).stream() + List agentIds = registryService.findByApplicationAndEnvironment(appSlug, env.slug()).stream() .map(AgentInfo::instanceId) .toList(); @@ -123,14 +121,6 @@ public class DiagramRenderController { return ResponseEntity.ok(layout); } - /** - * Determine if JSON is the explicitly preferred format. - *

- * Returns true only when the first media type in the Accept header is - * "application/json". Clients sending broad Accept lists like - * "text/plain, application/json, */*" are treated as unspecific - * and receive the SVG default. - */ private boolean isJsonPreferred(String accept) { String[] parts = accept.split(","); if (parts.length == 0) return false; diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LogQueryController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LogQueryController.java index 1fb6890e..c61d3377 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LogQueryController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LogQueryController.java @@ -2,6 +2,8 @@ package com.cameleer.server.app.controller; import com.cameleer.server.app.dto.LogEntryResponse; import com.cameleer.server.app.dto.LogSearchPageResponse; +import com.cameleer.server.app.web.EnvPath; +import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.search.LogSearchRequest; import com.cameleer.server.core.search.LogSearchResponse; import com.cameleer.server.core.storage.LogIndex; @@ -18,8 +20,8 @@ import java.util.Arrays; import java.util.List; @RestController -@RequestMapping("/api/v1/logs") -@Tag(name = "Application Logs", description = "Query application logs") +@RequestMapping("/api/v1/environments/{envSlug}") +@Tag(name = "Application Logs", description = "Query application logs (env-scoped)") public class LogQueryController { private final LogIndex logIndex; @@ -28,11 +30,12 @@ public class LogQueryController { this.logIndex = logIndex; } - @GetMapping - @Operation(summary = "Search application log entries", - description = "Returns log entries with cursor-based pagination and level count aggregation. " + - "Supports free-text search, multi-level filtering, and optional application scoping.") + @GetMapping("/logs") + @Operation(summary = "Search application log entries in this environment", + description = "Cursor-paginated log search scoped to the env in the path. " + + "Supports free-text search, multi-level filtering, and optional application/agent scoping.") public ResponseEntity searchLogs( + @EnvPath Environment env, @RequestParam(required = false) String q, @RequestParam(required = false) String query, @RequestParam(required = false) String level, @@ -40,7 +43,6 @@ public class LogQueryController { @RequestParam(name = "agentId", required = false) String instanceId, @RequestParam(required = false) String exchangeId, @RequestParam(required = false) String logger, - @RequestParam(required = false) String environment, @RequestParam(required = false) String source, @RequestParam(required = false) String from, @RequestParam(required = false) String to, @@ -51,7 +53,6 @@ public class LogQueryController { // q takes precedence over deprecated query param String searchText = q != null ? q : query; - // Parse CSV levels List levels = List.of(); if (level != null && !level.isEmpty()) { levels = Arrays.stream(level.split(",")) @@ -65,7 +66,7 @@ public class LogQueryController { LogSearchRequest request = new LogSearchRequest( searchText, levels, application, instanceId, exchangeId, - logger, environment, source, fromInstant, toInstant, cursor, limit, sort); + logger, env.slug(), source, fromInstant, toInstant, cursor, limit, sort); LogSearchResponse result = logIndex.search(request); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteCatalogController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteCatalogController.java index 7591bf43..96bd415a 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteCatalogController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteCatalogController.java @@ -3,15 +3,16 @@ package com.cameleer.server.app.controller; import com.cameleer.server.app.dto.AgentSummary; import com.cameleer.server.app.dto.AppCatalogEntry; import com.cameleer.server.app.dto.RouteSummary; +import com.cameleer.server.app.web.EnvPath; import com.cameleer.common.graph.RouteGraph; import com.cameleer.server.core.agent.AgentInfo; import com.cameleer.server.core.agent.AgentRegistryService; import com.cameleer.server.core.agent.AgentState; import com.cameleer.server.core.agent.RouteStateRegistry; +import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.storage.DiagramStore; import com.cameleer.server.core.storage.RouteCatalogEntry; import com.cameleer.server.core.storage.RouteCatalogStore; -import com.cameleer.server.core.storage.StatsStore; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; @@ -34,8 +35,8 @@ import java.util.Set; import java.util.stream.Collectors; @RestController -@RequestMapping("/api/v1/routes") -@Tag(name = "Route Catalog", description = "Route catalog and discovery") +@RequestMapping("/api/v1/environments/{envSlug}") +@Tag(name = "Route Catalog", description = "Route catalog and discovery (env-scoped)") public class RouteCatalogController { private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RouteCatalogController.class); @@ -58,28 +59,22 @@ public class RouteCatalogController { this.routeCatalogStore = routeCatalogStore; } - @GetMapping("/catalog") - @Operation(summary = "Get route catalog", - description = "Returns all applications with their routes, agents, and health status") + @GetMapping("/routes") + @Operation(summary = "Get route catalog for this environment", + description = "Returns all applications with their routes, agents, and health status — filtered to this environment") @ApiResponse(responseCode = "200", description = "Catalog returned") public ResponseEntity> getCatalog( + @EnvPath Environment env, @RequestParam(required = false) String from, - @RequestParam(required = false) String to, - @RequestParam(required = false) String environment) { - List allAgents = registryService.findAll(); + @RequestParam(required = false) String to) { + String envSlug = env.slug(); + List allAgents = registryService.findAll().stream() + .filter(a -> envSlug.equals(a.environmentId())) + .toList(); - // Filter agents by environment if specified - if (environment != null && !environment.isBlank()) { - allAgents = allAgents.stream() - .filter(a -> environment.equals(a.environmentId())) - .toList(); - } - - // Group agents by application name Map> agentsByApp = allAgents.stream() .collect(Collectors.groupingBy(AgentInfo::applicationId, LinkedHashMap::new, Collectors.toList())); - // Collect all distinct routes per app Map> routesByApp = new LinkedHashMap<>(); for (var entry : agentsByApp.entrySet()) { Set routes = new LinkedHashSet<>(); @@ -91,21 +86,16 @@ public class RouteCatalogController { routesByApp.put(entry.getKey(), routes); } - // Time range for exchange counts — use provided range or default to last 24h Instant now = Instant.now(); Instant rangeFrom = from != null ? Instant.parse(from) : now.minus(24, ChronoUnit.HOURS); Instant rangeTo = to != null ? Instant.parse(to) : now; - // Route exchange counts from AggregatingMergeTree (literal SQL — ClickHouse JDBC driver - // wraps prepared statements in sub-queries that strip AggregateFunction column types) 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, uniqMerge(total_count) AS cnt, MAX(bucket) AS last_seen " + "FROM stats_1m_route WHERE bucket >= " + lit(rangeFrom) + " AND bucket < " + lit(rangeTo) + - envFilter + + " AND environment = " + lit(envSlug) + " GROUP BY application_id, route_id", rs -> { String key = rs.getString("application_id") + "/" + rs.getString("route_id"); @@ -117,9 +107,6 @@ public class RouteCatalogController { log.warn("Failed to query route exchange counts: {}", e.getMessage()); } - // Merge route IDs from ClickHouse stats into routesByApp. - // After server restart, auto-healed agents have empty routeIds, but - // ClickHouse still has execution data with the correct route IDs. for (var countEntry : routeExchangeCounts.entrySet()) { String[] parts = countEntry.getKey().split("/", 2); if (parts.length == 2) { @@ -127,12 +114,8 @@ public class RouteCatalogController { } } - // Merge routes from persistent catalog (covers routes with 0 executions - // and routes from previous app versions within the selected time window) try { - List catalogEntries = (environment != null && !environment.isBlank()) - ? routeCatalogStore.findByEnvironment(environment, rangeFrom, rangeTo) - : routeCatalogStore.findAll(rangeFrom, rangeTo); + List catalogEntries = routeCatalogStore.findByEnvironment(envSlug, rangeFrom, rangeTo); for (RouteCatalogEntry entry : catalogEntries) { routesByApp.computeIfAbsent(entry.applicationId(), k -> new LinkedHashSet<>()) .add(entry.routeId()); @@ -141,7 +124,6 @@ public class RouteCatalogController { log.warn("Failed to query route catalog: {}", e.getMessage()); } - // Build catalog entries — merge apps from agent registry + ClickHouse data Set allAppIds = new LinkedHashSet<>(agentsByApp.keySet()); allAppIds.addAll(routesByApp.keySet()); @@ -149,7 +131,6 @@ public class RouteCatalogController { for (String appId : allAppIds) { List agents = agentsByApp.getOrDefault(appId, List.of()); - // Routes Set routeIds = routesByApp.getOrDefault(appId, Set.of()); List agentIds = agents.stream().map(AgentInfo::instanceId).toList(); List routeSummaries = routeIds.stream() @@ -159,21 +140,17 @@ public class RouteCatalogController { Instant lastSeen = routeLastSeen.get(key); String fromUri = resolveFromEndpointUri(routeId, agentIds); String state = routeStateRegistry.getState(appId, routeId).name().toLowerCase(); - // Only include non-default states (stopped/suspended); null means started 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 = worst state among agents String health = computeWorstHealth(agents); - // Total exchange count for the app long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum(); catalog.add(new AppCatalogEntry(appId, routeSummaries, agentSummaries, @@ -183,7 +160,6 @@ public class RouteCatalogController { return ResponseEntity.ok(catalog); } - /** Resolve the from() endpoint URI for a route by looking up its diagram. */ private String resolveFromEndpointUri(String routeId, List agentIds) { return diagramStore.findContentHashForRouteByAgents(routeId, agentIds) .flatMap(diagramStore::findByContentHash) @@ -192,14 +168,12 @@ public class RouteCatalogController { .orElse(null); } - /** Format an Instant as a ClickHouse DateTime literal in UTC. */ 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)) + "'"; } - /** Format a string as a ClickHouse SQL literal with backslash + quote escaping. */ private static String lit(String value) { return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"; } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteMetricsController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteMetricsController.java index 7fee5819..a3a407dc 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteMetricsController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteMetricsController.java @@ -2,8 +2,10 @@ package com.cameleer.server.app.controller; import com.cameleer.server.app.dto.ProcessorMetrics; import com.cameleer.server.app.dto.RouteMetrics; +import com.cameleer.server.app.web.EnvPath; import com.cameleer.server.core.admin.AppSettings; import com.cameleer.server.core.admin.AppSettingsRepository; +import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.storage.StatsStore; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -15,24 +17,23 @@ 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.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; import java.util.List; import java.util.Map; @RestController -@RequestMapping("/api/v1/routes") -@Tag(name = "Route Metrics", description = "Route performance metrics") +@RequestMapping("/api/v1/environments/{envSlug}/routes") +@Tag(name = "Route Metrics", description = "Route performance metrics (env-scoped)") public class RouteMetricsController { private final JdbcTemplate jdbc; private final StatsStore statsStore; private final AppSettingsRepository appSettingsRepository; - public RouteMetricsController(@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc, StatsStore statsStore, + public RouteMetricsController(@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc, + StatsStore statsStore, AppSettingsRepository appSettingsRepository) { this.jdbc = jdbc; this.statsStore = statsStore; @@ -40,35 +41,32 @@ public class RouteMetricsController { } @GetMapping("/metrics") - @Operation(summary = "Get route metrics", - description = "Returns aggregated performance metrics per route for the given time window") + @Operation(summary = "Get route metrics for this environment", + description = "Returns aggregated performance metrics per route for the given time window. " + + "Optional appId filter narrows to a single application.") @ApiResponse(responseCode = "200", description = "Metrics returned") public ResponseEntity> getMetrics( + @EnvPath Environment env, @RequestParam(required = false) String from, @RequestParam(required = false) String to, - @RequestParam(required = false) String appId, - @RequestParam(required = false) String environment) { + @RequestParam(required = false) String appId) { Instant toInstant = to != null ? Instant.parse(to) : Instant.now(); Instant fromInstant = from != null ? Instant.parse(from) : toInstant.minus(24, ChronoUnit.HOURS); long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds(); - // Literal SQL — ClickHouse JDBC driver wraps prepared statements in sub-queries - // that strip AggregateFunction column types, breaking -Merge combinators var sql = new StringBuilder( "SELECT application_id, route_id, " + "uniqMerge(total_count) AS total, " + "uniqIfMerge(failed_count) AS failed, " + "CASE WHEN uniqMerge(total_count) > 0 THEN toFloat64(sumMerge(duration_sum)) / uniqMerge(total_count) ELSE 0 END AS avg_dur, " + "COALESCE(quantileMerge(0.99)(p99_duration), 0) AS p99_dur " + - "FROM stats_1m_route WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant)); + "FROM stats_1m_route WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) + + " AND environment = " + lit(env.slug())); if (appId != null) { sql.append(" AND application_id = " + lit(appId)); } - if (environment != null) { - sql.append(" AND environment = " + lit(environment)); - } sql.append(" GROUP BY application_id, route_id ORDER BY application_id, route_id"); List metrics = jdbc.query(sql.toString(), (rs, rowNum) -> { @@ -87,7 +85,7 @@ public class RouteMetricsController { avgDur, p99Dur, errorRate, tps, List.of(), -1.0); }); - // Fetch sparklines (12 buckets over the time window) + // Sparklines if (!metrics.isEmpty()) { int sparkBuckets = 12; long bucketSeconds = Math.max(windowSeconds / sparkBuckets, 60); @@ -95,15 +93,12 @@ public class RouteMetricsController { for (int i = 0; i < metrics.size(); i++) { RouteMetrics m = metrics.get(i); try { - var sparkWhere = new StringBuilder( - "FROM stats_1m_route WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) + - " AND application_id = " + lit(m.appId()) + " AND route_id = " + lit(m.routeId())); - if (environment != null) { - sparkWhere.append(" AND environment = " + lit(environment)); - } String sparkSql = "SELECT toStartOfInterval(bucket, toIntervalSecond(" + bucketSeconds + ")) AS period, " + "COALESCE(uniqMerge(total_count), 0) AS cnt " + - sparkWhere + " GROUP BY period ORDER BY period"; + "FROM stats_1m_route WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) + + " AND environment = " + lit(env.slug()) + + " AND application_id = " + lit(m.appId()) + " AND route_id = " + lit(m.routeId()) + + " GROUP BY period ORDER BY period"; List sparkline = jdbc.query(sparkSql, (rs, rowNum) -> rs.getDouble("cnt")); metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(), @@ -115,17 +110,16 @@ public class RouteMetricsController { } } - // Enrich with SLA compliance per route + // SLA compliance if (!metrics.isEmpty()) { - // Determine SLA threshold (per-app or default) - String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId()); - int threshold = (effectiveAppId != null && environment != null && !environment.isBlank()) - ? appSettingsRepository.findByApplicationAndEnvironment(effectiveAppId, environment) + String effectiveAppId = appId != null ? appId : metrics.get(0).appId(); + int threshold = effectiveAppId != null + ? appSettingsRepository.findByApplicationAndEnvironment(effectiveAppId, env.slug()) .map(AppSettings::slaThresholdMs).orElse(300) : 300; Map slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant, - effectiveAppId, threshold, environment); + effectiveAppId, threshold, env.slug()); for (int i = 0; i < metrics.size(); i++) { RouteMetrics m = metrics.get(i); @@ -142,24 +136,19 @@ public class RouteMetricsController { } @GetMapping("/metrics/processors") - @Operation(summary = "Get processor metrics", + @Operation(summary = "Get processor metrics for this environment", description = "Returns aggregated performance metrics per processor for the given route and time window") @ApiResponse(responseCode = "200", description = "Metrics returned") public ResponseEntity> getProcessorMetrics( + @EnvPath Environment env, @RequestParam String routeId, @RequestParam(required = false) String appId, @RequestParam(required = false) Instant from, - @RequestParam(required = false) Instant to, - @RequestParam(required = false) String environment) { + @RequestParam(required = false) Instant to) { Instant toInstant = to != null ? to : Instant.now(); Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS); - // Literal SQL for AggregatingMergeTree -Merge combinators. - // Aliases (tc, fc) must NOT shadow column names (total_count, failed_count) — - // ClickHouse 24.12 new analyzer resolves subsequent uniqMerge(total_count) - // to the alias (UInt64) instead of the AggregateFunction column. - // total_count/failed_count use uniq(execution_id) to deduplicate repeated inserts. var sql = new StringBuilder( "SELECT processor_id, processor_type, route_id, application_id, " + "uniqMerge(total_count) AS tc, " + @@ -168,14 +157,12 @@ public class RouteMetricsController { "quantileMerge(0.99)(p99_duration) AS p99_duration_ms " + "FROM stats_1m_processor_detail " + "WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) + + " AND environment = " + lit(env.slug()) + " AND route_id = " + lit(routeId)); if (appId != null) { sql.append(" AND application_id = " + lit(appId)); } - if (environment != null) { - sql.append(" AND environment = " + lit(environment)); - } sql.append(" GROUP BY processor_id, processor_type, route_id, application_id"); sql.append(" ORDER BY tc DESC"); @@ -198,14 +185,12 @@ public class RouteMetricsController { return ResponseEntity.ok(metrics); } - /** Format an Instant as a ClickHouse DateTime literal. */ 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)) + "'"; } - /** Format a string as a ClickHouse SQL literal with backslash + quote escaping. */ private static String lit(String value) { return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"; } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java index b7d5fb2d..c4050795 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java @@ -1,7 +1,9 @@ package com.cameleer.server.app.controller; +import com.cameleer.server.app.web.EnvPath; import com.cameleer.server.core.admin.AppSettings; import com.cameleer.server.core.admin.AppSettingsRepository; +import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.search.ExecutionStats; import com.cameleer.server.core.search.ExecutionSummary; import com.cameleer.server.core.search.SearchRequest; @@ -25,14 +27,12 @@ import java.util.List; import java.util.Map; /** - * Search endpoints for querying route executions. - *

- * GET supports basic filters via query parameters. POST accepts a full - * {@link SearchRequest} JSON body for advanced search with all filter types. + * Execution search and stats endpoints. Env is the path; env filter is + * derived from the path and always applied to underlying ClickHouse queries. */ @RestController -@RequestMapping("/api/v1/search") -@Tag(name = "Search", description = "Transaction search endpoints") +@RequestMapping("/api/v1/environments/{envSlug}") +@Tag(name = "Search", description = "Transaction search and stats (env-scoped)") public class SearchController { private final SearchService searchService; @@ -45,8 +45,9 @@ public class SearchController { } @GetMapping("/executions") - @Operation(summary = "Search executions with basic filters") + @Operation(summary = "Search executions with basic filters (env from path)") public ResponseEntity> searchGet( + @EnvPath Environment env, @RequestParam(required = false) String status, @RequestParam(required = false) Instant timeFrom, @RequestParam(required = false) Instant timeTo, @@ -56,7 +57,6 @@ public class SearchController { @RequestParam(name = "agentId", required = false) String instanceId, @RequestParam(required = false) String processorType, @RequestParam(required = false) String application, - @RequestParam(required = false) String environment, @RequestParam(defaultValue = "0") int offset, @RequestParam(defaultValue = "50") int limit, @RequestParam(required = false) String sortField, @@ -71,116 +71,116 @@ public class SearchController { application, null, offset, limit, sortField, sortDir, - environment + env.slug() ); return ResponseEntity.ok(searchService.search(request)); } - @PostMapping("/executions") - @Operation(summary = "Advanced search with all filters") + @PostMapping("/executions/search") + @Operation(summary = "Advanced search with all filters", + description = "Env from the path overrides any environment field in the body.") public ResponseEntity> searchPost( + @EnvPath Environment env, @RequestBody SearchRequest request) { - return ResponseEntity.ok(searchService.search(request)); + SearchRequest scoped = request.withEnvironment(env.slug()); + return ResponseEntity.ok(searchService.search(scoped)); } @GetMapping("/stats") @Operation(summary = "Aggregate execution stats (P99 latency, active count, SLA compliance)") public ResponseEntity stats( + @EnvPath Environment env, @RequestParam Instant from, @RequestParam(required = false) Instant to, @RequestParam(required = false) String routeId, - @RequestParam(required = false) String application, - @RequestParam(required = false) String environment) { + @RequestParam(required = false) String application) { Instant end = to != null ? to : Instant.now(); ExecutionStats stats; if (routeId == null && application == null) { - stats = searchService.stats(from, end, environment); + stats = searchService.stats(from, end, env.slug()); } else if (routeId == null) { - stats = searchService.statsForApp(from, end, application, environment); + stats = searchService.statsForApp(from, end, application, env.slug()); } else { - stats = searchService.statsForRoute(from, end, routeId, application, environment); + stats = searchService.statsForRoute(from, end, routeId, application, env.slug()); } - // Enrich with SLA compliance (per-env threshold when both app and env are specified) - int threshold = (application != null && !application.isBlank() - && environment != null && !environment.isBlank()) - ? appSettingsRepository.findByApplicationAndEnvironment(application, environment) + int threshold = application != null && !application.isBlank() + ? appSettingsRepository.findByApplicationAndEnvironment(application, env.slug()) .map(AppSettings::slaThresholdMs).orElse(300) : 300; - double sla = searchService.slaCompliance(from, end, threshold, application, routeId, environment); + double sla = searchService.slaCompliance(from, end, threshold, application, routeId, env.slug()); return ResponseEntity.ok(stats.withSlaCompliance(sla)); } @GetMapping("/stats/timeseries") @Operation(summary = "Bucketed time-series stats over a time window") public ResponseEntity timeseries( + @EnvPath Environment env, @RequestParam Instant from, @RequestParam(required = false) Instant to, @RequestParam(defaultValue = "24") int buckets, @RequestParam(required = false) String routeId, - @RequestParam(required = false) String application, - @RequestParam(required = false) String environment) { + @RequestParam(required = false) String application) { Instant end = to != null ? to : Instant.now(); if (routeId == null && application == null) { - return ResponseEntity.ok(searchService.timeseries(from, end, buckets, environment)); + return ResponseEntity.ok(searchService.timeseries(from, end, buckets, env.slug())); } if (routeId == null) { - return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application, environment)); + return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application, env.slug())); } - return ResponseEntity.ok(searchService.timeseriesForRoute(from, end, buckets, routeId, application, environment)); + return ResponseEntity.ok(searchService.timeseriesForRoute(from, end, buckets, routeId, application, env.slug())); } @GetMapping("/stats/timeseries/by-app") @Operation(summary = "Timeseries grouped by application") public ResponseEntity> timeseriesByApp( + @EnvPath Environment env, @RequestParam Instant from, @RequestParam(required = false) Instant to, - @RequestParam(defaultValue = "24") int buckets, - @RequestParam(required = false) String environment) { + @RequestParam(defaultValue = "24") int buckets) { Instant end = to != null ? to : Instant.now(); - return ResponseEntity.ok(searchService.timeseriesGroupedByApp(from, end, buckets, environment)); + return ResponseEntity.ok(searchService.timeseriesGroupedByApp(from, end, buckets, env.slug())); } @GetMapping("/stats/timeseries/by-route") @Operation(summary = "Timeseries grouped by route for an application") public ResponseEntity> timeseriesByRoute( + @EnvPath Environment env, @RequestParam Instant from, @RequestParam(required = false) Instant to, @RequestParam(defaultValue = "24") int buckets, - @RequestParam String application, - @RequestParam(required = false) String environment) { + @RequestParam String application) { Instant end = to != null ? to : Instant.now(); - return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application, environment)); + return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application, env.slug())); } @GetMapping("/stats/punchcard") @Operation(summary = "Transaction punchcard: weekday x hour grid (rolling 7 days)") public ResponseEntity> punchcard( - @RequestParam(required = false) String application, - @RequestParam(required = false) String environment) { + @EnvPath Environment env, + @RequestParam(required = false) String application) { Instant to = Instant.now(); Instant from = to.minus(java.time.Duration.ofDays(7)); - return ResponseEntity.ok(searchService.punchcard(from, to, application, environment)); + return ResponseEntity.ok(searchService.punchcard(from, to, application, env.slug())); } @GetMapping("/attributes/keys") - @Operation(summary = "Distinct attribute key names for the given environment", - description = "Scoped to an environment to prevent cross-env attribute leakage in UI completions") - public ResponseEntity> attributeKeys(@RequestParam String environment) { - return ResponseEntity.ok(searchService.distinctAttributeKeys(environment)); + @Operation(summary = "Distinct attribute key names for this environment") + public ResponseEntity> attributeKeys(@EnvPath Environment env) { + return ResponseEntity.ok(searchService.distinctAttributeKeys(env.slug())); } @GetMapping("/errors/top") @Operation(summary = "Top N errors with velocity trend") public ResponseEntity> topErrors( + @EnvPath Environment env, @RequestParam Instant from, @RequestParam(required = false) Instant to, @RequestParam(required = false) String application, @RequestParam(required = false) String routeId, - @RequestParam(required = false) String environment, @RequestParam(defaultValue = "5") int limit) { Instant end = to != null ? to : Instant.now(); - return ResponseEntity.ok(searchService.topErrors(from, end, application, routeId, limit, environment)); + return ResponseEntity.ok(searchService.topErrors(from, end, application, routeId, limit, env.slug())); } } diff --git a/ui/src/api/queries/agent-metrics.ts b/ui/src/api/queries/agent-metrics.ts index 2a4acec3..db5b7c4c 100644 --- a/ui/src/api/queries/agent-metrics.ts +++ b/ui/src/api/queries/agent-metrics.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { config } from '../../config'; import { useAuthStore } from '../../auth/auth-store'; +import { useEnvironmentStore } from '../environment-store'; import { useRefreshInterval } from './use-refresh-interval'; export function useAgentMetrics( @@ -11,9 +12,10 @@ export function useAgentMetrics( to?: string, mode: 'gauge' | 'delta' = 'gauge', ) { + const environment = useEnvironmentStore((s) => s.environment); const refetchInterval = useRefreshInterval(30_000); return useQuery({ - queryKey: ['agent-metrics', agentId, names.join(','), buckets, from, to, mode], + queryKey: ['agent-metrics', environment, agentId, names.join(','), buckets, from, to, mode], queryFn: async () => { const token = useAuthStore.getState().accessToken; const params = new URLSearchParams({ @@ -23,16 +25,18 @@ export function useAgentMetrics( }); if (from) params.set('from', from); if (to) params.set('to', to); - const res = await fetch(`${config.apiBaseUrl}/agents/${agentId}/metrics?${params}`, { - headers: { - Authorization: `Bearer ${token}`, - 'X-Cameleer-Protocol-Version': '1', - }, - }); + const res = await fetch( + `${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/agents/${encodeURIComponent(agentId!)}/metrics?${params}`, + { + headers: { + Authorization: `Bearer ${token}`, + 'X-Cameleer-Protocol-Version': '1', + }, + }); if (!res.ok) throw new Error(`${res.status}`); return res.json() as Promise<{ metrics: Record> }>; }, - enabled: !!agentId && names.length > 0, + enabled: !!agentId && names.length > 0 && !!environment, refetchInterval, }); } diff --git a/ui/src/api/queries/agents.ts b/ui/src/api/queries/agents.ts index f3932466..0e18f846 100644 --- a/ui/src/api/queries/agents.ts +++ b/ui/src/api/queries/agents.ts @@ -1,53 +1,60 @@ import { useQuery } from '@tanstack/react-query'; import { config } from '../../config'; import { useAuthStore } from '../../auth/auth-store'; +import { useEnvironmentStore } from '../environment-store'; import { useRefreshInterval } from './use-refresh-interval'; -export function useAgents(status?: string, application?: string, environment?: string) { +export function useAgents(status?: string, application?: string) { + const environment = useEnvironmentStore((s) => s.environment); const refetchInterval = useRefreshInterval(10_000); return useQuery({ - queryKey: ['agents', status, application, environment], + queryKey: ['agents', environment, status, application], queryFn: async () => { const token = useAuthStore.getState().accessToken; const params = new URLSearchParams(); if (status) params.set('status', status); if (application) params.set('application', application); - if (environment) params.set('environment', environment); const qs = params.toString(); - const res = await fetch(`${config.apiBaseUrl}/agents${qs ? `?${qs}` : ''}`, { - headers: { - Authorization: `Bearer ${token}`, - 'X-Cameleer-Protocol-Version': '1', - }, - }); + const res = await fetch( + `${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/agents${qs ? `?${qs}` : ''}`, + { + headers: { + Authorization: `Bearer ${token}`, + 'X-Cameleer-Protocol-Version': '1', + }, + }); if (!res.ok) throw new Error('Failed to load agents'); return res.json(); }, + enabled: !!environment, refetchInterval, }); } -export function useAgentEvents(appId?: string, agentId?: string, limit = 50, toOverride?: string, environment?: string) { +export function useAgentEvents(appId?: string, agentId?: string, limit = 50, toOverride?: string) { + const environment = useEnvironmentStore((s) => s.environment); const refetchInterval = useRefreshInterval(15_000); return useQuery({ - queryKey: ['agents', 'events', appId, agentId, limit, toOverride, environment], + queryKey: ['agents', 'events', environment, appId, agentId, limit, toOverride], queryFn: async () => { const token = useAuthStore.getState().accessToken; const params = new URLSearchParams(); if (appId) params.set('appId', appId); if (agentId) params.set('agentId', agentId); - if (environment) params.set('environment', environment); if (toOverride) params.set('to', toOverride); params.set('limit', String(limit)); - const res = await fetch(`${config.apiBaseUrl}/agents/events-log?${params}`, { - headers: { - Authorization: `Bearer ${token}`, - 'X-Cameleer-Protocol-Version': '1', - }, - }); + const res = await fetch( + `${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/agents/events?${params}`, + { + headers: { + Authorization: `Bearer ${token}`, + 'X-Cameleer-Protocol-Version': '1', + }, + }); if (!res.ok) throw new Error('Failed to load agent events'); return res.json(); }, + enabled: !!environment, refetchInterval, }); } diff --git a/ui/src/api/queries/catalog.ts b/ui/src/api/queries/catalog.ts index 78aa216d..d29a3c91 100644 --- a/ui/src/api/queries/catalog.ts +++ b/ui/src/api/queries/catalog.ts @@ -89,15 +89,15 @@ export function useDismissApp() { export function useRouteMetrics(from?: string, to?: string, appId?: string, environment?: string) { const refetchInterval = useRefreshInterval(30_000); return useQuery({ - queryKey: ['routes', 'metrics', from, to, appId, environment], + queryKey: ['routes', 'metrics', environment, from, to, appId], queryFn: async () => { const token = useAuthStore.getState().accessToken; const params = new URLSearchParams(); if (from) params.set('from', from); if (to) params.set('to', to); if (appId) params.set('appId', appId); - if (environment) params.set('environment', environment); - const res = await fetch(`${config.apiBaseUrl}/routes/metrics?${params}`, { + const res = await fetch( + `${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/routes/metrics?${params}`, { headers: { Authorization: `Bearer ${token}`, 'X-Cameleer-Protocol-Version': '1', @@ -106,6 +106,7 @@ export function useRouteMetrics(from?: string, to?: string, appId?: string, envi if (!res.ok) throw new Error('Failed to load route metrics'); return res.json(); }, + enabled: !!environment, placeholderData: (prev: unknown) => prev, refetchInterval, }); diff --git a/ui/src/api/queries/correlation.ts b/ui/src/api/queries/correlation.ts index c76cd3b1..f6af188d 100644 --- a/ui/src/api/queries/correlation.ts +++ b/ui/src/api/queries/correlation.ts @@ -1,21 +1,31 @@ import { useQuery } from '@tanstack/react-query'; -import { api } from '../client'; +import { config as appConfig } from '../../config'; +import { useAuthStore } from '../../auth/auth-store'; export function useCorrelationChain(correlationId: string | null, environment?: string) { return useQuery({ - queryKey: ['correlation-chain', correlationId, environment], + queryKey: ['correlation-chain', environment, correlationId], queryFn: async () => { - const { data } = await api.POST('/search/executions', { - body: { - correlationId: correlationId!, - environment, - limit: 20, - sortField: 'startTime', - sortDir: 'asc', - }, - }); - return data; + const token = useAuthStore.getState().accessToken; + const res = await fetch( + `${appConfig.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/executions/search`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + 'X-Cameleer-Protocol-Version': '1', + }, + body: JSON.stringify({ + correlationId: correlationId!, + limit: 20, + sortField: 'startTime', + sortDir: 'asc', + }), + }); + if (!res.ok) throw new Error('Failed to load correlation chain'); + return res.json(); }, - enabled: !!correlationId, + enabled: !!correlationId && !!environment, }); } diff --git a/ui/src/api/queries/dashboard.ts b/ui/src/api/queries/dashboard.ts index a5bbea50..0f826270 100644 --- a/ui/src/api/queries/dashboard.ts +++ b/ui/src/api/queries/dashboard.ts @@ -42,11 +42,12 @@ export interface GroupedTimeseries { export function useTimeseriesByApp(from?: string, to?: string, environment?: string) { const refetchInterval = useRefreshInterval(30_000); return useQuery({ - queryKey: ['dashboard', 'timeseries-by-app', from, to, environment], - queryFn: () => fetchJson('/search/stats/timeseries/by-app', { - from, to, buckets: '24', environment, + queryKey: ['dashboard', 'timeseries-by-app', environment, from, to], + queryFn: () => fetchJson( + `/environments/${encodeURIComponent(environment!)}/stats/timeseries/by-app`, { + from, to, buckets: '24', }), - enabled: !!from, + enabled: !!from && !!environment, placeholderData: (prev: GroupedTimeseries | undefined) => prev, refetchInterval, }); @@ -57,11 +58,12 @@ export function useTimeseriesByApp(from?: string, to?: string, environment?: str export function useTimeseriesByRoute(from?: string, to?: string, application?: string, environment?: string) { const refetchInterval = useRefreshInterval(30_000); return useQuery({ - queryKey: ['dashboard', 'timeseries-by-route', from, to, application, environment], - queryFn: () => fetchJson('/search/stats/timeseries/by-route', { - from, to, application, buckets: '24', environment, + queryKey: ['dashboard', 'timeseries-by-route', environment, from, to, application], + queryFn: () => fetchJson( + `/environments/${encodeURIComponent(environment!)}/stats/timeseries/by-route`, { + from, to, application, buckets: '24', }), - enabled: !!from && !!application, + enabled: !!from && !!application && !!environment, placeholderData: (prev: GroupedTimeseries | undefined) => prev, refetchInterval, }); @@ -82,11 +84,12 @@ export interface TopError { export function useTopErrors(from?: string, to?: string, application?: string, routeId?: string, environment?: string) { const refetchInterval = useRefreshInterval(10_000); return useQuery({ - queryKey: ['dashboard', 'top-errors', from, to, application, routeId, environment], - queryFn: () => fetchJson('/search/errors/top', { - from, to, application, routeId, limit: '5', environment, + queryKey: ['dashboard', 'top-errors', environment, from, to, application, routeId], + queryFn: () => fetchJson( + `/environments/${encodeURIComponent(environment!)}/errors/top`, { + from, to, application, routeId, limit: '5', }), - enabled: !!from, + enabled: !!from && !!environment, placeholderData: (prev: TopError[] | undefined) => prev, refetchInterval, }); @@ -104,8 +107,10 @@ export interface PunchcardCell { export function usePunchcard(application?: string, environment?: string) { const refetchInterval = useRefreshInterval(60_000); return useQuery({ - queryKey: ['dashboard', 'punchcard', application, environment], - queryFn: () => fetchJson('/search/stats/punchcard', { application, environment }), + queryKey: ['dashboard', 'punchcard', environment, application], + queryFn: () => fetchJson( + `/environments/${encodeURIComponent(environment!)}/stats/punchcard`, { application }), + enabled: !!environment, placeholderData: (prev: PunchcardCell[] | undefined) => prev ?? [], refetchInterval, }); diff --git a/ui/src/api/queries/diagrams.ts b/ui/src/api/queries/diagrams.ts index 12e26cd0..579a5e2d 100644 --- a/ui/src/api/queries/diagrams.ts +++ b/ui/src/api/queries/diagrams.ts @@ -58,11 +58,21 @@ export function useDiagramByRoute( return useQuery({ queryKey: ['diagrams', 'byRoute', environment, application, routeId, direction], queryFn: async () => { - const { data, error } = await api.GET('/diagrams', { - params: { query: { application: application!, environment: environment!, routeId: routeId!, direction } }, + const { useAuthStore } = await import('../../auth/auth-store'); + const { config: appConfig } = await import('../../config'); + const token = useAuthStore.getState().accessToken; + const url = `${appConfig.apiBaseUrl}/environments/${encodeURIComponent(environment!)}` + + `/apps/${encodeURIComponent(application!)}` + + `/routes/${encodeURIComponent(routeId!)}/diagram?direction=${direction}`; + const res = await fetch(url, { + headers: { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + 'X-Cameleer-Protocol-Version': '1', + }, }); - if (error) throw new Error('Failed to load diagram for route'); - return data as DiagramLayout; + if (!res.ok) throw new Error('Failed to load diagram for route'); + return (await res.json()) as DiagramLayout; }, enabled: !!application && !!routeId && !!environment, }); diff --git a/ui/src/api/queries/executions.ts b/ui/src/api/queries/executions.ts index d5286cfb..2fb77b9c 100644 --- a/ui/src/api/queries/executions.ts +++ b/ui/src/api/queries/executions.ts @@ -1,9 +1,35 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../client'; +import { config as appConfig } from '../../config'; +import { useAuthStore } from '../../auth/auth-store'; import { useEnvironmentStore } from '../environment-store'; +import type { components } from '../schema'; import type { SearchRequest } from '../types'; import { useLiveQuery } from './use-refresh-interval'; +type ExecutionStats = components['schemas']['ExecutionStats']; +type StatsTimeseries = components['schemas']['StatsTimeseries']; +type SearchResultSummary = components['schemas']['SearchResultExecutionSummary']; + +// Raw authenticated fetch — used for env-scoped endpoints where the +// generated openapi schema is still on the old flat shape. Switch back to +// api.GET once the schema is regenerated against a running P3-era backend. +async function envFetch(envSlug: string, path: string, init?: RequestInit): Promise { + const token = useAuthStore.getState().accessToken; + const res = await fetch( + `${appConfig.apiBaseUrl}/environments/${encodeURIComponent(envSlug)}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + 'X-Cameleer-Protocol-Version': '1', + ...init?.headers, + }, + }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json() as Promise; +} + export function useExecutionStats( timeFrom: string | undefined, timeTo: string | undefined, @@ -13,23 +39,15 @@ export function useExecutionStats( ) { const live = useLiveQuery(10_000); return useQuery({ - queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, application, environment], + queryKey: ['executions', 'stats', environment, timeFrom, timeTo, routeId, application], queryFn: async () => { - const { data, error } = await api.GET('/search/stats', { - params: { - query: { - from: timeFrom!, - to: timeTo || undefined, - routeId: routeId || undefined, - application: application || undefined, - environment: environment || undefined, - }, - }, - }); - if (error) throw new Error('Failed to load stats'); - return data!; + const params = new URLSearchParams({ from: timeFrom! }); + if (timeTo) params.set('to', timeTo); + if (routeId) params.set('routeId', routeId); + if (application) params.set('application', application); + return envFetch(environment!, `/stats?${params}`); }, - enabled: !!timeFrom && live.enabled, + enabled: !!timeFrom && !!environment && live.enabled, placeholderData: (prev) => prev, refetchInterval: live.refetchInterval, }); @@ -39,34 +57,23 @@ export function useAttributeKeys() { const environment = useEnvironmentStore((s) => s.environment); return useQuery({ queryKey: ['search', 'attribute-keys', environment], - queryFn: async () => { - const token = (await import('../../auth/auth-store')).useAuthStore.getState().accessToken; - const { config } = await import('../../config'); - const res = await fetch( - `${config.apiBaseUrl}/search/attributes/keys?environment=${encodeURIComponent(environment!)}`, - { headers: { Authorization: `Bearer ${token}` } }, - ); - if (!res.ok) throw new Error('Failed to load attribute keys'); - return res.json() as Promise; - }, + queryFn: () => envFetch(environment!, '/attributes/keys'), enabled: !!environment, staleTime: 60_000, }); } export function useSearchExecutions(filters: SearchRequest, live = false) { + const environment = useEnvironmentStore((s) => s.environment); const liveQuery = useLiveQuery(5_000); return useQuery({ - queryKey: ['executions', 'search', filters], - queryFn: async () => { - const { data, error } = await api.POST('/search/executions', { - body: filters, - }); - if (error) throw new Error('Search failed'); - return data!; - }, + queryKey: ['executions', 'search', environment, filters], + queryFn: () => envFetch(environment!, '/executions/search', { + method: 'POST', + body: JSON.stringify(filters), + }), placeholderData: (prev) => prev, - enabled: live ? liveQuery.enabled : true, + enabled: !!environment && (live ? liveQuery.enabled : true), refetchInterval: live ? liveQuery.refetchInterval : false, }); } @@ -80,24 +87,15 @@ export function useStatsTimeseries( ) { const live = useLiveQuery(30_000); return useQuery({ - queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, application, environment], + queryKey: ['executions', 'timeseries', environment, timeFrom, timeTo, routeId, application], queryFn: async () => { - const { data, error } = await api.GET('/search/stats/timeseries', { - params: { - query: { - from: timeFrom!, - to: timeTo || undefined, - buckets: 24, - routeId: routeId || undefined, - application: application || undefined, - environment: environment || undefined, - }, - }, - }); - if (error) throw new Error('Failed to load timeseries'); - return data!; + const params = new URLSearchParams({ from: timeFrom!, buckets: '24' }); + if (timeTo) params.set('to', timeTo); + if (routeId) params.set('routeId', routeId); + if (application) params.set('application', application); + return envFetch(environment!, `/stats/timeseries?${params}`); }, - enabled: !!timeFrom && live.enabled, + enabled: !!timeFrom && !!environment && live.enabled, placeholderData: (prev) => prev, refetchInterval: live.refetchInterval, }); diff --git a/ui/src/api/queries/logs.ts b/ui/src/api/queries/logs.ts index 3baef28d..959dff8e 100644 --- a/ui/src/api/queries/logs.ts +++ b/ui/src/api/queries/logs.ts @@ -32,7 +32,8 @@ export interface LogSearchParams { application?: string; agentId?: string; source?: string; - environment?: string; + /** Required: env in path */ + environment: string; exchangeId?: string; logger?: string; from?: string; @@ -50,7 +51,6 @@ async function fetchLogs(params: LogSearchParams): Promise fetchLogs(params), - enabled: options?.enabled ?? true, + enabled: (options?.enabled ?? true) && !!params.environment, placeholderData: (prev) => prev, refetchInterval: options?.refetchInterval ?? defaultRefetch, staleTime: 300, @@ -107,7 +108,7 @@ export function useApplicationLogs( application: application || undefined, agentId: agentId || undefined, source: options?.source || undefined, - environment: selectedEnv || undefined, + environment: selectedEnv ?? '', exchangeId: options?.exchangeId || undefined, from: useTimeRange ? timeRange.start.toISOString() : undefined, to: useTimeRange ? to : undefined, @@ -120,7 +121,7 @@ export function useApplicationLogs( useTimeRange ? to : null, options?.limit, options?.exchangeId, options?.source], queryFn: () => fetchLogs(params), - enabled: !!application, + enabled: !!application && !!selectedEnv, placeholderData: (prev) => prev, refetchInterval, }); @@ -144,7 +145,7 @@ export function useStartupLogs( ) { const params: LogSearchParams = { application: application || undefined, - environment: environment || undefined, + environment: environment ?? '', source: 'container', from: deployCreatedAt || undefined, sort: 'asc', @@ -152,7 +153,7 @@ export function useStartupLogs( }; return useLogs(params, { - enabled: !!application && !!deployCreatedAt, + enabled: !!application && !!deployCreatedAt && !!environment, refetchInterval: isStarting ? 3_000 : false, }); } diff --git a/ui/src/api/queries/processor-metrics.ts b/ui/src/api/queries/processor-metrics.ts index 84a6a680..e18af2a8 100644 --- a/ui/src/api/queries/processor-metrics.ts +++ b/ui/src/api/queries/processor-metrics.ts @@ -6,23 +6,24 @@ import { useRefreshInterval } from './use-refresh-interval'; export function useProcessorMetrics(routeId: string | null, appId?: string, environment?: string) { const refetchInterval = useRefreshInterval(30_000); return useQuery({ - queryKey: ['processor-metrics', routeId, appId, environment], + queryKey: ['processor-metrics', environment, routeId, appId], queryFn: async () => { const token = useAuthStore.getState().accessToken; const params = new URLSearchParams(); if (routeId) params.set('routeId', routeId); if (appId) params.set('appId', appId); - if (environment) params.set('environment', environment); - const res = await fetch(`${config.apiBaseUrl}/routes/metrics/processors?${params}`, { - headers: { - Authorization: `Bearer ${token}`, - 'X-Cameleer-Protocol-Version': '1', - }, - }); + const res = await fetch( + `${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/routes/metrics/processors?${params}`, + { + headers: { + Authorization: `Bearer ${token}`, + 'X-Cameleer-Protocol-Version': '1', + }, + }); if (!res.ok) throw new Error(`${res.status}`); return res.json(); }, - enabled: !!routeId, + enabled: !!routeId && !!environment, refetchInterval, }); } diff --git a/ui/src/components/ExecutionDiagram/tabs/LogTab.tsx b/ui/src/components/ExecutionDiagram/tabs/LogTab.tsx index 8fdc94f2..90d0b2c4 100644 --- a/ui/src/components/ExecutionDiagram/tabs/LogTab.tsx +++ b/ui/src/components/ExecutionDiagram/tabs/LogTab.tsx @@ -4,6 +4,7 @@ import { Input, Button, LogViewer } from '@cameleer/design-system'; import type { LogEntry } from '@cameleer/design-system'; import { useLogs } from '../../../api/queries/logs'; import type { LogEntryResponse } from '../../../api/queries/logs'; +import { useEnvironmentStore } from '../../../api/environment-store'; import { mapLogLevel } from '../../../utils/agent-utils'; import logStyles from './LogTab.module.css'; import diagramStyles from '../ExecutionDiagram.module.css'; @@ -27,9 +28,10 @@ export function LogTab({ applicationId, exchangeId, processorId }: LogTabProps) const [filter, setFilter] = useState(''); const navigate = useNavigate(); + const environment = useEnvironmentStore((s) => s.environment) ?? ''; const { data: logPage, isLoading } = useLogs( - { exchangeId, limit: 500 }, - { enabled: !!exchangeId }, + { exchangeId, environment, limit: 500 }, + { enabled: !!exchangeId && !!environment }, ); const entries = useMemo(() => { diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 3711037f..c9bf83af 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -303,8 +303,10 @@ function LayoutContent() { const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment); const { data: catalog } = useCatalog(selectedEnv); - const { data: allAgents } = useAgents(); // unfiltered — for environment discovery - const { data: agents } = useAgents(undefined, undefined, selectedEnv); // filtered — for sidebar/search + // Env is always required now (path-based endpoint). For cross-env "all agents" + // we'd need a separate flat endpoint; sidebar uses env-filtered list directly. + const { data: agents } = useAgents(); // env pulled from store internally + const allAgents = agents; const { data: attributeKeys } = useAttributeKeys(); const { data: envRecords = [] } = useEnvironments(); diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index d0bb7ec4..99f3a1c0 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -163,7 +163,7 @@ export default function AgentHealth() { const navigate = useNavigate(); const { toast } = useToast(); const selectedEnv = useEnvironmentStore((s) => s.environment); - const { data: agents } = useAgents(undefined, appId, selectedEnv); + const { data: agents } = useAgents(undefined, appId); const { data: appConfig } = useApplicationConfig(appId, selectedEnv); const updateConfig = useUpdateApplicationConfig(); @@ -282,7 +282,7 @@ export default function AgentHealth() { }, [appConfig, configDraft, updateConfig, toast, appId]); const [eventSortAsc, setEventSortAsc] = useState(false); const [eventRefreshTo, setEventRefreshTo] = useState(); - const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo, selectedEnv); + const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo); const [appFilter, setAppFilter] = useState(''); type AppSortKey = 'status' | 'name' | 'tps' | 'cpu' | 'heartbeat'; diff --git a/ui/src/pages/AgentInstance/AgentInstance.tsx b/ui/src/pages/AgentInstance/AgentInstance.tsx index aecb516f..dbdcf857 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.tsx +++ b/ui/src/pages/AgentInstance/AgentInstance.tsx @@ -45,8 +45,8 @@ export default function AgentInstance() { const timeTo = timeRange.end.toISOString(); const selectedEnv = useEnvironmentStore((s) => s.environment); - const { data: agents, isLoading } = useAgents(undefined, appId, selectedEnv); - const { data: events } = useAgentEvents(appId, instanceId, 50, eventRefreshTo, selectedEnv); + const { data: agents, isLoading } = useAgents(undefined, appId); + const { data: events } = useAgentEvents(appId, instanceId, 50, eventRefreshTo); const agent = useMemo( () => (agents || []).find((a: any) => a.instanceId === instanceId) as any,