feat: add environment filtering across all APIs and UI
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m8s
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / docker (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 15:42:26 +02:00
parent babdc1d7a4
commit 694d0eef59
25 changed files with 439 additions and 160 deletions

View File

@@ -271,7 +271,8 @@ public class AgentRegistrationController {
content = @Content(schema = @Schema(implementation = ErrorResponse.class))) content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<List<AgentInstanceResponse>> listAgents( public ResponseEntity<List<AgentInstanceResponse>> listAgents(
@RequestParam(required = false) String status, @RequestParam(required = false) String status,
@RequestParam(required = false) String application) { @RequestParam(required = false) String application,
@RequestParam(required = false) String environment) {
List<AgentInfo> agents; List<AgentInfo> agents;
if (status != null) { if (status != null) {
@@ -292,6 +293,13 @@ public class AgentRegistrationController {
.toList(); .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 // Enrich with runtime metrics from continuous aggregates
Map<String, double[]> agentMetrics = queryAgentMetrics(); Map<String, double[]> agentMetrics = queryAgentMetrics();
final List<AgentInfo> finalAgents = agents; final List<AgentInfo> finalAgents = agents;

View File

@@ -40,6 +40,7 @@ public class LogQueryController {
@RequestParam(name = "agentId", required = false) String instanceId, @RequestParam(name = "agentId", required = false) String instanceId,
@RequestParam(required = false) String exchangeId, @RequestParam(required = false) String exchangeId,
@RequestParam(required = false) String logger, @RequestParam(required = false) String logger,
@RequestParam(required = false) String environment,
@RequestParam(required = false) String from, @RequestParam(required = false) String from,
@RequestParam(required = false) String to, @RequestParam(required = false) String to,
@RequestParam(required = false) String cursor, @RequestParam(required = false) String cursor,
@@ -63,7 +64,7 @@ public class LogQueryController {
LogSearchRequest request = new LogSearchRequest( LogSearchRequest request = new LogSearchRequest(
searchText, levels, application, instanceId, exchangeId, searchText, levels, application, instanceId, exchangeId,
logger, fromInstant, toInstant, cursor, limit, sort); logger, environment, fromInstant, toInstant, cursor, limit, sort);
LogSearchResponse result = logIndex.search(request); LogSearchResponse result = logIndex.search(request);

View File

@@ -59,9 +59,17 @@ public class RouteCatalogController {
@ApiResponse(responseCode = "200", description = "Catalog returned") @ApiResponse(responseCode = "200", description = "Catalog returned")
public ResponseEntity<List<AppCatalogEntry>> getCatalog( public ResponseEntity<List<AppCatalogEntry>> getCatalog(
@RequestParam(required = false) String from, @RequestParam(required = false) String from,
@RequestParam(required = false) String to) { @RequestParam(required = false) String to,
@RequestParam(required = false) String environment) {
List<AgentInfo> allAgents = registryService.findAll(); List<AgentInfo> 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 // Group agents by application name
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream() Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
.collect(Collectors.groupingBy(AgentInfo::applicationId, LinkedHashMap::new, Collectors.toList())); .collect(Collectors.groupingBy(AgentInfo::applicationId, LinkedHashMap::new, Collectors.toList()));
@@ -87,9 +95,12 @@ public class RouteCatalogController {
Map<String, Long> routeExchangeCounts = new LinkedHashMap<>(); Map<String, Long> routeExchangeCounts = new LinkedHashMap<>();
Map<String, Instant> routeLastSeen = new LinkedHashMap<>(); Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
try { try {
String envFilter = (environment != null && !environment.isBlank())
? " AND environment = " + lit(environment) : "";
jdbc.query( jdbc.query(
"SELECT application_id, route_id, countMerge(total_count) AS cnt, MAX(bucket) AS last_seen " + "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) + "FROM stats_1m_route WHERE bucket >= " + lit(rangeFrom) + " AND bucket < " + lit(rangeTo) +
envFilter +
" GROUP BY application_id, route_id", " GROUP BY application_id, route_id",
rs -> { rs -> {
String key = rs.getString("application_id") + "/" + rs.getString("route_id"); String key = rs.getString("application_id") + "/" + rs.getString("route_id");
@@ -169,6 +180,11 @@ public class RouteCatalogController {
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'"; .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<AgentInfo> agents) { private String computeWorstHealth(List<AgentInfo> agents) {
boolean hasDead = false; boolean hasDead = false;
boolean hasStale = false; boolean hasStale = false;

View File

@@ -115,7 +115,7 @@ public class RouteMetricsController {
.map(AppSettings::slaThresholdMs).orElse(300); .map(AppSettings::slaThresholdMs).orElse(300);
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant, Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
effectiveAppId, threshold); effectiveAppId, threshold, null);
for (int i = 0; i < metrics.size(); i++) { for (int i = 0; i < metrics.size(); i++) {
RouteMetrics m = metrics.get(i); RouteMetrics m = metrics.get(i);

View File

@@ -60,6 +60,7 @@ public class SearchController {
@RequestParam(name = "agentId", required = false) String instanceId, @RequestParam(name = "agentId", required = false) String instanceId,
@RequestParam(required = false) String processorType, @RequestParam(required = false) String processorType,
@RequestParam(required = false) String application, @RequestParam(required = false) String application,
@RequestParam(required = false) String environment,
@RequestParam(defaultValue = "0") int offset, @RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "50") int limit, @RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String sortField, @RequestParam(required = false) String sortField,
@@ -75,7 +76,8 @@ public class SearchController {
routeId, instanceId, processorType, routeId, instanceId, processorType,
application, agentIds, application, agentIds,
offset, limit, offset, limit,
sortField, sortDir sortField, sortDir,
environment
); );
return ResponseEntity.ok(searchService.search(request)); return ResponseEntity.ok(searchService.search(request));
@@ -100,23 +102,24 @@ public class SearchController {
@RequestParam Instant from, @RequestParam Instant from,
@RequestParam(required = false) Instant to, @RequestParam(required = false) Instant to,
@RequestParam(required = false) String routeId, @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(); Instant end = to != null ? to : Instant.now();
ExecutionStats stats; ExecutionStats stats;
if (routeId == null && application == null) { if (routeId == null && application == null) {
stats = searchService.stats(from, end); stats = searchService.stats(from, end, environment);
} else if (routeId == null) { } else if (routeId == null) {
stats = searchService.statsForApp(from, end, application); stats = searchService.statsForApp(from, end, application, environment);
} else { } else {
List<String> agentIds = resolveApplicationToAgentIds(application); List<String> agentIds = resolveApplicationToAgentIds(application);
stats = searchService.stats(from, end, routeId, agentIds); stats = searchService.stats(from, end, routeId, agentIds, environment);
} }
// Enrich with SLA compliance // Enrich with SLA compliance
int threshold = appSettingsRepository int threshold = appSettingsRepository
.findByApplicationId(application != null ? application : "") .findByApplicationId(application != null ? application : "")
.map(AppSettings::slaThresholdMs).orElse(300); .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)); return ResponseEntity.ok(stats.withSlaCompliance(sla));
} }
@@ -127,19 +130,20 @@ public class SearchController {
@RequestParam(required = false) Instant to, @RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "24") int buckets, @RequestParam(defaultValue = "24") int buckets,
@RequestParam(required = false) String routeId, @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(); Instant end = to != null ? to : Instant.now();
if (routeId == null && application == null) { 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) { if (routeId == null) {
return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application)); return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application, environment));
} }
List<String> agentIds = resolveApplicationToAgentIds(application); List<String> agentIds = resolveApplicationToAgentIds(application);
if (routeId == null && agentIds.isEmpty()) { 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") @GetMapping("/stats/timeseries/by-app")
@@ -147,9 +151,10 @@ public class SearchController {
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByApp( public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByApp(
@RequestParam Instant from, @RequestParam Instant from,
@RequestParam(required = false) Instant to, @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(); 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") @GetMapping("/stats/timeseries/by-route")
@@ -158,18 +163,20 @@ public class SearchController {
@RequestParam Instant from, @RequestParam Instant from,
@RequestParam(required = false) Instant to, @RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "24") int buckets, @RequestParam(defaultValue = "24") int buckets,
@RequestParam String application) { @RequestParam String application,
@RequestParam(required = false) String environment) {
Instant end = to != null ? to : Instant.now(); 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") @GetMapping("/stats/punchcard")
@Operation(summary = "Transaction punchcard: weekday x hour grid (rolling 7 days)") @Operation(summary = "Transaction punchcard: weekday x hour grid (rolling 7 days)")
public ResponseEntity<List<StatsStore.PunchcardCell>> punchcard( public ResponseEntity<List<StatsStore.PunchcardCell>> punchcard(
@RequestParam(required = false) String application) { @RequestParam(required = false) String application,
@RequestParam(required = false) String environment) {
Instant to = Instant.now(); Instant to = Instant.now();
Instant from = to.minus(java.time.Duration.ofDays(7)); 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") @GetMapping("/attributes/keys")
@@ -185,9 +192,10 @@ public class SearchController {
@RequestParam(required = false) Instant to, @RequestParam(required = false) Instant to,
@RequestParam(required = false) String application, @RequestParam(required = false) String application,
@RequestParam(required = false) String routeId, @RequestParam(required = false) String routeId,
@RequestParam(required = false) String environment,
@RequestParam(defaultValue = "5") int limit) { @RequestParam(defaultValue = "5") int limit) {
Instant end = to != null ? to : Instant.now(); 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));
} }
/** /**

View File

@@ -14,6 +14,7 @@ public record AgentInstanceResponse(
@NotNull String instanceId, @NotNull String instanceId,
@NotNull String displayName, @NotNull String displayName,
@NotNull String applicationId, @NotNull String applicationId,
String environmentId,
@NotNull String status, @NotNull String status,
@NotNull List<String> routeIds, @NotNull List<String> routeIds,
@NotNull Instant registeredAt, @NotNull Instant registeredAt,
@@ -30,6 +31,7 @@ public record AgentInstanceResponse(
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds(); long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
return new AgentInstanceResponse( return new AgentInstanceResponse(
info.instanceId(), info.displayName(), info.applicationId(), info.instanceId(), info.displayName(), info.applicationId(),
info.environmentId(),
info.state().name(), info.routeIds(), info.state().name(), info.routeIds(),
info.registeredAt(), info.lastHeartbeat(), info.registeredAt(), info.lastHeartbeat(),
info.version(), info.capabilities(), info.version(), info.capabilities(),
@@ -41,7 +43,8 @@ public record AgentInstanceResponse(
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) { public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
return new AgentInstanceResponse( return new AgentInstanceResponse(
instanceId, displayName, applicationId, status, routeIds, registeredAt, lastHeartbeat, instanceId, displayName, applicationId, environmentId,
status, routeIds, registeredAt, lastHeartbeat,
version, capabilities, version, capabilities,
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
); );

View File

@@ -108,6 +108,11 @@ public class ClickHouseLogStore implements LogIndex {
baseConditions.add("tenant_id = ?"); baseConditions.add("tenant_id = ?");
baseParams.add(tenantId); baseParams.add(tenantId);
if (request.environment() != null && !request.environment().isEmpty()) {
baseConditions.add("environment = ?");
baseParams.add(request.environment());
}
if (request.application() != null && !request.application().isEmpty()) { if (request.application() != null && !request.application().isEmpty()) {
baseConditions.add("application = ?"); baseConditions.add("application = ?");
baseParams.add(request.application()); baseParams.add(request.application());

View File

@@ -182,6 +182,11 @@ public class ClickHouseSearchIndex implements SearchIndex {
params.add(request.durationMax()); 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 // Global full-text search: exact ID match, full-text on execution + processor level
if (request.text() != null && !request.text().isBlank()) { if (request.text() != null && !request.text().isBlank()) {
String term = escapeLike(request.text()); String term = escapeLike(request.text());

View File

@@ -42,20 +42,20 @@ public class ClickHouseStatsStore implements StatsStore {
// ── Stats (aggregate) ──────────────────────────────────────────────── // ── Stats (aggregate) ────────────────────────────────────────────────
@Override @Override
public ExecutionStats stats(Instant from, Instant to) { public ExecutionStats stats(Instant from, Instant to, String environment) {
return queryStats("stats_1m_all", from, to, List.of(), true); return queryStats("stats_1m_all", from, to, List.of(), true, environment);
} }
@Override @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( return queryStats("stats_1m_app", from, to, List.of(
new Filter("application_id", applicationId)), true); new Filter("application_id", applicationId)), true, environment);
} }
@Override @Override
public ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds) { public ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds, String environment) {
return queryStats("stats_1m_route", from, to, List.of( return queryStats("stats_1m_route", from, to, List.of(
new Filter("route_id", routeId)), true); new Filter("route_id", routeId)), true, environment);
} }
@Override @Override
@@ -66,21 +66,21 @@ public class ClickHouseStatsStore implements StatsStore {
// ── Timeseries ─────────────────────────────────────────────────────── // ── Timeseries ───────────────────────────────────────────────────────
@Override @Override
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount) { public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, String environment) {
return queryTimeseries("stats_1m_all", from, to, bucketCount, List.of(), true); return queryTimeseries("stats_1m_all", from, to, bucketCount, List.of(), true, environment);
} }
@Override @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( return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of(
new Filter("application_id", applicationId)), true); new Filter("application_id", applicationId)), true, environment);
} }
@Override @Override
public StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount, public StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount,
String routeId, List<String> agentIds) { String routeId, List<String> agentIds, String environment) {
return queryTimeseries("stats_1m_route", from, to, bucketCount, List.of( return queryTimeseries("stats_1m_route", from, to, bucketCount, List.of(
new Filter("route_id", routeId)), true); new Filter("route_id", routeId)), true, environment);
} }
@Override @Override
@@ -92,23 +92,23 @@ public class ClickHouseStatsStore implements StatsStore {
// ── Grouped timeseries ─────────────────────────────────────────────── // ── Grouped timeseries ───────────────────────────────────────────────
@Override @Override
public Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount) { public Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount, String environment) {
return queryGroupedTimeseries("stats_1m_app", "application_id", from, to, return queryGroupedTimeseries("stats_1m_app", "application_id", from, to,
bucketCount, List.of()); bucketCount, List.of(), environment);
} }
@Override @Override
public Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to, public Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to,
int bucketCount, String applicationId) { int bucketCount, String applicationId, String environment) {
return queryGroupedTimeseries("stats_1m_route", "route_id", from, to, 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) ────────────── // ── SLA compliance (raw table — prepared statements OK) ──────────────
@Override @Override
public double slaCompliance(Instant from, Instant to, int thresholdMs, public double slaCompliance(Instant from, Instant to, int thresholdMs,
String applicationId, String routeId) { String applicationId, String routeId, String environment) {
String sql = "SELECT " + String sql = "SELECT " +
"countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " + "countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
"countIf(status != 'RUNNING') AS total " + "countIf(status != 'RUNNING') AS total " +
@@ -120,6 +120,10 @@ public class ClickHouseStatsStore implements StatsStore {
params.add(tenantId); params.add(tenantId);
params.add(Timestamp.from(from)); params.add(Timestamp.from(from));
params.add(Timestamp.from(to)); params.add(Timestamp.from(to));
if (environment != null && !environment.isBlank()) {
sql += " AND environment = ?";
params.add(environment);
}
if (applicationId != null) { if (applicationId != null) {
sql += " AND application_id = ?"; sql += " AND application_id = ?";
params.add(applicationId); params.add(applicationId);
@@ -137,37 +141,59 @@ public class ClickHouseStatsStore implements StatsStore {
} }
@Override @Override
public Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs) { public Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs, String environment) {
String sql = "SELECT application_id, " + String sql = "SELECT application_id, " +
"countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " + "countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
"countIf(status != 'RUNNING') AS total " + "countIf(status != 'RUNNING') AS total " +
"FROM executions FINAL " + "FROM executions FINAL " +
"WHERE tenant_id = ? AND start_time >= ? AND start_time < ? " + "WHERE tenant_id = ? AND start_time >= ? AND start_time < ?";
"GROUP BY application_id";
List<Object> 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<String, long[]> result = new LinkedHashMap<>(); Map<String, long[]> result = new LinkedHashMap<>();
jdbc.query(sql, (rs) -> { jdbc.query(sql, (rs) -> {
result.put(rs.getString("application_id"), result.put(rs.getString("application_id"),
new long[]{rs.getLong("compliant"), rs.getLong("total")}); new long[]{rs.getLong("compliant"), rs.getLong("total")});
}, defaultThresholdMs, tenantId, Timestamp.from(from), Timestamp.from(to)); }, params.toArray());
return result; return result;
} }
@Override @Override
public Map<String, long[]> slaCountsByRoute(Instant from, Instant to, public Map<String, long[]> slaCountsByRoute(Instant from, Instant to,
String applicationId, int thresholdMs) { String applicationId, int thresholdMs, String environment) {
String sql = "SELECT route_id, " + String sql = "SELECT route_id, " +
"countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " + "countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
"countIf(status != 'RUNNING') AS total " + "countIf(status != 'RUNNING') AS total " +
"FROM executions FINAL " + "FROM executions FINAL " +
"WHERE tenant_id = ? AND start_time >= ? AND start_time < ? " + "WHERE tenant_id = ? AND start_time >= ? AND start_time < ? " +
"AND application_id = ? GROUP BY route_id"; "AND application_id = ?";
List<Object> 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<String, long[]> result = new LinkedHashMap<>(); Map<String, long[]> result = new LinkedHashMap<>();
jdbc.query(sql, (rs) -> { jdbc.query(sql, (rs) -> {
result.put(rs.getString("route_id"), result.put(rs.getString("route_id"),
new long[]{rs.getLong("compliant"), rs.getLong("total")}); new long[]{rs.getLong("compliant"), rs.getLong("total")});
}, thresholdMs, tenantId, Timestamp.from(from), Timestamp.from(to), applicationId); }, params.toArray());
return result; return result;
} }
@@ -175,12 +201,16 @@ public class ClickHouseStatsStore implements StatsStore {
@Override @Override
public List<TopError> topErrors(Instant from, Instant to, String applicationId, public List<TopError> topErrors(Instant from, Instant to, String applicationId,
String routeId, int limit) { String routeId, int limit, String environment) {
StringBuilder where = new StringBuilder( StringBuilder where = new StringBuilder(
"status = 'FAILED' AND start_time >= ? AND start_time < ?"); "status = 'FAILED' AND start_time >= ? AND start_time < ?");
List<Object> params = new ArrayList<>(); List<Object> params = new ArrayList<>();
params.add(Timestamp.from(from)); params.add(Timestamp.from(from));
params.add(Timestamp.from(to)); params.add(Timestamp.from(to));
if (environment != null && !environment.isBlank()) {
where.append(" AND environment = ?");
params.add(environment);
}
if (applicationId != null) { if (applicationId != null) {
where.append(" AND application_id = ?"); where.append(" AND application_id = ?");
params.add(applicationId); params.add(applicationId);
@@ -247,7 +277,7 @@ public class ClickHouseStatsStore implements StatsStore {
} }
@Override @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))) " + String sql = "SELECT COUNT(DISTINCT COALESCE(error_type, substring(error_message, 1, 200))) " +
"FROM executions FINAL " + "FROM executions FINAL " +
"WHERE tenant_id = ? AND status = 'FAILED' AND start_time >= ? AND start_time < ?"; "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(tenantId);
params.add(Timestamp.from(from)); params.add(Timestamp.from(from));
params.add(Timestamp.from(to)); params.add(Timestamp.from(to));
if (environment != null && !environment.isBlank()) {
sql += " AND environment = ?";
params.add(environment);
}
if (applicationId != null) { if (applicationId != null) {
sql += " AND application_id = ?"; sql += " AND application_id = ?";
params.add(applicationId); params.add(applicationId);
@@ -268,7 +302,7 @@ public class ClickHouseStatsStore implements StatsStore {
// ── Punchcard (AggregatingMergeTree — literal SQL) ─────────────────── // ── Punchcard (AggregatingMergeTree — literal SQL) ───────────────────
@Override @Override
public List<PunchcardCell> punchcard(Instant from, Instant to, String applicationId) { public List<PunchcardCell> punchcard(Instant from, Instant to, String applicationId, String environment) {
String view = applicationId != null ? "stats_1m_app" : "stats_1m_all"; String view = applicationId != null ? "stats_1m_app" : "stats_1m_all";
String sql = "SELECT toDayOfWeek(bucket, 1) % 7 AS weekday, " + String sql = "SELECT toDayOfWeek(bucket, 1) % 7 AS weekday, " +
"toHour(bucket) AS hour, " + "toHour(bucket) AS hour, " +
@@ -278,6 +312,9 @@ public class ClickHouseStatsStore implements StatsStore {
" WHERE tenant_id = " + lit(tenantId) + " WHERE tenant_id = " + lit(tenantId) +
" AND bucket >= " + lit(from) + " AND bucket >= " + lit(from) +
" AND bucket < " + lit(to); " AND bucket < " + lit(to);
if (environment != null && !environment.isBlank()) {
sql += " AND environment = " + lit(environment);
}
if (applicationId != null) { if (applicationId != null) {
sql += " AND application_id = " + lit(applicationId); sql += " AND application_id = " + lit(applicationId);
} }
@@ -294,7 +331,7 @@ public class ClickHouseStatsStore implements StatsStore {
/** /**
* Format an Instant as a ClickHouse DateTime literal. * Format an Instant as a ClickHouse DateTime literal.
* Uses java.sql.Timestamp to match the JVMClickHouse 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 * used by the JDBC driver, then truncates to second precision for DateTime
* column compatibility. * column compatibility.
*/ */
@@ -318,7 +355,7 @@ public class ClickHouseStatsStore implements StatsStore {
* Build -Merge combinator SQL for the given view and time range. * Build -Merge combinator SQL for the given view and time range.
*/ */
private String buildStatsSql(String view, Instant rangeFrom, Instant rangeTo, private String buildStatsSql(String view, Instant rangeFrom, Instant rangeTo,
List<Filter> filters, boolean hasRunning) { List<Filter> filters, boolean hasRunning, String environment) {
String runningCol = hasRunning ? "countIfMerge(running_count)" : "0"; String runningCol = hasRunning ? "countIfMerge(running_count)" : "0";
String sql = "SELECT " + String sql = "SELECT " +
"countMerge(total_count) AS total_count, " + "countMerge(total_count) AS total_count, " +
@@ -330,6 +367,9 @@ public class ClickHouseStatsStore implements StatsStore {
" WHERE tenant_id = " + lit(tenantId) + " WHERE tenant_id = " + lit(tenantId) +
" AND bucket >= " + lit(rangeFrom) + " AND bucket >= " + lit(rangeFrom) +
" AND bucket < " + lit(rangeTo); " AND bucket < " + lit(rangeTo);
if (environment != null && !environment.isBlank()) {
sql += " AND environment = " + lit(environment);
}
for (Filter f : filters) { for (Filter f : filters) {
sql += " AND " + f.column() + " = " + lit(f.value()); 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. * Uses literal SQL to avoid ClickHouse JDBC driver PreparedStatement issues.
*/ */
private ExecutionStats queryStats(String view, Instant from, Instant to, private ExecutionStats queryStats(String view, Instant from, Instant to,
List<Filter> filters, boolean hasRunning) { List<Filter> 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; long totalCount = 0, failedCount = 0, avgDuration = 0, p99Duration = 0, activeCount = 0;
var currentResult = jdbc.query(sql, (rs, rowNum) -> { var currentResult = jdbc.query(sql, (rs, rowNum) -> {
long tc = rs.getLong("total_count"); long tc = rs.getLong("total_count");
long fc = rs.getLong("failed_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 p99 = (long) rs.getDouble("p99_duration"); // quantileMerge returns Float64
long ac = rs.getLong("active_count"); long ac = rs.getLong("active_count");
return new long[]{tc, fc, ds, p99, ac}; return new long[]{tc, fc, ds, p99, ac};
@@ -364,7 +404,7 @@ public class ClickHouseStatsStore implements StatsStore {
// Previous period (shifted back 24h) // Previous period (shifted back 24h)
Instant prevFrom = from.minus(Duration.ofHours(24)); Instant prevFrom = from.minus(Duration.ofHours(24));
Instant prevTo = to.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; long prevTotal = 0, prevFailed = 0, prevAvg = 0, prevP99 = 0;
var prevResult = jdbc.query(prevSql, (rs, rowNum) -> { var prevResult = jdbc.query(prevSql, (rs, rowNum) -> {
@@ -383,7 +423,7 @@ public class ClickHouseStatsStore implements StatsStore {
// Today total // Today total
Instant todayStart = Instant.now().truncatedTo(ChronoUnit.DAYS); 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; long totalToday = 0;
var todayResult = jdbc.query(todaySql, (rs, rowNum) -> rs.getLong("total_count")); 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, private StatsTimeseries queryTimeseries(String view, Instant from, Instant to,
int bucketCount, List<Filter> filters, int bucketCount, List<Filter> filters,
boolean hasRunningCount) { boolean hasRunningCount, String environment) {
long intervalSeconds = Duration.between(from, to).toSeconds() / Math.max(bucketCount, 1); long intervalSeconds = Duration.between(from, to).toSeconds() / Math.max(bucketCount, 1);
if (intervalSeconds < 60) intervalSeconds = 60; if (intervalSeconds < 60) intervalSeconds = 60;
@@ -416,6 +456,9 @@ public class ClickHouseStatsStore implements StatsStore {
" WHERE tenant_id = " + lit(tenantId) + " WHERE tenant_id = " + lit(tenantId) +
" AND bucket >= " + lit(from) + " AND bucket >= " + lit(from) +
" AND bucket < " + lit(to); " AND bucket < " + lit(to);
if (environment != null && !environment.isBlank()) {
sql += " AND environment = " + lit(environment);
}
for (Filter f : filters) { for (Filter f : filters) {
sql += " AND " + f.column() + " = " + lit(f.value()); sql += " AND " + f.column() + " = " + lit(f.value());
} }
@@ -439,7 +482,7 @@ public class ClickHouseStatsStore implements StatsStore {
*/ */
private Map<String, StatsTimeseries> queryGroupedTimeseries( private Map<String, StatsTimeseries> queryGroupedTimeseries(
String view, String groupCol, Instant from, Instant to, String view, String groupCol, Instant from, Instant to,
int bucketCount, List<Filter> filters) { int bucketCount, List<Filter> filters, String environment) {
long intervalSeconds = Duration.between(from, to).toSeconds() / Math.max(bucketCount, 1); long intervalSeconds = Duration.between(from, to).toSeconds() / Math.max(bucketCount, 1);
if (intervalSeconds < 60) intervalSeconds = 60; if (intervalSeconds < 60) intervalSeconds = 60;
@@ -456,6 +499,9 @@ public class ClickHouseStatsStore implements StatsStore {
" WHERE tenant_id = " + lit(tenantId) + " WHERE tenant_id = " + lit(tenantId) +
" AND bucket >= " + lit(from) + " AND bucket >= " + lit(from) +
" AND bucket < " + lit(to); " AND bucket < " + lit(to);
if (environment != null && !environment.isBlank()) {
sql += " AND environment = " + lit(environment);
}
for (Filter f : filters) { for (Filter f : filters) {
sql += " AND " + f.column() + " = " + lit(f.value()); sql += " AND " + f.column() + " = " + lit(f.value());
} }

View File

@@ -53,7 +53,7 @@ class ClickHouseLogStoreIT {
} }
private LogSearchRequest req(String application) { 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 ───────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────
@@ -99,7 +99,7 @@ class ClickHouseLogStoreIT {
)); ));
LogSearchResponse result = store.search(new LogSearchRequest( 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()).hasSize(1);
assertThat(result.data().get(0).level()).isEqualTo("ERROR"); assertThat(result.data().get(0).level()).isEqualTo("ERROR");
@@ -116,7 +116,7 @@ class ClickHouseLogStoreIT {
)); ));
LogSearchResponse result = store.search(new LogSearchRequest( 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); assertThat(result.data()).hasSize(2);
} }
@@ -130,7 +130,7 @@ class ClickHouseLogStoreIT {
)); ));
LogSearchResponse result = store.search(new LogSearchRequest( 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()).hasSize(1);
assertThat(result.data().get(0).message()).contains("order #12345"); assertThat(result.data().get(0).message()).contains("order #12345");
@@ -147,7 +147,7 @@ class ClickHouseLogStoreIT {
)); ));
LogSearchResponse result = store.search(new LogSearchRequest( 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()).hasSize(1);
assertThat(result.data().get(0).message()).isEqualTo("msg with exchange"); 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"); Instant to = Instant.parse("2026-03-31T13:00:00Z");
LogSearchResponse result = store.search(new LogSearchRequest( 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()).hasSize(1);
assertThat(result.data().get(0).message()).isEqualTo("noon"); assertThat(result.data().get(0).message()).isEqualTo("noon");
@@ -188,7 +188,7 @@ class ClickHouseLogStoreIT {
// No application filter — should return both // No application filter — should return both
LogSearchResponse result = store.search(new LogSearchRequest( 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); assertThat(result.data()).hasSize(2);
} }
@@ -202,7 +202,7 @@ class ClickHouseLogStoreIT {
)); ));
LogSearchResponse result = store.search(new LogSearchRequest( 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()).hasSize(1);
assertThat(result.data().get(0).loggerName()).contains("OrderProcessor"); assertThat(result.data().get(0).loggerName()).contains("OrderProcessor");
@@ -221,7 +221,7 @@ class ClickHouseLogStoreIT {
// Page 1: limit 2 // Page 1: limit 2
LogSearchResponse page1 = store.search(new LogSearchRequest( 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.data()).hasSize(2);
assertThat(page1.hasMore()).isTrue(); assertThat(page1.hasMore()).isTrue();
@@ -230,7 +230,7 @@ class ClickHouseLogStoreIT {
// Page 2: use cursor // Page 2: use cursor
LogSearchResponse page2 = store.search(new LogSearchRequest( 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.data()).hasSize(2);
assertThat(page2.hasMore()).isTrue(); assertThat(page2.hasMore()).isTrue();
@@ -238,7 +238,7 @@ class ClickHouseLogStoreIT {
// Page 3: last page // Page 3: last page
LogSearchResponse page3 = store.search(new LogSearchRequest( 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.data()).hasSize(1);
assertThat(page3.hasMore()).isFalse(); assertThat(page3.hasMore()).isFalse();
@@ -257,7 +257,7 @@ class ClickHouseLogStoreIT {
// Filter for ERROR only, but counts should include all levels // Filter for ERROR only, but counts should include all levels
LogSearchResponse result = store.search(new LogSearchRequest( 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()).hasSize(1);
assertThat(result.levelCounts()).containsEntry("INFO", 2L); assertThat(result.levelCounts()).containsEntry("INFO", 2L);
@@ -275,7 +275,7 @@ class ClickHouseLogStoreIT {
)); ));
LogSearchResponse result = store.search(new LogSearchRequest( 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()).hasSize(3);
assertThat(result.data().get(0).message()).isEqualTo("msg-1"); assertThat(result.data().get(0).message()).isEqualTo("msg-1");

View File

@@ -118,7 +118,7 @@ class ClickHouseSearchIndexIT {
void search_withNoFilters_returnsAllExecutions() { void search_withNoFilters_returnsAllExecutions() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, null, null, null, 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -130,7 +130,7 @@ class ClickHouseSearchIndexIT {
void search_byStatus_filtersCorrectly() { void search_byStatus_filtersCorrectly() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
"FAILED", null, null, null, null, null, null, null, null, null, "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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -145,7 +145,7 @@ class ClickHouseSearchIndexIT {
// Time window covering exec-1 and exec-2 but not exec-3 // Time window covering exec-1 and exec-2 but not exec-3
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, baseTime, baseTime.plusMillis(1500), null, null, null, null, null, null, null, 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -158,7 +158,7 @@ class ClickHouseSearchIndexIT {
void search_fullTextSearch_findsInErrorMessage() { void search_fullTextSearch_findsInErrorMessage() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, null, null, null, null, null, "NullPointerException", null, null, null, 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -170,7 +170,7 @@ class ClickHouseSearchIndexIT {
void search_fullTextSearch_findsInInputBody() { void search_fullTextSearch_findsInInputBody() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, null, null, null, null, null, "12345", null, null, null, 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -182,7 +182,7 @@ class ClickHouseSearchIndexIT {
void search_textInBody_searchesProcessorBodies() { void search_textInBody_searchesProcessorBodies() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, "Hello World", null, null, 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -194,7 +194,7 @@ class ClickHouseSearchIndexIT {
void search_textInHeaders_searchesProcessorHeaders() { void search_textInHeaders_searchesProcessorHeaders() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, null, "secret-token", null, 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -206,7 +206,7 @@ class ClickHouseSearchIndexIT {
void search_textInErrors_searchesErrorFields() { void search_textInErrors_searchesErrorFields() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, null, null, "Foo.bar", 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -218,7 +218,7 @@ class ClickHouseSearchIndexIT {
void search_withHighlight_returnsSnippet() { void search_withHighlight_returnsSnippet() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, null, null, null, null, null, "NullPointerException", null, null, null, 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -230,7 +230,7 @@ class ClickHouseSearchIndexIT {
void search_pagination_works() { void search_pagination_works() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, null, null, null, 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -244,7 +244,7 @@ class ClickHouseSearchIndexIT {
void search_byApplication_filtersCorrectly() { void search_byApplication_filtersCorrectly() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, null, null, null, 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -256,7 +256,7 @@ class ClickHouseSearchIndexIT {
void search_byAgentIds_filtersCorrectly() { void search_byAgentIds_filtersCorrectly() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, null, null, null, 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -268,7 +268,7 @@ class ClickHouseSearchIndexIT {
void count_returnsMatchingCount() { void count_returnsMatchingCount() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
"COMPLETED", null, null, null, null, null, null, null, null, null, "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); long count = searchIndex.count(request);
@@ -279,7 +279,7 @@ class ClickHouseSearchIndexIT {
void search_multipleStatusFilter_works() { void search_multipleStatusFilter_works() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
"COMPLETED,FAILED", null, null, null, null, null, null, null, null, null, "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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -290,7 +290,7 @@ class ClickHouseSearchIndexIT {
void search_byCorrelationId_filtersCorrectly() { void search_byCorrelationId_filtersCorrectly() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, null, null, null, null, "corr-1", null, null, null, null, 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);
@@ -302,7 +302,7 @@ class ClickHouseSearchIndexIT {
void search_byDurationRange_filtersCorrectly() { void search_byDurationRange_filtersCorrectly() {
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
null, null, null, 300L, 600L, null, null, null, null, null, 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<ExecutionSummary> result = searchIndex.search(request); SearchResult<ExecutionSummary> result = searchIndex.search(request);

View File

@@ -162,7 +162,7 @@ class ClickHouseChunkPipelineIT {
null, null, null, null, null, null, null, null, null, null, null, null,
"ORD-123", null, null, null, "ORD-123", null, null, null,
null, null, null, null, null, null, null, null, null, null,
0, 50, null, null)); 0, 50, null, null, null));
assertThat(result.total()).isEqualTo(1); assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("pipeline-1"); assertThat(result.data().get(0).executionId()).isEqualTo("pipeline-1");
assertThat(result.data().get(0).status()).isEqualTo("COMPLETED"); assertThat(result.data().get(0).status()).isEqualTo("COMPLETED");
@@ -173,7 +173,7 @@ class ClickHouseChunkPipelineIT {
null, null, null, null, null, null, null, null, null, null, null, null,
null, "ABC-123", null, null, null, "ABC-123", null, null,
null, null, null, null, null, null, null, null, null, null,
0, 50, null, null)); 0, 50, null, null, null));
assertThat(bodyResult.total()).isEqualTo(1); assertThat(bodyResult.total()).isEqualTo(1);
// Verify iteration data in processor_executions // Verify iteration data in processor_executions

View File

@@ -156,7 +156,7 @@ class ClickHouseStatsStoreIT {
Instant from = BASE.minusSeconds(60); Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300); 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.totalCount()).isEqualTo(10);
assertThat(stats.failedCount()).isEqualTo(2); assertThat(stats.failedCount()).isEqualTo(2);
@@ -170,10 +170,10 @@ class ClickHouseStatsStoreIT {
Instant from = BASE.minusSeconds(60); Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300); 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); 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); assertThat(app2.totalCount()).isEqualTo(2);
} }
@@ -182,7 +182,7 @@ class ClickHouseStatsStoreIT {
Instant from = BASE.minusSeconds(60); Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300); 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); assertThat(routeA.totalCount()).isEqualTo(6);
} }
@@ -193,7 +193,7 @@ class ClickHouseStatsStoreIT {
Instant from = BASE.minusSeconds(60); Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300); Instant to = BASE.plusSeconds(300);
StatsTimeseries ts = store.timeseries(from, to, 5); StatsTimeseries ts = store.timeseries(from, to, 5, null);
assertThat(ts.buckets()).isNotEmpty(); assertThat(ts.buckets()).isNotEmpty();
long totalAcrossBuckets = ts.buckets().stream() long totalAcrossBuckets = ts.buckets().stream()
@@ -206,7 +206,7 @@ class ClickHouseStatsStoreIT {
Instant from = BASE.minusSeconds(60); Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300); 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() long totalAcrossBuckets = ts.buckets().stream()
.mapToLong(StatsTimeseries.TimeseriesBucket::totalCount).sum(); .mapToLong(StatsTimeseries.TimeseriesBucket::totalCount).sum();
@@ -218,7 +218,7 @@ class ClickHouseStatsStoreIT {
Instant from = BASE.minusSeconds(60); Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300); Instant to = BASE.plusSeconds(300);
Map<String, StatsTimeseries> grouped = store.timeseriesGroupedByApp(from, to, 5); Map<String, StatsTimeseries> grouped = store.timeseriesGroupedByApp(from, to, 5, null);
assertThat(grouped).containsKeys("app-1", "app-2"); assertThat(grouped).containsKeys("app-1", "app-2");
} }
@@ -228,7 +228,7 @@ class ClickHouseStatsStoreIT {
Instant from = BASE.minusSeconds(60); Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300); Instant to = BASE.plusSeconds(300);
Map<String, StatsTimeseries> grouped = store.timeseriesGroupedByRoute(from, to, 5, "app-1"); Map<String, StatsTimeseries> grouped = store.timeseriesGroupedByRoute(from, to, 5, "app-1", null);
assertThat(grouped).containsKeys("route-a", "route-b"); 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 // compliant (<=250ms): exec-01(200), exec-05(100), exec-06(150), exec-07(50), exec-08(60) = 5
// total non-running: 9 // total non-running: 9
// compliance = 5/9 * 100 ~ 55.56% // 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); assertThat(sla).isBetween(55.0, 56.0);
} }
@@ -255,7 +255,7 @@ class ClickHouseStatsStoreIT {
Instant from = BASE.minusSeconds(60); Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300); Instant to = BASE.plusSeconds(300);
List<TopError> errors = store.topErrors(from, to, null, null, 10); List<TopError> errors = store.topErrors(from, to, null, null, 10, null);
assertThat(errors).isNotEmpty(); assertThat(errors).isNotEmpty();
assertThat(errors.get(0).errorType()).isEqualTo("NPE"); assertThat(errors.get(0).errorType()).isEqualTo("NPE");
@@ -269,7 +269,7 @@ class ClickHouseStatsStoreIT {
Instant from = BASE.minusSeconds(60); Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300); 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" assertThat(count).isEqualTo(1); // only "NPE"
} }
@@ -281,7 +281,7 @@ class ClickHouseStatsStoreIT {
Instant from = BASE.minusSeconds(60); Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300); Instant to = BASE.plusSeconds(300);
List<PunchcardCell> cells = store.punchcard(from, to, null); List<PunchcardCell> cells = store.punchcard(from, to, null, null);
assertThat(cells).isNotEmpty(); assertThat(cells).isNotEmpty();
long totalCount = cells.stream().mapToLong(PunchcardCell::totalCount).sum(); long totalCount = cells.stream().mapToLong(PunchcardCell::totalCount).sum();
@@ -294,7 +294,7 @@ class ClickHouseStatsStoreIT {
Instant to = BASE.plusSeconds(300); Instant to = BASE.plusSeconds(300);
// threshold=250ms // threshold=250ms
Map<String, long[]> counts = store.slaCountsByApp(from, to, 250); Map<String, long[]> counts = store.slaCountsByApp(from, to, 250, null);
assertThat(counts).containsKeys("app-1", "app-2"); assertThat(counts).containsKeys("app-1", "app-2");
// app-1: 8 total executions, all non-RUNNING // app-1: 8 total executions, all non-RUNNING
@@ -313,7 +313,7 @@ class ClickHouseStatsStoreIT {
Instant from = BASE.minusSeconds(60); Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300); Instant to = BASE.plusSeconds(300);
Map<String, long[]> counts = store.slaCountsByRoute(from, to, "app-1", 250); Map<String, long[]> counts = store.slaCountsByRoute(from, to, "app-1", 250, null);
assertThat(counts).containsKeys("route-a", "route-b"); 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, // route-a: exec-01(200)OK, exec-02(300)NO, exec-03(400)NO, exec-04(500)NO,

View File

@@ -12,6 +12,7 @@ import java.util.List;
* @param instanceId agent instance ID filter * @param instanceId agent instance ID filter
* @param exchangeId Camel exchange ID filter * @param exchangeId Camel exchange ID filter
* @param logger logger name substring 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 from inclusive start of time range (required)
* @param to inclusive end of time range (required) * @param to inclusive end of time range (required)
* @param cursor ISO timestamp cursor for keyset pagination * @param cursor ISO timestamp cursor for keyset pagination
@@ -25,6 +26,7 @@ public record LogSearchRequest(
String instanceId, String instanceId,
String exchangeId, String exchangeId,
String logger, String logger,
String environment,
Instant from, Instant from,
Instant to, Instant to,
String cursor, String cursor,

View File

@@ -28,6 +28,7 @@ import java.util.List;
* @param limit page size (default 50, max 500) * @param limit page size (default 50, max 500)
* @param sortField column to sort by (default: startTime) * @param sortField column to sort by (default: startTime)
* @param sortDir sort direction: asc or desc (default: desc) * @param sortDir sort direction: asc or desc (default: desc)
* @param environment optional environment filter (e.g. "dev", "staging", "prod")
*/ */
public record SearchRequest( public record SearchRequest(
String status, String status,
@@ -48,7 +49,8 @@ public record SearchRequest(
int offset, int offset,
int limit, int limit,
String sortField, String sortField,
String sortDir String sortDir,
String environment
) { ) {
private static final int DEFAULT_LIMIT = 50; private static final int DEFAULT_LIMIT = 50;
@@ -90,7 +92,17 @@ public record SearchRequest(
status, timeFrom, timeTo, durationMin, durationMax, correlationId, status, timeFrom, timeTo, durationMin, durationMax, correlationId,
text, textInBody, textInHeaders, textInErrors, text, textInBody, textInHeaders, textInErrors,
routeId, instanceId, processorType, applicationId, resolvedInstanceIds, 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
); );
} }
} }

View File

@@ -30,65 +30,126 @@ public class SearchService {
} }
public ExecutionStats stats(Instant from, Instant to) { 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) { 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<String> agentIds) { public ExecutionStats stats(Instant from, Instant to, String routeId, List<String> 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<String> agentIds, String environment) {
return statsStore.statsForRoute(from, to, routeId, agentIds, environment);
} }
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount) { 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) { 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, public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount,
String routeId, List<String> agentIds) { String routeId, List<String> 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<String> agentIds, String environment) {
return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds, environment);
} }
// ── Dashboard-specific queries ──────────────────────────────────────── // ── Dashboard-specific queries ────────────────────────────────────────
public Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount) { public Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount) {
return statsStore.timeseriesGroupedByApp(from, to, bucketCount); return statsStore.timeseriesGroupedByApp(from, to, bucketCount, null);
}
public Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount, String environment) {
return statsStore.timeseriesGroupedByApp(from, to, bucketCount, environment);
} }
public Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to, public Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to,
int bucketCount, String applicationId) { int bucketCount, String applicationId) {
return statsStore.timeseriesGroupedByRoute(from, to, bucketCount, applicationId); return statsStore.timeseriesGroupedByRoute(from, to, bucketCount, applicationId, null);
}
public Map<String, StatsTimeseries> 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, public double slaCompliance(Instant from, Instant to, int thresholdMs,
String applicationId, String routeId) { 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<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs) { public Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs) {
return statsStore.slaCountsByApp(from, to, defaultThresholdMs); return statsStore.slaCountsByApp(from, to, defaultThresholdMs, null);
}
public Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs, String environment) {
return statsStore.slaCountsByApp(from, to, defaultThresholdMs, environment);
} }
public Map<String, long[]> slaCountsByRoute(Instant from, Instant to, public Map<String, long[]> slaCountsByRoute(Instant from, Instant to,
String applicationId, int thresholdMs) { String applicationId, int thresholdMs) {
return statsStore.slaCountsByRoute(from, to, applicationId, thresholdMs); return statsStore.slaCountsByRoute(from, to, applicationId, thresholdMs, null);
}
public Map<String, long[]> slaCountsByRoute(Instant from, Instant to,
String applicationId, int thresholdMs, String environment) {
return statsStore.slaCountsByRoute(from, to, applicationId, thresholdMs, environment);
} }
public List<TopError> topErrors(Instant from, Instant to, String applicationId, public List<TopError> topErrors(Instant from, Instant to, String applicationId,
String routeId, int limit) { String routeId, int limit) {
return statsStore.topErrors(from, to, applicationId, routeId, limit); return statsStore.topErrors(from, to, applicationId, routeId, limit, null);
}
public List<TopError> 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) { 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<StatsStore.PunchcardCell> punchcard(Instant from, Instant to, String applicationId) { public List<StatsStore.PunchcardCell> punchcard(Instant from, Instant to, String applicationId) {
return statsStore.punchcard(from, to, applicationId); return statsStore.punchcard(from, to, applicationId, null);
}
public List<StatsStore.PunchcardCell> punchcard(Instant from, Instant to, String applicationId, String environment) {
return statsStore.punchcard(from, to, applicationId, environment);
} }
} }

View File

@@ -11,58 +11,58 @@ import java.util.Map;
public interface StatsStore { public interface StatsStore {
// Global stats (stats_1m_all) // 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) // 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 // Per-route stats (stats_1m_route), optionally scoped to specific agents
ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds); ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds, String environment);
// Per-processor stats (stats_1m_processor) // Per-processor stats (stats_1m_processor)
ExecutionStats statsForProcessor(Instant from, Instant to, String routeId, String processorType); ExecutionStats statsForProcessor(Instant from, Instant to, String routeId, String processorType);
// Global timeseries // Global timeseries
StatsTimeseries timeseries(Instant from, Instant to, int bucketCount); StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, String environment);
// Per-app timeseries // 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 // Per-route timeseries, optionally scoped to specific agents
StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount, StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount,
String routeId, List<String> agentIds); String routeId, List<String> agentIds, String environment);
// Per-processor timeseries // Per-processor timeseries
StatsTimeseries timeseriesForProcessor(Instant from, Instant to, int bucketCount, StatsTimeseries timeseriesForProcessor(Instant from, Instant to, int bucketCount,
String routeId, String processorType); String routeId, String processorType);
// Grouped timeseries by application (for L1 dashboard charts) // Grouped timeseries by application (for L1 dashboard charts)
Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount); Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount, String environment);
// Grouped timeseries by route within an application (for L2 dashboard charts) // Grouped timeseries by route within an application (for L2 dashboard charts)
Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to, int bucketCount, Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to, int bucketCount,
String applicationId); String applicationId, String environment);
// SLA compliance: % of completed exchanges with duration <= thresholdMs // SLA compliance: % of completed exchanges with duration <= thresholdMs
double slaCompliance(Instant from, Instant to, int 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]} // Batch SLA counts by app: {appId -> [compliant, total]}
Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs); Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs, String environment);
// Batch SLA counts by route within an app: {routeId -> [compliant, total]} // Batch SLA counts by route within an app: {routeId -> [compliant, total]}
Map<String, long[]> slaCountsByRoute(Instant from, Instant to, String applicationId, Map<String, long[]> slaCountsByRoute(Instant from, Instant to, String applicationId,
int thresholdMs); int thresholdMs, String environment);
// Top N errors with velocity trend // Top N errors with velocity trend
List<TopError> topErrors(Instant from, Instant to, String applicationId, List<TopError> topErrors(Instant from, Instant to, String applicationId,
String routeId, int limit); String routeId, int limit, String environment);
// Count of distinct error types in window // 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 // Punchcard: aggregate by weekday (0=Sun..6=Sat) x hour (0-23) over last 7 days
List<PunchcardCell> punchcard(Instant from, Instant to, String applicationId); List<PunchcardCell> punchcard(Instant from, Instant to, String applicationId, String environment);
record PunchcardCell(int weekday, int hour, long totalCount, long failedCount) {} record PunchcardCell(int weekday, int hour, long totalCount, long failedCount) {}
} }

View File

@@ -1,19 +1,27 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '../client';
import { config } from '../../config'; import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store'; import { useAuthStore } from '../../auth/auth-store';
import { useRefreshInterval } from './use-refresh-interval'; 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); const refetchInterval = useRefreshInterval(10_000);
return useQuery({ return useQuery({
queryKey: ['agents', status, application], queryKey: ['agents', status, application, environment],
queryFn: async () => { queryFn: async () => {
const { data, error } = await api.GET('/agents', { const token = useAuthStore.getState().accessToken;
params: { query: { ...(status ? { status } : {}), ...(application ? { application } : {}) } }, 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'); if (!res.ok) throw new Error('Failed to load agents');
return data!; return res.json();
}, },
refetchInterval, refetchInterval,
}); });

View File

@@ -3,15 +3,16 @@ import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store'; import { useAuthStore } from '../../auth/auth-store';
import { useRefreshInterval } from './use-refresh-interval'; 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); const refetchInterval = useRefreshInterval(15_000);
return useQuery({ return useQuery({
queryKey: ['routes', 'catalog', from, to], queryKey: ['routes', 'catalog', from, to, environment],
queryFn: async () => { queryFn: async () => {
const token = useAuthStore.getState().accessToken; const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams(); const params = new URLSearchParams();
if (from) params.set('from', from); if (from) params.set('from', from);
if (to) params.set('to', to); if (to) params.set('to', to);
if (environment) params.set('environment', environment);
const qs = params.toString(); const qs = params.toString();
const res = await fetch(`${config.apiBaseUrl}/routes/catalog${qs ? `?${qs}` : ''}`, { const res = await fetch(`${config.apiBaseUrl}/routes/catalog${qs ? `?${qs}` : ''}`, {
headers: { headers: {

View File

@@ -39,12 +39,12 @@ export interface GroupedTimeseries {
[key: string]: { buckets: TimeseriesBucket[] }; [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); const refetchInterval = useRefreshInterval(30_000);
return useQuery({ return useQuery({
queryKey: ['dashboard', 'timeseries-by-app', from, to], queryKey: ['dashboard', 'timeseries-by-app', from, to, environment],
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-app', { queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-app', {
from, to, buckets: '24', from, to, buckets: '24', environment,
}), }),
enabled: !!from, enabled: !!from,
placeholderData: (prev: GroupedTimeseries | undefined) => prev, placeholderData: (prev: GroupedTimeseries | undefined) => prev,
@@ -54,12 +54,12 @@ export function useTimeseriesByApp(from?: string, to?: string) {
// ── Timeseries by route (L2 charts) ─────────────────────────────────── // ── 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); const refetchInterval = useRefreshInterval(30_000);
return useQuery({ return useQuery({
queryKey: ['dashboard', 'timeseries-by-route', from, to, application], queryKey: ['dashboard', 'timeseries-by-route', from, to, application, environment],
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-route', { queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-route', {
from, to, application, buckets: '24', from, to, application, buckets: '24', environment,
}), }),
enabled: !!from && !!application, enabled: !!from && !!application,
placeholderData: (prev: GroupedTimeseries | undefined) => prev, placeholderData: (prev: GroupedTimeseries | undefined) => prev,
@@ -79,12 +79,12 @@ export interface TopError {
lastSeen: string; 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); const refetchInterval = useRefreshInterval(10_000);
return useQuery({ return useQuery({
queryKey: ['dashboard', 'top-errors', from, to, application, routeId], queryKey: ['dashboard', 'top-errors', from, to, application, routeId, environment],
queryFn: () => fetchJson<TopError[]>('/search/errors/top', { queryFn: () => fetchJson<TopError[]>('/search/errors/top', {
from, to, application, routeId, limit: '5', from, to, application, routeId, limit: '5', environment,
}), }),
enabled: !!from, enabled: !!from,
placeholderData: (prev: TopError[] | undefined) => prev, placeholderData: (prev: TopError[] | undefined) => prev,
@@ -101,11 +101,11 @@ export interface PunchcardCell {
failedCount: number; failedCount: number;
} }
export function usePunchcard(application?: string) { export function usePunchcard(application?: string, environment?: string) {
const refetchInterval = useRefreshInterval(60_000); const refetchInterval = useRefreshInterval(60_000);
return useQuery({ return useQuery({
queryKey: ['dashboard', 'punchcard', application], queryKey: ['dashboard', 'punchcard', application, environment],
queryFn: () => fetchJson<PunchcardCell[]>('/search/stats/punchcard', { application }), queryFn: () => fetchJson<PunchcardCell[]>('/search/stats/punchcard', { application, environment }),
placeholderData: (prev: PunchcardCell[] | undefined) => prev ?? [], placeholderData: (prev: PunchcardCell[] | undefined) => prev ?? [],
refetchInterval, refetchInterval,
}); });

View File

@@ -8,10 +8,11 @@ export function useExecutionStats(
timeTo: string | undefined, timeTo: string | undefined,
routeId?: string, routeId?: string,
application?: string, application?: string,
environment?: string,
) { ) {
const live = useLiveQuery(10_000); const live = useLiveQuery(10_000);
return useQuery({ return useQuery({
queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, application], queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, application, environment],
queryFn: async () => { queryFn: async () => {
const { data, error } = await api.GET('/search/stats', { const { data, error } = await api.GET('/search/stats', {
params: { params: {
@@ -20,6 +21,7 @@ export function useExecutionStats(
to: timeTo || undefined, to: timeTo || undefined,
routeId: routeId || undefined, routeId: routeId || undefined,
application: application || undefined, application: application || undefined,
environment: environment || undefined,
}, },
}, },
}); });
@@ -70,10 +72,11 @@ export function useStatsTimeseries(
timeTo: string | undefined, timeTo: string | undefined,
routeId?: string, routeId?: string,
application?: string, application?: string,
environment?: string,
) { ) {
const live = useLiveQuery(30_000); const live = useLiveQuery(30_000);
return useQuery({ return useQuery({
queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, application], queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, application, environment],
queryFn: async () => { queryFn: async () => {
const { data, error } = await api.GET('/search/stats/timeseries', { const { data, error } = await api.GET('/search/stats/timeseries', {
params: { params: {
@@ -83,6 +86,7 @@ export function useStatsTimeseries(
buckets: 24, buckets: 24,
routeId: routeId || undefined, routeId: routeId || undefined,
application: application || undefined, application: application || undefined,
environment: environment || undefined,
}, },
}, },
}); });

View File

@@ -1502,6 +1502,7 @@ export interface components {
limit?: number; limit?: number;
sortField?: string; sortField?: string;
sortDir?: string; sortDir?: string;
environment?: string;
}; };
ExecutionSummary: { ExecutionSummary: {
executionId: string; executionId: string;
@@ -1960,6 +1961,7 @@ export interface components {
instanceId: string; instanceId: string;
displayName: string; displayName: string;
applicationId: string; applicationId: string;
environmentId?: string;
status: string; status: string;
routeIds: string[]; routeIds: string[];
/** Format: date-time */ /** Format: date-time */
@@ -2773,6 +2775,7 @@ export interface operations {
agentId?: string; agentId?: string;
processorType?: string; processorType?: string;
application?: string; application?: string;
environment?: string;
offset?: number; offset?: number;
limit?: number; limit?: number;
sortField?: string; sortField?: string;
@@ -3795,6 +3798,7 @@ export interface operations {
to?: string; to?: string;
routeId?: string; routeId?: string;
application?: string; application?: string;
environment?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -3821,6 +3825,7 @@ export interface operations {
buckets?: number; buckets?: number;
routeId?: string; routeId?: string;
application?: string; application?: string;
environment?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -3846,6 +3851,7 @@ export interface operations {
to?: string; to?: string;
buckets?: number; buckets?: number;
application: string; application: string;
environment?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -3872,6 +3878,7 @@ export interface operations {
from: string; from: string;
to?: string; to?: string;
buckets?: number; buckets?: number;
environment?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -3896,6 +3903,7 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
application?: string; application?: string;
environment?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -3921,6 +3929,7 @@ export interface operations {
to?: string; to?: string;
application?: string; application?: string;
routeId?: string; routeId?: string;
environment?: string;
limit?: number; limit?: number;
}; };
header?: never; header?: never;
@@ -4334,6 +4343,7 @@ export interface operations {
query?: { query?: {
status?: string; status?: string;
application?: string; application?: string;
environment?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;

View File

@@ -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);
}

View File

@@ -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 (
<select
className={styles.select}
value={value ?? ''}
onChange={(e) => onChange(e.target.value || undefined)}
aria-label="Environment filter"
>
<option value="">All Envs</option>
{environments.map((env) => (
<option key={env} value={env}>{env}</option>
))}
</select>
);
}

View File

@@ -1,4 +1,4 @@
import { Outlet, useNavigate, useLocation } from 'react-router'; import { Outlet, useNavigate, useLocation, useSearchParams } from 'react-router';
import { import {
AppShell, AppShell,
Sidebar, Sidebar,
@@ -26,6 +26,7 @@ import { useAuthStore } from '../auth/auth-store';
import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react'; import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ContentTabs } from './ContentTabs'; import { ContentTabs } from './ContentTabs';
import { EnvironmentSelector } from './EnvironmentSelector';
import { useScope } from '../hooks/useScope'; import { useScope } from '../hooks/useScope';
import { import {
buildAppTreeNodes, buildAppTreeNodes,
@@ -271,12 +272,38 @@ const SK_COLLAPSED = 'sidebar:collapsed';
function LayoutContent() { function LayoutContent() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { timeRange, autoRefresh, refreshTimeRange } = useGlobalFilters(); 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(); const { data: attributeKeys } = useAttributeKeys();
// Extract distinct environments from agents
const environments: string[] = useMemo(() => {
if (!agents) return [];
const envSet = new Set<string>();
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) ---------------- // --- Admin search data (only fetched on admin pages) ----------------
const isAdminPage = location.pathname.startsWith('/admin'); const isAdminPage = location.pathname.startsWith('/admin');
const { data: adminUsers } = useUsers(isAdminPage); const { data: adminUsers } = useUsers(isAdminPage);
@@ -675,6 +702,7 @@ function LayoutContent() {
<AppShell sidebar={sidebarElement}> <AppShell sidebar={sidebarElement}>
<TopBar <TopBar
breadcrumb={breadcrumb} breadcrumb={breadcrumb}
environment={selectedEnv}
user={username ? { name: username } : undefined} user={username ? { name: username } : undefined}
onLogout={handleLogout} onLogout={handleLogout}
/> />
@@ -689,6 +717,16 @@ function LayoutContent() {
data={searchData} data={searchData}
/> />
{!isAdminPage && environments.length > 0 && (
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '4px 1.5rem 0' }}>
<EnvironmentSelector
environments={environments}
value={selectedEnv}
onChange={setSelectedEnv}
/>
</div>
)}
{!isAdminPage && ( {!isAdminPage && (
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} /> <ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
)} )}