From 694d0eef590daaf28d1c94673b1392f61c7976ac Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:42:26 +0200 Subject: [PATCH] feat: add environment filtering across all APIs and UI Backend: Added optional `environment` query parameter to catalog, search, stats, timeseries, punchcard, top-errors, logs, and agents endpoints. ClickHouse queries filter by environment when specified (literal SQL for AggregatingMergeTree, ? binds for raw tables). StatsStore interface methods all accept environment parameter. UI: Added EnvironmentSelector component (compact native select). LayoutShell extracts distinct environments from agent data and passes selected environment to catalog and agent queries via URL search param (?env=). TopBar shows current environment label. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AgentRegistrationController.java | 10 +- .../app/controller/LogQueryController.java | 3 +- .../controller/RouteCatalogController.java | 18 ++- .../controller/RouteMetricsController.java | 2 +- .../app/controller/SearchController.java | 44 ++++--- .../server/app/dto/AgentInstanceResponse.java | 5 +- .../server/app/search/ClickHouseLogStore.java | 5 + .../app/search/ClickHouseSearchIndex.java | 5 + .../app/storage/ClickHouseStatsStore.java | 118 ++++++++++++------ .../app/search/ClickHouseLogStoreIT.java | 26 ++-- .../app/search/ClickHouseSearchIndexIT.java | 32 ++--- .../storage/ClickHouseChunkPipelineIT.java | 4 +- .../app/storage/ClickHouseStatsStoreIT.java | 28 ++--- .../server/core/search/LogSearchRequest.java | 2 + .../server/core/search/SearchRequest.java | 16 ++- .../server/core/search/SearchService.java | 89 ++++++++++--- .../server/core/storage/StatsStore.java | 28 ++--- ui/src/api/queries/agents.ts | 22 ++-- ui/src/api/queries/catalog.ts | 5 +- ui/src/api/queries/dashboard.ts | 24 ++-- ui/src/api/queries/executions.ts | 8 +- ui/src/api/schema.d.ts | 10 ++ .../components/EnvironmentSelector.module.css | 26 ++++ ui/src/components/EnvironmentSelector.tsx | 25 ++++ ui/src/components/LayoutShell.tsx | 44 ++++++- 25 files changed, 439 insertions(+), 160 deletions(-) create mode 100644 ui/src/components/EnvironmentSelector.module.css create mode 100644 ui/src/components/EnvironmentSelector.tsx diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java index 4cf67c2b..15fcfbd5 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java @@ -271,7 +271,8 @@ public class AgentRegistrationController { content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity> listAgents( @RequestParam(required = false) String status, - @RequestParam(required = false) String application) { + @RequestParam(required = false) String application, + @RequestParam(required = false) String environment) { List agents; if (status != null) { @@ -292,6 +293,13 @@ public class AgentRegistrationController { .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(); final List finalAgents = agents; diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LogQueryController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LogQueryController.java index 78e73031..b7a6abb6 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LogQueryController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LogQueryController.java @@ -40,6 +40,7 @@ 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 from, @RequestParam(required = false) String to, @RequestParam(required = false) String cursor, @@ -63,7 +64,7 @@ public class LogQueryController { LogSearchRequest request = new LogSearchRequest( searchText, levels, application, instanceId, exchangeId, - logger, fromInstant, toInstant, cursor, limit, sort); + logger, environment, fromInstant, toInstant, cursor, limit, sort); LogSearchResponse result = logIndex.search(request); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteCatalogController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteCatalogController.java index f0082eef..06157cf3 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteCatalogController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteCatalogController.java @@ -59,9 +59,17 @@ public class RouteCatalogController { @ApiResponse(responseCode = "200", description = "Catalog returned") public ResponseEntity> getCatalog( @RequestParam(required = false) String from, - @RequestParam(required = false) String to) { + @RequestParam(required = false) String to, + @RequestParam(required = false) String environment) { List allAgents = registryService.findAll(); + // 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())); @@ -87,9 +95,12 @@ public class RouteCatalogController { Map routeExchangeCounts = new LinkedHashMap<>(); Map routeLastSeen = new LinkedHashMap<>(); try { + String envFilter = (environment != null && !environment.isBlank()) + ? " AND environment = " + lit(environment) : ""; jdbc.query( "SELECT application_id, route_id, countMerge(total_count) AS cnt, MAX(bucket) AS last_seen " + "FROM stats_1m_route WHERE bucket >= " + lit(rangeFrom) + " AND bucket < " + lit(rangeTo) + + envFilter + " GROUP BY application_id, route_id", rs -> { String key = rs.getString("application_id") + "/" + rs.getString("route_id"); @@ -169,6 +180,11 @@ public class RouteCatalogController { .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("'", "\\'") + "'"; + } + private String computeWorstHealth(List agents) { boolean hasDead = false; boolean hasStale = false; diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteMetricsController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteMetricsController.java index 199f4f62..b6a6d561 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteMetricsController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteMetricsController.java @@ -115,7 +115,7 @@ public class RouteMetricsController { .map(AppSettings::slaThresholdMs).orElse(300); Map slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant, - effectiveAppId, threshold); + effectiveAppId, threshold, null); for (int i = 0; i < metrics.size(); i++) { RouteMetrics m = metrics.get(i); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java index e7733d77..63780723 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java @@ -60,6 +60,7 @@ 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, @@ -75,7 +76,8 @@ public class SearchController { routeId, instanceId, processorType, application, agentIds, offset, limit, - sortField, sortDir + sortField, sortDir, + environment ); return ResponseEntity.ok(searchService.search(request)); @@ -100,23 +102,24 @@ public class SearchController { @RequestParam Instant from, @RequestParam(required = false) Instant to, @RequestParam(required = false) String routeId, - @RequestParam(required = false) String application) { + @RequestParam(required = false) String application, + @RequestParam(required = false) String environment) { Instant end = to != null ? to : Instant.now(); ExecutionStats stats; if (routeId == null && application == null) { - stats = searchService.stats(from, end); + stats = searchService.stats(from, end, environment); } else if (routeId == null) { - stats = searchService.statsForApp(from, end, application); + stats = searchService.statsForApp(from, end, application, environment); } else { List agentIds = resolveApplicationToAgentIds(application); - stats = searchService.stats(from, end, routeId, agentIds); + stats = searchService.stats(from, end, routeId, agentIds, environment); } // Enrich with SLA compliance int threshold = appSettingsRepository .findByApplicationId(application != null ? application : "") .map(AppSettings::slaThresholdMs).orElse(300); - double sla = searchService.slaCompliance(from, end, threshold, application, routeId); + double sla = searchService.slaCompliance(from, end, threshold, application, routeId, environment); return ResponseEntity.ok(stats.withSlaCompliance(sla)); } @@ -127,19 +130,20 @@ public class SearchController { @RequestParam(required = false) Instant to, @RequestParam(defaultValue = "24") int buckets, @RequestParam(required = false) String routeId, - @RequestParam(required = false) String application) { + @RequestParam(required = false) String application, + @RequestParam(required = false) String environment) { Instant end = to != null ? to : Instant.now(); if (routeId == null && application == null) { - return ResponseEntity.ok(searchService.timeseries(from, end, buckets)); + return ResponseEntity.ok(searchService.timeseries(from, end, buckets, environment)); } if (routeId == null) { - return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application)); + return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application, environment)); } List agentIds = resolveApplicationToAgentIds(application); if (routeId == null && agentIds.isEmpty()) { - return ResponseEntity.ok(searchService.timeseries(from, end, buckets)); + return ResponseEntity.ok(searchService.timeseries(from, end, buckets, environment)); } - return ResponseEntity.ok(searchService.timeseries(from, end, buckets, routeId, agentIds)); + return ResponseEntity.ok(searchService.timeseries(from, end, buckets, routeId, agentIds, environment)); } @GetMapping("/stats/timeseries/by-app") @@ -147,9 +151,10 @@ public class SearchController { public ResponseEntity> timeseriesByApp( @RequestParam Instant from, @RequestParam(required = false) Instant to, - @RequestParam(defaultValue = "24") int buckets) { + @RequestParam(defaultValue = "24") int buckets, + @RequestParam(required = false) String environment) { Instant end = to != null ? to : Instant.now(); - return ResponseEntity.ok(searchService.timeseriesGroupedByApp(from, end, buckets)); + return ResponseEntity.ok(searchService.timeseriesGroupedByApp(from, end, buckets, environment)); } @GetMapping("/stats/timeseries/by-route") @@ -158,18 +163,20 @@ public class SearchController { @RequestParam Instant from, @RequestParam(required = false) Instant to, @RequestParam(defaultValue = "24") int buckets, - @RequestParam String application) { + @RequestParam String application, + @RequestParam(required = false) String environment) { Instant end = to != null ? to : Instant.now(); - return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application)); + return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application, environment)); } @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 application, + @RequestParam(required = false) String environment) { Instant to = Instant.now(); Instant from = to.minus(java.time.Duration.ofDays(7)); - return ResponseEntity.ok(searchService.punchcard(from, to, application)); + return ResponseEntity.ok(searchService.punchcard(from, to, application, environment)); } @GetMapping("/attributes/keys") @@ -185,9 +192,10 @@ public class SearchController { @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)); + return ResponseEntity.ok(searchService.topErrors(from, end, application, routeId, limit, environment)); } /** diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentInstanceResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentInstanceResponse.java index 905cdf60..0bc85a6f 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentInstanceResponse.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentInstanceResponse.java @@ -14,6 +14,7 @@ public record AgentInstanceResponse( @NotNull String instanceId, @NotNull String displayName, @NotNull String applicationId, + String environmentId, @NotNull String status, @NotNull List routeIds, @NotNull Instant registeredAt, @@ -30,6 +31,7 @@ public record AgentInstanceResponse( long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds(); return new AgentInstanceResponse( info.instanceId(), info.displayName(), info.applicationId(), + info.environmentId(), info.state().name(), info.routeIds(), info.registeredAt(), info.lastHeartbeat(), info.version(), info.capabilities(), @@ -41,7 +43,8 @@ public record AgentInstanceResponse( public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) { return new AgentInstanceResponse( - instanceId, displayName, applicationId, status, routeIds, registeredAt, lastHeartbeat, + instanceId, displayName, applicationId, environmentId, + status, routeIds, registeredAt, lastHeartbeat, version, capabilities, tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds ); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseLogStore.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseLogStore.java index 68ca0392..50dfbfc1 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseLogStore.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseLogStore.java @@ -108,6 +108,11 @@ public class ClickHouseLogStore implements LogIndex { baseConditions.add("tenant_id = ?"); baseParams.add(tenantId); + if (request.environment() != null && !request.environment().isEmpty()) { + baseConditions.add("environment = ?"); + baseParams.add(request.environment()); + } + if (request.application() != null && !request.application().isEmpty()) { baseConditions.add("application = ?"); baseParams.add(request.application()); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchIndex.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchIndex.java index bf630401..ce8bcff2 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchIndex.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchIndex.java @@ -182,6 +182,11 @@ public class ClickHouseSearchIndex implements SearchIndex { params.add(request.durationMax()); } + if (request.environment() != null && !request.environment().isBlank()) { + conditions.add("environment = ?"); + params.add(request.environment()); + } + // Global full-text search: exact ID match, full-text on execution + processor level if (request.text() != null && !request.text().isBlank()) { String term = escapeLike(request.text()); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseStatsStore.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseStatsStore.java index 8a317f3d..ebe5ca3b 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseStatsStore.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseStatsStore.java @@ -42,20 +42,20 @@ public class ClickHouseStatsStore implements StatsStore { // ── Stats (aggregate) ──────────────────────────────────────────────── @Override - public ExecutionStats stats(Instant from, Instant to) { - return queryStats("stats_1m_all", from, to, List.of(), true); + public ExecutionStats stats(Instant from, Instant to, String environment) { + return queryStats("stats_1m_all", from, to, List.of(), true, environment); } @Override - public ExecutionStats statsForApp(Instant from, Instant to, String applicationId) { + public ExecutionStats statsForApp(Instant from, Instant to, String applicationId, String environment) { return queryStats("stats_1m_app", from, to, List.of( - new Filter("application_id", applicationId)), true); + new Filter("application_id", applicationId)), true, environment); } @Override - public ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List agentIds) { + public ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List agentIds, String environment) { return queryStats("stats_1m_route", from, to, List.of( - new Filter("route_id", routeId)), true); + new Filter("route_id", routeId)), true, environment); } @Override @@ -66,21 +66,21 @@ public class ClickHouseStatsStore implements StatsStore { // ── Timeseries ─────────────────────────────────────────────────────── @Override - public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount) { - return queryTimeseries("stats_1m_all", from, to, bucketCount, List.of(), true); + public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, String environment) { + return queryTimeseries("stats_1m_all", from, to, bucketCount, List.of(), true, environment); } @Override - public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId) { + public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId, String environment) { return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of( - new Filter("application_id", applicationId)), true); + new Filter("application_id", applicationId)), true, environment); } @Override public StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount, - String routeId, List agentIds) { + String routeId, List agentIds, String environment) { return queryTimeseries("stats_1m_route", from, to, bucketCount, List.of( - new Filter("route_id", routeId)), true); + new Filter("route_id", routeId)), true, environment); } @Override @@ -92,23 +92,23 @@ public class ClickHouseStatsStore implements StatsStore { // ── Grouped timeseries ─────────────────────────────────────────────── @Override - public Map timeseriesGroupedByApp(Instant from, Instant to, int bucketCount) { + public Map timeseriesGroupedByApp(Instant from, Instant to, int bucketCount, String environment) { return queryGroupedTimeseries("stats_1m_app", "application_id", from, to, - bucketCount, List.of()); + bucketCount, List.of(), environment); } @Override public Map timeseriesGroupedByRoute(Instant from, Instant to, - int bucketCount, String applicationId) { + int bucketCount, String applicationId, String environment) { return queryGroupedTimeseries("stats_1m_route", "route_id", from, to, - bucketCount, List.of(new Filter("application_id", applicationId))); + bucketCount, List.of(new Filter("application_id", applicationId)), environment); } // ── SLA compliance (raw table — prepared statements OK) ────────────── @Override public double slaCompliance(Instant from, Instant to, int thresholdMs, - String applicationId, String routeId) { + String applicationId, String routeId, String environment) { String sql = "SELECT " + "countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " + "countIf(status != 'RUNNING') AS total " + @@ -120,6 +120,10 @@ public class ClickHouseStatsStore implements StatsStore { params.add(tenantId); params.add(Timestamp.from(from)); params.add(Timestamp.from(to)); + if (environment != null && !environment.isBlank()) { + sql += " AND environment = ?"; + params.add(environment); + } if (applicationId != null) { sql += " AND application_id = ?"; params.add(applicationId); @@ -137,37 +141,59 @@ public class ClickHouseStatsStore implements StatsStore { } @Override - public Map slaCountsByApp(Instant from, Instant to, int defaultThresholdMs) { + public Map slaCountsByApp(Instant from, Instant to, int defaultThresholdMs, String environment) { String sql = "SELECT application_id, " + "countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " + "countIf(status != 'RUNNING') AS total " + "FROM executions FINAL " + - "WHERE tenant_id = ? AND start_time >= ? AND start_time < ? " + - "GROUP BY application_id"; + "WHERE tenant_id = ? AND start_time >= ? AND start_time < ?"; + + List params = new ArrayList<>(); + params.add(defaultThresholdMs); + params.add(tenantId); + params.add(Timestamp.from(from)); + params.add(Timestamp.from(to)); + if (environment != null && !environment.isBlank()) { + sql += " AND environment = ?"; + params.add(environment); + } + sql += " GROUP BY application_id"; Map result = new LinkedHashMap<>(); jdbc.query(sql, (rs) -> { result.put(rs.getString("application_id"), new long[]{rs.getLong("compliant"), rs.getLong("total")}); - }, defaultThresholdMs, tenantId, Timestamp.from(from), Timestamp.from(to)); + }, params.toArray()); return result; } @Override public Map slaCountsByRoute(Instant from, Instant to, - String applicationId, int thresholdMs) { + String applicationId, int thresholdMs, String environment) { String sql = "SELECT route_id, " + "countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " + "countIf(status != 'RUNNING') AS total " + "FROM executions FINAL " + "WHERE tenant_id = ? AND start_time >= ? AND start_time < ? " + - "AND application_id = ? GROUP BY route_id"; + "AND application_id = ?"; + + List params = new ArrayList<>(); + params.add(thresholdMs); + params.add(tenantId); + params.add(Timestamp.from(from)); + params.add(Timestamp.from(to)); + params.add(applicationId); + if (environment != null && !environment.isBlank()) { + sql += " AND environment = ?"; + params.add(environment); + } + sql += " GROUP BY route_id"; Map result = new LinkedHashMap<>(); jdbc.query(sql, (rs) -> { result.put(rs.getString("route_id"), new long[]{rs.getLong("compliant"), rs.getLong("total")}); - }, thresholdMs, tenantId, Timestamp.from(from), Timestamp.from(to), applicationId); + }, params.toArray()); return result; } @@ -175,12 +201,16 @@ public class ClickHouseStatsStore implements StatsStore { @Override public List topErrors(Instant from, Instant to, String applicationId, - String routeId, int limit) { + String routeId, int limit, String environment) { StringBuilder where = new StringBuilder( "status = 'FAILED' AND start_time >= ? AND start_time < ?"); List params = new ArrayList<>(); params.add(Timestamp.from(from)); params.add(Timestamp.from(to)); + if (environment != null && !environment.isBlank()) { + where.append(" AND environment = ?"); + params.add(environment); + } if (applicationId != null) { where.append(" AND application_id = ?"); params.add(applicationId); @@ -247,7 +277,7 @@ public class ClickHouseStatsStore implements StatsStore { } @Override - public int activeErrorTypes(Instant from, Instant to, String applicationId) { + public int activeErrorTypes(Instant from, Instant to, String applicationId, String environment) { String sql = "SELECT COUNT(DISTINCT COALESCE(error_type, substring(error_message, 1, 200))) " + "FROM executions FINAL " + "WHERE tenant_id = ? AND status = 'FAILED' AND start_time >= ? AND start_time < ?"; @@ -256,6 +286,10 @@ public class ClickHouseStatsStore implements StatsStore { params.add(tenantId); params.add(Timestamp.from(from)); params.add(Timestamp.from(to)); + if (environment != null && !environment.isBlank()) { + sql += " AND environment = ?"; + params.add(environment); + } if (applicationId != null) { sql += " AND application_id = ?"; params.add(applicationId); @@ -268,7 +302,7 @@ public class ClickHouseStatsStore implements StatsStore { // ── Punchcard (AggregatingMergeTree — literal SQL) ─────────────────── @Override - public List punchcard(Instant from, Instant to, String applicationId) { + public List punchcard(Instant from, Instant to, String applicationId, String environment) { String view = applicationId != null ? "stats_1m_app" : "stats_1m_all"; String sql = "SELECT toDayOfWeek(bucket, 1) % 7 AS weekday, " + "toHour(bucket) AS hour, " + @@ -278,6 +312,9 @@ public class ClickHouseStatsStore implements StatsStore { " WHERE tenant_id = " + lit(tenantId) + " AND bucket >= " + lit(from) + " AND bucket < " + lit(to); + if (environment != null && !environment.isBlank()) { + sql += " AND environment = " + lit(environment); + } if (applicationId != null) { sql += " AND application_id = " + lit(applicationId); } @@ -294,7 +331,7 @@ public class ClickHouseStatsStore implements StatsStore { /** * Format an Instant as a ClickHouse DateTime literal. - * Uses java.sql.Timestamp to match the JVM→ClickHouse timezone convention + * Uses java.sql.Timestamp to match the JVM-ClickHouse timezone convention * used by the JDBC driver, then truncates to second precision for DateTime * column compatibility. */ @@ -318,7 +355,7 @@ public class ClickHouseStatsStore implements StatsStore { * Build -Merge combinator SQL for the given view and time range. */ private String buildStatsSql(String view, Instant rangeFrom, Instant rangeTo, - List filters, boolean hasRunning) { + List filters, boolean hasRunning, String environment) { String runningCol = hasRunning ? "countIfMerge(running_count)" : "0"; String sql = "SELECT " + "countMerge(total_count) AS total_count, " + @@ -330,6 +367,9 @@ public class ClickHouseStatsStore implements StatsStore { " WHERE tenant_id = " + lit(tenantId) + " AND bucket >= " + lit(rangeFrom) + " AND bucket < " + lit(rangeTo); + if (environment != null && !environment.isBlank()) { + sql += " AND environment = " + lit(environment); + } for (Filter f : filters) { sql += " AND " + f.column() + " = " + lit(f.value()); } @@ -341,15 +381,15 @@ public class ClickHouseStatsStore implements StatsStore { * Uses literal SQL to avoid ClickHouse JDBC driver PreparedStatement issues. */ private ExecutionStats queryStats(String view, Instant from, Instant to, - List filters, boolean hasRunning) { + List filters, boolean hasRunning, String environment) { - String sql = buildStatsSql(view, from, to, filters, hasRunning); + String sql = buildStatsSql(view, from, to, filters, hasRunning, environment); long totalCount = 0, failedCount = 0, avgDuration = 0, p99Duration = 0, activeCount = 0; var currentResult = jdbc.query(sql, (rs, rowNum) -> { long tc = rs.getLong("total_count"); long fc = rs.getLong("failed_count"); - long ds = rs.getLong("duration_sum"); // Nullable → 0 if null + long ds = rs.getLong("duration_sum"); // Nullable -> 0 if null long p99 = (long) rs.getDouble("p99_duration"); // quantileMerge returns Float64 long ac = rs.getLong("active_count"); return new long[]{tc, fc, ds, p99, ac}; @@ -364,7 +404,7 @@ public class ClickHouseStatsStore implements StatsStore { // Previous period (shifted back 24h) Instant prevFrom = from.minus(Duration.ofHours(24)); Instant prevTo = to.minus(Duration.ofHours(24)); - String prevSql = buildStatsSql(view, prevFrom, prevTo, filters, hasRunning); + String prevSql = buildStatsSql(view, prevFrom, prevTo, filters, hasRunning, environment); long prevTotal = 0, prevFailed = 0, prevAvg = 0, prevP99 = 0; var prevResult = jdbc.query(prevSql, (rs, rowNum) -> { @@ -383,7 +423,7 @@ public class ClickHouseStatsStore implements StatsStore { // Today total Instant todayStart = Instant.now().truncatedTo(ChronoUnit.DAYS); - String todaySql = buildStatsSql(view, todayStart, Instant.now(), filters, hasRunning); + String todaySql = buildStatsSql(view, todayStart, Instant.now(), filters, hasRunning, environment); long totalToday = 0; var todayResult = jdbc.query(todaySql, (rs, rowNum) -> rs.getLong("total_count")); @@ -399,7 +439,7 @@ public class ClickHouseStatsStore implements StatsStore { */ private StatsTimeseries queryTimeseries(String view, Instant from, Instant to, int bucketCount, List filters, - boolean hasRunningCount) { + boolean hasRunningCount, String environment) { long intervalSeconds = Duration.between(from, to).toSeconds() / Math.max(bucketCount, 1); if (intervalSeconds < 60) intervalSeconds = 60; @@ -416,6 +456,9 @@ public class ClickHouseStatsStore implements StatsStore { " WHERE tenant_id = " + lit(tenantId) + " AND bucket >= " + lit(from) + " AND bucket < " + lit(to); + if (environment != null && !environment.isBlank()) { + sql += " AND environment = " + lit(environment); + } for (Filter f : filters) { sql += " AND " + f.column() + " = " + lit(f.value()); } @@ -439,7 +482,7 @@ public class ClickHouseStatsStore implements StatsStore { */ private Map queryGroupedTimeseries( String view, String groupCol, Instant from, Instant to, - int bucketCount, List filters) { + int bucketCount, List filters, String environment) { long intervalSeconds = Duration.between(from, to).toSeconds() / Math.max(bucketCount, 1); if (intervalSeconds < 60) intervalSeconds = 60; @@ -456,6 +499,9 @@ public class ClickHouseStatsStore implements StatsStore { " WHERE tenant_id = " + lit(tenantId) + " AND bucket >= " + lit(from) + " AND bucket < " + lit(to); + if (environment != null && !environment.isBlank()) { + sql += " AND environment = " + lit(environment); + } for (Filter f : filters) { sql += " AND " + f.column() + " = " + lit(f.value()); } diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/ClickHouseLogStoreIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/ClickHouseLogStoreIT.java index e5ada088..95299c1e 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/ClickHouseLogStoreIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/ClickHouseLogStoreIT.java @@ -53,7 +53,7 @@ class ClickHouseLogStoreIT { } private LogSearchRequest req(String application) { - return new LogSearchRequest(null, null, application, null, null, null, null, null, null, 100, "desc"); + return new LogSearchRequest(null, null, application, null, null, null, null, null, null, null, 100, "desc"); } // ── Tests ───────────────────────────────────────────────────────────── @@ -99,7 +99,7 @@ class ClickHouseLogStoreIT { )); LogSearchResponse result = store.search(new LogSearchRequest( - null, List.of("ERROR"), "my-app", null, null, null, null, null, null, 100, "desc")); + null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, 100, "desc")); assertThat(result.data()).hasSize(1); assertThat(result.data().get(0).level()).isEqualTo("ERROR"); @@ -116,7 +116,7 @@ class ClickHouseLogStoreIT { )); LogSearchResponse result = store.search(new LogSearchRequest( - null, List.of("WARN", "ERROR"), "my-app", null, null, null, null, null, null, 100, "desc")); + null, List.of("WARN", "ERROR"), "my-app", null, null, null, null, null, null, null, 100, "desc")); assertThat(result.data()).hasSize(2); } @@ -130,7 +130,7 @@ class ClickHouseLogStoreIT { )); LogSearchResponse result = store.search(new LogSearchRequest( - "order #12345", null, "my-app", null, null, null, null, null, null, 100, "desc")); + "order #12345", null, "my-app", null, null, null, null, null, null, null, 100, "desc")); assertThat(result.data()).hasSize(1); assertThat(result.data().get(0).message()).contains("order #12345"); @@ -147,7 +147,7 @@ class ClickHouseLogStoreIT { )); LogSearchResponse result = store.search(new LogSearchRequest( - null, null, "my-app", null, "exchange-abc", null, null, null, null, 100, "desc")); + null, null, "my-app", null, "exchange-abc", null, null, null, null, null, 100, "desc")); assertThat(result.data()).hasSize(1); assertThat(result.data().get(0).message()).isEqualTo("msg with exchange"); @@ -170,7 +170,7 @@ class ClickHouseLogStoreIT { Instant to = Instant.parse("2026-03-31T13:00:00Z"); LogSearchResponse result = store.search(new LogSearchRequest( - null, null, "my-app", null, null, null, from, to, null, 100, "desc")); + null, null, "my-app", null, null, null, null, from, to, null, 100, "desc")); assertThat(result.data()).hasSize(1); assertThat(result.data().get(0).message()).isEqualTo("noon"); @@ -188,7 +188,7 @@ class ClickHouseLogStoreIT { // No application filter — should return both LogSearchResponse result = store.search(new LogSearchRequest( - null, null, null, null, null, null, null, null, null, 100, "desc")); + null, null, null, null, null, null, null, null, null, null, 100, "desc")); assertThat(result.data()).hasSize(2); } @@ -202,7 +202,7 @@ class ClickHouseLogStoreIT { )); LogSearchResponse result = store.search(new LogSearchRequest( - null, null, "my-app", null, null, "OrderProcessor", null, null, null, 100, "desc")); + null, null, "my-app", null, null, "OrderProcessor", null, null, null, null, 100, "desc")); assertThat(result.data()).hasSize(1); assertThat(result.data().get(0).loggerName()).contains("OrderProcessor"); @@ -221,7 +221,7 @@ class ClickHouseLogStoreIT { // Page 1: limit 2 LogSearchResponse page1 = store.search(new LogSearchRequest( - null, null, "my-app", null, null, null, null, null, null, 2, "desc")); + null, null, "my-app", null, null, null, null, null, null, null, 2, "desc")); assertThat(page1.data()).hasSize(2); assertThat(page1.hasMore()).isTrue(); @@ -230,7 +230,7 @@ class ClickHouseLogStoreIT { // Page 2: use cursor LogSearchResponse page2 = store.search(new LogSearchRequest( - null, null, "my-app", null, null, null, null, null, page1.nextCursor(), 2, "desc")); + null, null, "my-app", null, null, null, null, null, null, page1.nextCursor(), 2, "desc")); assertThat(page2.data()).hasSize(2); assertThat(page2.hasMore()).isTrue(); @@ -238,7 +238,7 @@ class ClickHouseLogStoreIT { // Page 3: last page LogSearchResponse page3 = store.search(new LogSearchRequest( - null, null, "my-app", null, null, null, null, null, page2.nextCursor(), 2, "desc")); + null, null, "my-app", null, null, null, null, null, null, page2.nextCursor(), 2, "desc")); assertThat(page3.data()).hasSize(1); assertThat(page3.hasMore()).isFalse(); @@ -257,7 +257,7 @@ class ClickHouseLogStoreIT { // Filter for ERROR only, but counts should include all levels LogSearchResponse result = store.search(new LogSearchRequest( - null, List.of("ERROR"), "my-app", null, null, null, null, null, null, 100, "desc")); + null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, 100, "desc")); assertThat(result.data()).hasSize(1); assertThat(result.levelCounts()).containsEntry("INFO", 2L); @@ -275,7 +275,7 @@ class ClickHouseLogStoreIT { )); LogSearchResponse result = store.search(new LogSearchRequest( - null, null, "my-app", null, null, null, null, null, null, 100, "asc")); + null, null, "my-app", null, null, null, null, null, null, null, 100, "asc")); assertThat(result.data()).hasSize(3); assertThat(result.data().get(0).message()).isEqualTo("msg-1"); diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/ClickHouseSearchIndexIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/ClickHouseSearchIndexIT.java index 3fcae1f0..17eebe26 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/ClickHouseSearchIndexIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/ClickHouseSearchIndexIT.java @@ -118,7 +118,7 @@ class ClickHouseSearchIndexIT { void search_withNoFilters_returnsAllExecutions() { SearchRequest request = new SearchRequest( null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -130,7 +130,7 @@ class ClickHouseSearchIndexIT { void search_byStatus_filtersCorrectly() { SearchRequest request = new SearchRequest( "FAILED", null, null, null, null, null, null, null, null, null, - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -145,7 +145,7 @@ class ClickHouseSearchIndexIT { // Time window covering exec-1 and exec-2 but not exec-3 SearchRequest request = new SearchRequest( null, baseTime, baseTime.plusMillis(1500), null, null, null, null, null, null, null, - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -158,7 +158,7 @@ class ClickHouseSearchIndexIT { void search_fullTextSearch_findsInErrorMessage() { SearchRequest request = new SearchRequest( null, null, null, null, null, null, "NullPointerException", null, null, null, - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -170,7 +170,7 @@ class ClickHouseSearchIndexIT { void search_fullTextSearch_findsInInputBody() { SearchRequest request = new SearchRequest( null, null, null, null, null, null, "12345", null, null, null, - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -182,7 +182,7 @@ class ClickHouseSearchIndexIT { void search_textInBody_searchesProcessorBodies() { SearchRequest request = new SearchRequest( null, null, null, null, null, null, null, "Hello World", null, null, - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -194,7 +194,7 @@ class ClickHouseSearchIndexIT { void search_textInHeaders_searchesProcessorHeaders() { SearchRequest request = new SearchRequest( null, null, null, null, null, null, null, null, "secret-token", null, - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -206,7 +206,7 @@ class ClickHouseSearchIndexIT { void search_textInErrors_searchesErrorFields() { SearchRequest request = new SearchRequest( null, null, null, null, null, null, null, null, null, "Foo.bar", - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -218,7 +218,7 @@ class ClickHouseSearchIndexIT { void search_withHighlight_returnsSnippet() { SearchRequest request = new SearchRequest( null, null, null, null, null, null, "NullPointerException", null, null, null, - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -230,7 +230,7 @@ class ClickHouseSearchIndexIT { void search_pagination_works() { SearchRequest request = new SearchRequest( null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, 0, 2, null, null); + null, null, null, null, null, 0, 2, null, null, null); SearchResult result = searchIndex.search(request); @@ -244,7 +244,7 @@ class ClickHouseSearchIndexIT { void search_byApplication_filtersCorrectly() { SearchRequest request = new SearchRequest( null, null, null, null, null, null, null, null, null, null, - null, null, null, "other-app", null, 0, 50, null, null); + null, null, null, "other-app", null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -256,7 +256,7 @@ class ClickHouseSearchIndexIT { void search_byAgentIds_filtersCorrectly() { SearchRequest request = new SearchRequest( null, null, null, null, null, null, null, null, null, null, - null, null, null, null, List.of("agent-b"), 0, 50, null, null); + null, null, null, null, List.of("agent-b"), 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -268,7 +268,7 @@ class ClickHouseSearchIndexIT { void count_returnsMatchingCount() { SearchRequest request = new SearchRequest( "COMPLETED", null, null, null, null, null, null, null, null, null, - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); long count = searchIndex.count(request); @@ -279,7 +279,7 @@ class ClickHouseSearchIndexIT { void search_multipleStatusFilter_works() { SearchRequest request = new SearchRequest( "COMPLETED,FAILED", null, null, null, null, null, null, null, null, null, - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -290,7 +290,7 @@ class ClickHouseSearchIndexIT { void search_byCorrelationId_filtersCorrectly() { SearchRequest request = new SearchRequest( null, null, null, null, null, "corr-1", null, null, null, null, - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); @@ -302,7 +302,7 @@ class ClickHouseSearchIndexIT { void search_byDurationRange_filtersCorrectly() { SearchRequest request = new SearchRequest( null, null, null, 300L, 600L, null, null, null, null, null, - null, null, null, null, null, 0, 50, null, null); + null, null, null, null, null, 0, 50, null, null, null); SearchResult result = searchIndex.search(request); diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/ClickHouseChunkPipelineIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/ClickHouseChunkPipelineIT.java index 4bb63f97..c26362b7 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/ClickHouseChunkPipelineIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/ClickHouseChunkPipelineIT.java @@ -162,7 +162,7 @@ class ClickHouseChunkPipelineIT { null, null, null, null, null, null, "ORD-123", null, null, null, null, null, null, null, null, - 0, 50, null, null)); + 0, 50, null, null, null)); assertThat(result.total()).isEqualTo(1); assertThat(result.data().get(0).executionId()).isEqualTo("pipeline-1"); assertThat(result.data().get(0).status()).isEqualTo("COMPLETED"); @@ -173,7 +173,7 @@ class ClickHouseChunkPipelineIT { null, null, null, null, null, null, null, "ABC-123", null, null, null, null, null, null, null, - 0, 50, null, null)); + 0, 50, null, null, null)); assertThat(bodyResult.total()).isEqualTo(1); // Verify iteration data in processor_executions diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/ClickHouseStatsStoreIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/ClickHouseStatsStoreIT.java index 443cb9de..903cd032 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/ClickHouseStatsStoreIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/ClickHouseStatsStoreIT.java @@ -156,7 +156,7 @@ class ClickHouseStatsStoreIT { Instant from = BASE.minusSeconds(60); Instant to = BASE.plusSeconds(300); - ExecutionStats stats = store.stats(from, to); + ExecutionStats stats = store.stats(from, to, null); assertThat(stats.totalCount()).isEqualTo(10); assertThat(stats.failedCount()).isEqualTo(2); @@ -170,10 +170,10 @@ class ClickHouseStatsStoreIT { Instant from = BASE.minusSeconds(60); Instant to = BASE.plusSeconds(300); - ExecutionStats app1 = store.statsForApp(from, to, "app-1"); + ExecutionStats app1 = store.statsForApp(from, to, "app-1", null); assertThat(app1.totalCount()).isEqualTo(8); - ExecutionStats app2 = store.statsForApp(from, to, "app-2"); + ExecutionStats app2 = store.statsForApp(from, to, "app-2", null); assertThat(app2.totalCount()).isEqualTo(2); } @@ -182,7 +182,7 @@ class ClickHouseStatsStoreIT { Instant from = BASE.minusSeconds(60); Instant to = BASE.plusSeconds(300); - ExecutionStats routeA = store.statsForRoute(from, to, "route-a", List.of()); + ExecutionStats routeA = store.statsForRoute(from, to, "route-a", List.of(), null); assertThat(routeA.totalCount()).isEqualTo(6); } @@ -193,7 +193,7 @@ class ClickHouseStatsStoreIT { Instant from = BASE.minusSeconds(60); Instant to = BASE.plusSeconds(300); - StatsTimeseries ts = store.timeseries(from, to, 5); + StatsTimeseries ts = store.timeseries(from, to, 5, null); assertThat(ts.buckets()).isNotEmpty(); long totalAcrossBuckets = ts.buckets().stream() @@ -206,7 +206,7 @@ class ClickHouseStatsStoreIT { Instant from = BASE.minusSeconds(60); Instant to = BASE.plusSeconds(300); - StatsTimeseries ts = store.timeseriesForApp(from, to, 5, "app-1"); + StatsTimeseries ts = store.timeseriesForApp(from, to, 5, "app-1", null); long totalAcrossBuckets = ts.buckets().stream() .mapToLong(StatsTimeseries.TimeseriesBucket::totalCount).sum(); @@ -218,7 +218,7 @@ class ClickHouseStatsStoreIT { Instant from = BASE.minusSeconds(60); Instant to = BASE.plusSeconds(300); - Map grouped = store.timeseriesGroupedByApp(from, to, 5); + Map grouped = store.timeseriesGroupedByApp(from, to, 5, null); assertThat(grouped).containsKeys("app-1", "app-2"); } @@ -228,7 +228,7 @@ class ClickHouseStatsStoreIT { Instant from = BASE.minusSeconds(60); Instant to = BASE.plusSeconds(300); - Map grouped = store.timeseriesGroupedByRoute(from, to, 5, "app-1"); + Map grouped = store.timeseriesGroupedByRoute(from, to, 5, "app-1", null); assertThat(grouped).containsKeys("route-a", "route-b"); } @@ -244,7 +244,7 @@ class ClickHouseStatsStoreIT { // compliant (<=250ms): exec-01(200), exec-05(100), exec-06(150), exec-07(50), exec-08(60) = 5 // total non-running: 9 // compliance = 5/9 * 100 ~ 55.56% - double sla = store.slaCompliance(from, to, 250, null, null); + double sla = store.slaCompliance(from, to, 250, null, null, null); assertThat(sla).isBetween(55.0, 56.0); } @@ -255,7 +255,7 @@ class ClickHouseStatsStoreIT { Instant from = BASE.minusSeconds(60); Instant to = BASE.plusSeconds(300); - List errors = store.topErrors(from, to, null, null, 10); + List errors = store.topErrors(from, to, null, null, 10, null); assertThat(errors).isNotEmpty(); assertThat(errors.get(0).errorType()).isEqualTo("NPE"); @@ -269,7 +269,7 @@ class ClickHouseStatsStoreIT { Instant from = BASE.minusSeconds(60); Instant to = BASE.plusSeconds(300); - int count = store.activeErrorTypes(from, to, "app-1"); + int count = store.activeErrorTypes(from, to, "app-1", null); assertThat(count).isEqualTo(1); // only "NPE" } @@ -281,7 +281,7 @@ class ClickHouseStatsStoreIT { Instant from = BASE.minusSeconds(60); Instant to = BASE.plusSeconds(300); - List cells = store.punchcard(from, to, null); + List cells = store.punchcard(from, to, null, null); assertThat(cells).isNotEmpty(); long totalCount = cells.stream().mapToLong(PunchcardCell::totalCount).sum(); @@ -294,7 +294,7 @@ class ClickHouseStatsStoreIT { Instant to = BASE.plusSeconds(300); // threshold=250ms - Map counts = store.slaCountsByApp(from, to, 250); + Map counts = store.slaCountsByApp(from, to, 250, null); assertThat(counts).containsKeys("app-1", "app-2"); // app-1: 8 total executions, all non-RUNNING @@ -313,7 +313,7 @@ class ClickHouseStatsStoreIT { Instant from = BASE.minusSeconds(60); Instant to = BASE.plusSeconds(300); - Map counts = store.slaCountsByRoute(from, to, "app-1", 250); + Map counts = store.slaCountsByRoute(from, to, "app-1", 250, null); assertThat(counts).containsKeys("route-a", "route-b"); // route-a: exec-01(200)OK, exec-02(300)NO, exec-03(400)NO, exec-04(500)NO, diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/LogSearchRequest.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/LogSearchRequest.java index 132453a3..e8372a50 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/LogSearchRequest.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/LogSearchRequest.java @@ -12,6 +12,7 @@ import java.util.List; * @param instanceId agent instance ID filter * @param exchangeId Camel exchange ID filter * @param logger logger name substring filter + * @param environment optional environment filter (e.g. "dev", "staging", "prod") * @param from inclusive start of time range (required) * @param to inclusive end of time range (required) * @param cursor ISO timestamp cursor for keyset pagination @@ -25,6 +26,7 @@ public record LogSearchRequest( String instanceId, String exchangeId, String logger, + String environment, Instant from, Instant to, String cursor, diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java index 38f315d8..ba763b42 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java @@ -28,6 +28,7 @@ import java.util.List; * @param limit page size (default 50, max 500) * @param sortField column to sort by (default: startTime) * @param sortDir sort direction: asc or desc (default: desc) + * @param environment optional environment filter (e.g. "dev", "staging", "prod") */ public record SearchRequest( String status, @@ -48,7 +49,8 @@ public record SearchRequest( int offset, int limit, String sortField, - String sortDir + String sortDir, + String environment ) { private static final int DEFAULT_LIMIT = 50; @@ -90,7 +92,17 @@ public record SearchRequest( status, timeFrom, timeTo, durationMin, durationMax, correlationId, text, textInBody, textInHeaders, textInErrors, routeId, instanceId, processorType, applicationId, resolvedInstanceIds, - offset, limit, sortField, sortDir + offset, limit, sortField, sortDir, environment + ); + } + + /** Create a copy with the given environment filter. */ + public SearchRequest withEnvironment(String env) { + return new SearchRequest( + status, timeFrom, timeTo, durationMin, durationMax, correlationId, + text, textInBody, textInHeaders, textInErrors, + routeId, instanceId, processorType, applicationId, instanceIds, + offset, limit, sortField, sortDir, env ); } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java index 719285af..53ae779a 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java @@ -30,65 +30,126 @@ public class SearchService { } public ExecutionStats stats(Instant from, Instant to) { - return statsStore.stats(from, to); + return statsStore.stats(from, to, null); + } + + public ExecutionStats stats(Instant from, Instant to, String environment) { + return statsStore.stats(from, to, environment); } public ExecutionStats statsForApp(Instant from, Instant to, String applicationId) { - return statsStore.statsForApp(from, to, applicationId); + return statsStore.statsForApp(from, to, applicationId, null); + } + + public ExecutionStats statsForApp(Instant from, Instant to, String applicationId, String environment) { + return statsStore.statsForApp(from, to, applicationId, environment); } public ExecutionStats stats(Instant from, Instant to, String routeId, List agentIds) { - return statsStore.statsForRoute(from, to, routeId, agentIds); + return statsStore.statsForRoute(from, to, routeId, agentIds, null); + } + + public ExecutionStats stats(Instant from, Instant to, String routeId, List agentIds, String environment) { + return statsStore.statsForRoute(from, to, routeId, agentIds, environment); } public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount) { - return statsStore.timeseries(from, to, bucketCount); + return statsStore.timeseries(from, to, bucketCount, null); + } + + public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, String environment) { + return statsStore.timeseries(from, to, bucketCount, environment); } public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId) { - return statsStore.timeseriesForApp(from, to, bucketCount, applicationId); + return statsStore.timeseriesForApp(from, to, bucketCount, applicationId, null); + } + + public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId, String environment) { + return statsStore.timeseriesForApp(from, to, bucketCount, applicationId, environment); } public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, String routeId, List agentIds) { - return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds); + return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds, null); + } + + public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, + String routeId, List agentIds, String environment) { + return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds, environment); } // ── Dashboard-specific queries ──────────────────────────────────────── public Map timeseriesGroupedByApp(Instant from, Instant to, int bucketCount) { - return statsStore.timeseriesGroupedByApp(from, to, bucketCount); + return statsStore.timeseriesGroupedByApp(from, to, bucketCount, null); + } + + public Map timeseriesGroupedByApp(Instant from, Instant to, int bucketCount, String environment) { + return statsStore.timeseriesGroupedByApp(from, to, bucketCount, environment); } public Map timeseriesGroupedByRoute(Instant from, Instant to, int bucketCount, String applicationId) { - return statsStore.timeseriesGroupedByRoute(from, to, bucketCount, applicationId); + return statsStore.timeseriesGroupedByRoute(from, to, bucketCount, applicationId, null); + } + + public Map timeseriesGroupedByRoute(Instant from, Instant to, + int bucketCount, String applicationId, String environment) { + return statsStore.timeseriesGroupedByRoute(from, to, bucketCount, applicationId, environment); } public double slaCompliance(Instant from, Instant to, int thresholdMs, String applicationId, String routeId) { - return statsStore.slaCompliance(from, to, thresholdMs, applicationId, routeId); + return statsStore.slaCompliance(from, to, thresholdMs, applicationId, routeId, null); + } + + public double slaCompliance(Instant from, Instant to, int thresholdMs, + String applicationId, String routeId, String environment) { + return statsStore.slaCompliance(from, to, thresholdMs, applicationId, routeId, environment); } public Map slaCountsByApp(Instant from, Instant to, int defaultThresholdMs) { - return statsStore.slaCountsByApp(from, to, defaultThresholdMs); + return statsStore.slaCountsByApp(from, to, defaultThresholdMs, null); + } + + public Map slaCountsByApp(Instant from, Instant to, int defaultThresholdMs, String environment) { + return statsStore.slaCountsByApp(from, to, defaultThresholdMs, environment); } public Map slaCountsByRoute(Instant from, Instant to, String applicationId, int thresholdMs) { - return statsStore.slaCountsByRoute(from, to, applicationId, thresholdMs); + return statsStore.slaCountsByRoute(from, to, applicationId, thresholdMs, null); + } + + public Map slaCountsByRoute(Instant from, Instant to, + String applicationId, int thresholdMs, String environment) { + return statsStore.slaCountsByRoute(from, to, applicationId, thresholdMs, environment); } public List topErrors(Instant from, Instant to, String applicationId, String routeId, int limit) { - return statsStore.topErrors(from, to, applicationId, routeId, limit); + return statsStore.topErrors(from, to, applicationId, routeId, limit, null); + } + + public List topErrors(Instant from, Instant to, String applicationId, + String routeId, int limit, String environment) { + return statsStore.topErrors(from, to, applicationId, routeId, limit, environment); } public int activeErrorTypes(Instant from, Instant to, String applicationId) { - return statsStore.activeErrorTypes(from, to, applicationId); + return statsStore.activeErrorTypes(from, to, applicationId, null); + } + + public int activeErrorTypes(Instant from, Instant to, String applicationId, String environment) { + return statsStore.activeErrorTypes(from, to, applicationId, environment); } public List punchcard(Instant from, Instant to, String applicationId) { - return statsStore.punchcard(from, to, applicationId); + return statsStore.punchcard(from, to, applicationId, null); + } + + public List punchcard(Instant from, Instant to, String applicationId, String environment) { + return statsStore.punchcard(from, to, applicationId, environment); } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/StatsStore.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/StatsStore.java index 8a9c4654..744c1398 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/StatsStore.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/StatsStore.java @@ -11,58 +11,58 @@ import java.util.Map; public interface StatsStore { // Global stats (stats_1m_all) - ExecutionStats stats(Instant from, Instant to); + ExecutionStats stats(Instant from, Instant to, String environment); // Per-app stats (stats_1m_app) - ExecutionStats statsForApp(Instant from, Instant to, String applicationId); + ExecutionStats statsForApp(Instant from, Instant to, String applicationId, String environment); // Per-route stats (stats_1m_route), optionally scoped to specific agents - ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List agentIds); + ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List agentIds, String environment); // Per-processor stats (stats_1m_processor) ExecutionStats statsForProcessor(Instant from, Instant to, String routeId, String processorType); // Global timeseries - StatsTimeseries timeseries(Instant from, Instant to, int bucketCount); + StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, String environment); // Per-app timeseries - StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId); + StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId, String environment); // Per-route timeseries, optionally scoped to specific agents StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount, - String routeId, List agentIds); + String routeId, List agentIds, String environment); // Per-processor timeseries StatsTimeseries timeseriesForProcessor(Instant from, Instant to, int bucketCount, String routeId, String processorType); // Grouped timeseries by application (for L1 dashboard charts) - Map timeseriesGroupedByApp(Instant from, Instant to, int bucketCount); + Map timeseriesGroupedByApp(Instant from, Instant to, int bucketCount, String environment); // Grouped timeseries by route within an application (for L2 dashboard charts) Map timeseriesGroupedByRoute(Instant from, Instant to, int bucketCount, - String applicationId); + String applicationId, String environment); // SLA compliance: % of completed exchanges with duration <= thresholdMs double slaCompliance(Instant from, Instant to, int thresholdMs, - String applicationId, String routeId); + String applicationId, String routeId, String environment); // Batch SLA counts by app: {appId -> [compliant, total]} - Map slaCountsByApp(Instant from, Instant to, int defaultThresholdMs); + Map slaCountsByApp(Instant from, Instant to, int defaultThresholdMs, String environment); // Batch SLA counts by route within an app: {routeId -> [compliant, total]} Map slaCountsByRoute(Instant from, Instant to, String applicationId, - int thresholdMs); + int thresholdMs, String environment); // Top N errors with velocity trend List topErrors(Instant from, Instant to, String applicationId, - String routeId, int limit); + String routeId, int limit, String environment); // Count of distinct error types in window - int activeErrorTypes(Instant from, Instant to, String applicationId); + int activeErrorTypes(Instant from, Instant to, String applicationId, String environment); // Punchcard: aggregate by weekday (0=Sun..6=Sat) x hour (0-23) over last 7 days - List punchcard(Instant from, Instant to, String applicationId); + List punchcard(Instant from, Instant to, String applicationId, String environment); record PunchcardCell(int weekday, int hour, long totalCount, long failedCount) {} } diff --git a/ui/src/api/queries/agents.ts b/ui/src/api/queries/agents.ts index 504156bd..abd23e36 100644 --- a/ui/src/api/queries/agents.ts +++ b/ui/src/api/queries/agents.ts @@ -1,19 +1,27 @@ import { useQuery } from '@tanstack/react-query'; -import { api } from '../client'; import { config } from '../../config'; import { useAuthStore } from '../../auth/auth-store'; import { useRefreshInterval } from './use-refresh-interval'; -export function useAgents(status?: string, application?: string) { +export function useAgents(status?: string, application?: string, environment?: string) { const refetchInterval = useRefreshInterval(10_000); return useQuery({ - queryKey: ['agents', status, application], + queryKey: ['agents', status, application, environment], queryFn: async () => { - const { data, error } = await api.GET('/agents', { - params: { query: { ...(status ? { status } : {}), ...(application ? { application } : {}) } }, + 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', + }, }); - if (error) throw new Error('Failed to load agents'); - return data!; + if (!res.ok) throw new Error('Failed to load agents'); + return res.json(); }, refetchInterval, }); diff --git a/ui/src/api/queries/catalog.ts b/ui/src/api/queries/catalog.ts index 65a416cc..bb74abbd 100644 --- a/ui/src/api/queries/catalog.ts +++ b/ui/src/api/queries/catalog.ts @@ -3,15 +3,16 @@ import { config } from '../../config'; import { useAuthStore } from '../../auth/auth-store'; import { useRefreshInterval } from './use-refresh-interval'; -export function useRouteCatalog(from?: string, to?: string) { +export function useRouteCatalog(from?: string, to?: string, environment?: string) { const refetchInterval = useRefreshInterval(15_000); return useQuery({ - queryKey: ['routes', 'catalog', from, to], + queryKey: ['routes', 'catalog', from, to, 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}` : ''}`, { headers: { diff --git a/ui/src/api/queries/dashboard.ts b/ui/src/api/queries/dashboard.ts index 20ffcb86..889b9f97 100644 --- a/ui/src/api/queries/dashboard.ts +++ b/ui/src/api/queries/dashboard.ts @@ -39,12 +39,12 @@ export interface GroupedTimeseries { [key: string]: { buckets: TimeseriesBucket[] }; } -export function useTimeseriesByApp(from?: string, to?: string) { +export function useTimeseriesByApp(from?: string, to?: string, environment?: string) { const refetchInterval = useRefreshInterval(30_000); return useQuery({ - queryKey: ['dashboard', 'timeseries-by-app', from, to], + queryKey: ['dashboard', 'timeseries-by-app', from, to, environment], queryFn: () => fetchJson('/search/stats/timeseries/by-app', { - from, to, buckets: '24', + from, to, buckets: '24', environment, }), enabled: !!from, placeholderData: (prev: GroupedTimeseries | undefined) => prev, @@ -54,12 +54,12 @@ export function useTimeseriesByApp(from?: string, to?: string) { // ── Timeseries by route (L2 charts) ─────────────────────────────────── -export function useTimeseriesByRoute(from?: string, to?: string, application?: string) { +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], + queryKey: ['dashboard', 'timeseries-by-route', from, to, application, environment], queryFn: () => fetchJson('/search/stats/timeseries/by-route', { - from, to, application, buckets: '24', + from, to, application, buckets: '24', environment, }), enabled: !!from && !!application, placeholderData: (prev: GroupedTimeseries | undefined) => prev, @@ -79,12 +79,12 @@ export interface TopError { lastSeen: string; } -export function useTopErrors(from?: string, to?: string, application?: string, routeId?: string) { +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], + queryKey: ['dashboard', 'top-errors', from, to, application, routeId, environment], queryFn: () => fetchJson('/search/errors/top', { - from, to, application, routeId, limit: '5', + from, to, application, routeId, limit: '5', environment, }), enabled: !!from, placeholderData: (prev: TopError[] | undefined) => prev, @@ -101,11 +101,11 @@ export interface PunchcardCell { failedCount: number; } -export function usePunchcard(application?: string) { +export function usePunchcard(application?: string, environment?: string) { const refetchInterval = useRefreshInterval(60_000); return useQuery({ - queryKey: ['dashboard', 'punchcard', application], - queryFn: () => fetchJson('/search/stats/punchcard', { application }), + queryKey: ['dashboard', 'punchcard', application, environment], + queryFn: () => fetchJson('/search/stats/punchcard', { application, environment }), placeholderData: (prev: PunchcardCell[] | undefined) => prev ?? [], refetchInterval, }); diff --git a/ui/src/api/queries/executions.ts b/ui/src/api/queries/executions.ts index efd1c3b6..27cdf137 100644 --- a/ui/src/api/queries/executions.ts +++ b/ui/src/api/queries/executions.ts @@ -8,10 +8,11 @@ export function useExecutionStats( timeTo: string | undefined, routeId?: string, application?: string, + environment?: string, ) { const live = useLiveQuery(10_000); return useQuery({ - queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, application], + queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, application, environment], queryFn: async () => { const { data, error } = await api.GET('/search/stats', { params: { @@ -20,6 +21,7 @@ export function useExecutionStats( to: timeTo || undefined, routeId: routeId || undefined, application: application || undefined, + environment: environment || undefined, }, }, }); @@ -70,10 +72,11 @@ export function useStatsTimeseries( timeTo: string | undefined, routeId?: string, application?: string, + environment?: string, ) { const live = useLiveQuery(30_000); return useQuery({ - queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, application], + queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, application, environment], queryFn: async () => { const { data, error } = await api.GET('/search/stats/timeseries', { params: { @@ -83,6 +86,7 @@ export function useStatsTimeseries( buckets: 24, routeId: routeId || undefined, application: application || undefined, + environment: environment || undefined, }, }, }); diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 44f48f37..d392613b 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -1502,6 +1502,7 @@ export interface components { limit?: number; sortField?: string; sortDir?: string; + environment?: string; }; ExecutionSummary: { executionId: string; @@ -1960,6 +1961,7 @@ export interface components { instanceId: string; displayName: string; applicationId: string; + environmentId?: string; status: string; routeIds: string[]; /** Format: date-time */ @@ -2773,6 +2775,7 @@ export interface operations { agentId?: string; processorType?: string; application?: string; + environment?: string; offset?: number; limit?: number; sortField?: string; @@ -3795,6 +3798,7 @@ export interface operations { to?: string; routeId?: string; application?: string; + environment?: string; }; header?: never; path?: never; @@ -3821,6 +3825,7 @@ export interface operations { buckets?: number; routeId?: string; application?: string; + environment?: string; }; header?: never; path?: never; @@ -3846,6 +3851,7 @@ export interface operations { to?: string; buckets?: number; application: string; + environment?: string; }; header?: never; path?: never; @@ -3872,6 +3878,7 @@ export interface operations { from: string; to?: string; buckets?: number; + environment?: string; }; header?: never; path?: never; @@ -3896,6 +3903,7 @@ export interface operations { parameters: { query?: { application?: string; + environment?: string; }; header?: never; path?: never; @@ -3921,6 +3929,7 @@ export interface operations { to?: string; application?: string; routeId?: string; + environment?: string; limit?: number; }; header?: never; @@ -4334,6 +4343,7 @@ export interface operations { query?: { status?: string; application?: string; + environment?: string; }; header?: never; path?: never; diff --git a/ui/src/components/EnvironmentSelector.module.css b/ui/src/components/EnvironmentSelector.module.css new file mode 100644 index 00000000..2dd1984c --- /dev/null +++ b/ui/src/components/EnvironmentSelector.module.css @@ -0,0 +1,26 @@ +.select { + appearance: none; + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 20px 2px 6px; + font-size: 11px; + color: var(--text-muted); + cursor: pointer; + outline: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' fill='none' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 5px center; + background-size: 10px 6px; + min-width: 80px; + line-height: 1.4; +} + +.select:hover { + border-color: var(--text-muted); +} + +.select:focus-visible { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} diff --git a/ui/src/components/EnvironmentSelector.tsx b/ui/src/components/EnvironmentSelector.tsx new file mode 100644 index 00000000..5817445a --- /dev/null +++ b/ui/src/components/EnvironmentSelector.tsx @@ -0,0 +1,25 @@ +import styles from './EnvironmentSelector.module.css'; + +interface EnvironmentSelectorProps { + environments: string[]; + value: string | undefined; + onChange: (env: string | undefined) => void; +} + +export function EnvironmentSelector({ environments, value, onChange }: EnvironmentSelectorProps) { + if (environments.length === 0) return null; + + return ( + + ); +} diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 1d5253ef..968154df 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -1,4 +1,4 @@ -import { Outlet, useNavigate, useLocation } from 'react-router'; +import { Outlet, useNavigate, useLocation, useSearchParams } from 'react-router'; import { AppShell, Sidebar, @@ -26,6 +26,7 @@ import { useAuthStore } from '../auth/auth-store'; import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react'; import type { ReactNode } from 'react'; import { ContentTabs } from './ContentTabs'; +import { EnvironmentSelector } from './EnvironmentSelector'; import { useScope } from '../hooks/useScope'; import { buildAppTreeNodes, @@ -271,12 +272,38 @@ const SK_COLLAPSED = 'sidebar:collapsed'; function LayoutContent() { const navigate = useNavigate(); const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const { timeRange, autoRefresh, refreshTimeRange } = useGlobalFilters(); - const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString()); - const { data: agents } = useAgents(); + + // --- Environment filtering ----------------------------------------- + const selectedEnv = searchParams.get('env') || undefined; + const setSelectedEnv = useCallback((env: string | undefined) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (env) { + next.set('env', env); + } else { + next.delete('env'); + } + return next; + }, { replace: true }); + }, [setSearchParams]); + + const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString(), selectedEnv); + const { data: agents } = useAgents(undefined, undefined, selectedEnv); const { data: attributeKeys } = useAttributeKeys(); + // Extract distinct environments from agents + const environments: string[] = useMemo(() => { + if (!agents) return []; + const envSet = new Set(); + for (const a of agents as any[]) { + if (a.environmentId) envSet.add(a.environmentId); + } + return [...envSet].sort(); + }, [agents]); + // --- Admin search data (only fetched on admin pages) ---------------- const isAdminPage = location.pathname.startsWith('/admin'); const { data: adminUsers } = useUsers(isAdminPage); @@ -675,6 +702,7 @@ function LayoutContent() { @@ -689,6 +717,16 @@ function LayoutContent() { data={searchData} /> + {!isAdminPage && environments.length > 0 && ( +
+ +
+ )} + {!isAdminPage && ( )}