feat: add environment filtering across all APIs and UI
Backend: Added optional `environment` query parameter to catalog, search, stats, timeseries, punchcard, top-errors, logs, and agents endpoints. ClickHouse queries filter by environment when specified (literal SQL for AggregatingMergeTree, ? binds for raw tables). StatsStore interface methods all accept environment parameter. UI: Added EnvironmentSelector component (compact native select). LayoutShell extracts distinct environments from agent data and passes selected environment to catalog and agent queries via URL search param (?env=). TopBar shows current environment label. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -271,7 +271,8 @@ public class AgentRegistrationController {
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String application) {
|
||||
@RequestParam(required = false) String application,
|
||||
@RequestParam(required = false) String environment) {
|
||||
List<AgentInfo> agents;
|
||||
|
||||
if (status != null) {
|
||||
@@ -292,6 +293,13 @@ public class AgentRegistrationController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Apply environment filter if specified
|
||||
if (environment != null && !environment.isBlank()) {
|
||||
agents = agents.stream()
|
||||
.filter(a -> environment.equals(a.environmentId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Enrich with runtime metrics from continuous aggregates
|
||||
Map<String, double[]> agentMetrics = queryAgentMetrics();
|
||||
final List<AgentInfo> finalAgents = agents;
|
||||
|
||||
@@ -40,6 +40,7 @@ public class LogQueryController {
|
||||
@RequestParam(name = "agentId", required = false) String instanceId,
|
||||
@RequestParam(required = false) String exchangeId,
|
||||
@RequestParam(required = false) String logger,
|
||||
@RequestParam(required = false) String environment,
|
||||
@RequestParam(required = false) String from,
|
||||
@RequestParam(required = false) String to,
|
||||
@RequestParam(required = false) String cursor,
|
||||
@@ -63,7 +64,7 @@ public class LogQueryController {
|
||||
|
||||
LogSearchRequest request = new LogSearchRequest(
|
||||
searchText, levels, application, instanceId, exchangeId,
|
||||
logger, fromInstant, toInstant, cursor, limit, sort);
|
||||
logger, environment, fromInstant, toInstant, cursor, limit, sort);
|
||||
|
||||
LogSearchResponse result = logIndex.search(request);
|
||||
|
||||
|
||||
@@ -59,9 +59,17 @@ public class RouteCatalogController {
|
||||
@ApiResponse(responseCode = "200", description = "Catalog returned")
|
||||
public ResponseEntity<List<AppCatalogEntry>> getCatalog(
|
||||
@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();
|
||||
|
||||
// Filter agents by environment if specified
|
||||
if (environment != null && !environment.isBlank()) {
|
||||
allAgents = allAgents.stream()
|
||||
.filter(a -> environment.equals(a.environmentId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Group agents by application name
|
||||
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
||||
.collect(Collectors.groupingBy(AgentInfo::applicationId, LinkedHashMap::new, Collectors.toList()));
|
||||
@@ -87,9 +95,12 @@ public class RouteCatalogController {
|
||||
Map<String, Long> routeExchangeCounts = new LinkedHashMap<>();
|
||||
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
||||
try {
|
||||
String envFilter = (environment != null && !environment.isBlank())
|
||||
? " AND environment = " + lit(environment) : "";
|
||||
jdbc.query(
|
||||
"SELECT application_id, route_id, countMerge(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
||||
"FROM stats_1m_route WHERE bucket >= " + lit(rangeFrom) + " AND bucket < " + lit(rangeTo) +
|
||||
envFilter +
|
||||
" GROUP BY application_id, route_id",
|
||||
rs -> {
|
||||
String key = rs.getString("application_id") + "/" + rs.getString("route_id");
|
||||
@@ -169,6 +180,11 @@ public class RouteCatalogController {
|
||||
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
|
||||
}
|
||||
|
||||
/** Format a string as a ClickHouse SQL literal with backslash + quote escaping. */
|
||||
private static String lit(String value) {
|
||||
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
|
||||
}
|
||||
|
||||
private String computeWorstHealth(List<AgentInfo> agents) {
|
||||
boolean hasDead = false;
|
||||
boolean hasStale = false;
|
||||
|
||||
@@ -115,7 +115,7 @@ public class RouteMetricsController {
|
||||
.map(AppSettings::slaThresholdMs).orElse(300);
|
||||
|
||||
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
|
||||
effectiveAppId, threshold);
|
||||
effectiveAppId, threshold, null);
|
||||
|
||||
for (int i = 0; i < metrics.size(); i++) {
|
||||
RouteMetrics m = metrics.get(i);
|
||||
|
||||
@@ -60,6 +60,7 @@ public class SearchController {
|
||||
@RequestParam(name = "agentId", required = false) String instanceId,
|
||||
@RequestParam(required = false) String processorType,
|
||||
@RequestParam(required = false) String application,
|
||||
@RequestParam(required = false) String environment,
|
||||
@RequestParam(defaultValue = "0") int offset,
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
@RequestParam(required = false) String sortField,
|
||||
@@ -75,7 +76,8 @@ public class SearchController {
|
||||
routeId, instanceId, processorType,
|
||||
application, agentIds,
|
||||
offset, limit,
|
||||
sortField, sortDir
|
||||
sortField, sortDir,
|
||||
environment
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(searchService.search(request));
|
||||
@@ -100,23 +102,24 @@ public class SearchController {
|
||||
@RequestParam Instant from,
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(required = false) String routeId,
|
||||
@RequestParam(required = false) String application) {
|
||||
@RequestParam(required = false) String application,
|
||||
@RequestParam(required = false) String environment) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
ExecutionStats stats;
|
||||
if (routeId == null && application == null) {
|
||||
stats = searchService.stats(from, end);
|
||||
stats = searchService.stats(from, end, environment);
|
||||
} else if (routeId == null) {
|
||||
stats = searchService.statsForApp(from, end, application);
|
||||
stats = searchService.statsForApp(from, end, application, environment);
|
||||
} else {
|
||||
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||
stats = searchService.stats(from, end, routeId, agentIds);
|
||||
stats = searchService.stats(from, end, routeId, agentIds, environment);
|
||||
}
|
||||
|
||||
// Enrich with SLA compliance
|
||||
int threshold = appSettingsRepository
|
||||
.findByApplicationId(application != null ? application : "")
|
||||
.map(AppSettings::slaThresholdMs).orElse(300);
|
||||
double sla = searchService.slaCompliance(from, end, threshold, application, routeId);
|
||||
double sla = searchService.slaCompliance(from, end, threshold, application, routeId, environment);
|
||||
return ResponseEntity.ok(stats.withSlaCompliance(sla));
|
||||
}
|
||||
|
||||
@@ -127,19 +130,20 @@ public class SearchController {
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(defaultValue = "24") int buckets,
|
||||
@RequestParam(required = false) String routeId,
|
||||
@RequestParam(required = false) String application) {
|
||||
@RequestParam(required = false) String application,
|
||||
@RequestParam(required = false) String environment) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
if (routeId == null && application == null) {
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, environment));
|
||||
}
|
||||
if (routeId == null) {
|
||||
return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application));
|
||||
return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application, environment));
|
||||
}
|
||||
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||
if (routeId == null && agentIds.isEmpty()) {
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, environment));
|
||||
}
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, routeId, agentIds));
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, routeId, agentIds, environment));
|
||||
}
|
||||
|
||||
@GetMapping("/stats/timeseries/by-app")
|
||||
@@ -147,9 +151,10 @@ public class SearchController {
|
||||
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByApp(
|
||||
@RequestParam Instant from,
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(defaultValue = "24") int buckets) {
|
||||
@RequestParam(defaultValue = "24") int buckets,
|
||||
@RequestParam(required = false) String environment) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
return ResponseEntity.ok(searchService.timeseriesGroupedByApp(from, end, buckets));
|
||||
return ResponseEntity.ok(searchService.timeseriesGroupedByApp(from, end, buckets, environment));
|
||||
}
|
||||
|
||||
@GetMapping("/stats/timeseries/by-route")
|
||||
@@ -158,18 +163,20 @@ public class SearchController {
|
||||
@RequestParam Instant from,
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(defaultValue = "24") int buckets,
|
||||
@RequestParam String application) {
|
||||
@RequestParam String application,
|
||||
@RequestParam(required = false) String environment) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application));
|
||||
return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application, environment));
|
||||
}
|
||||
|
||||
@GetMapping("/stats/punchcard")
|
||||
@Operation(summary = "Transaction punchcard: weekday x hour grid (rolling 7 days)")
|
||||
public ResponseEntity<List<StatsStore.PunchcardCell>> punchcard(
|
||||
@RequestParam(required = false) String application) {
|
||||
@RequestParam(required = false) String application,
|
||||
@RequestParam(required = false) String environment) {
|
||||
Instant to = Instant.now();
|
||||
Instant from = to.minus(java.time.Duration.ofDays(7));
|
||||
return ResponseEntity.ok(searchService.punchcard(from, to, application));
|
||||
return ResponseEntity.ok(searchService.punchcard(from, to, application, environment));
|
||||
}
|
||||
|
||||
@GetMapping("/attributes/keys")
|
||||
@@ -185,9 +192,10 @@ public class SearchController {
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(required = false) String application,
|
||||
@RequestParam(required = false) String routeId,
|
||||
@RequestParam(required = false) String environment,
|
||||
@RequestParam(defaultValue = "5") int limit) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
return ResponseEntity.ok(searchService.topErrors(from, end, application, routeId, limit));
|
||||
return ResponseEntity.ok(searchService.topErrors(from, end, application, routeId, limit, environment));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@ public record AgentInstanceResponse(
|
||||
@NotNull String instanceId,
|
||||
@NotNull String displayName,
|
||||
@NotNull String applicationId,
|
||||
String environmentId,
|
||||
@NotNull String status,
|
||||
@NotNull List<String> routeIds,
|
||||
@NotNull Instant registeredAt,
|
||||
@@ -30,6 +31,7 @@ public record AgentInstanceResponse(
|
||||
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
|
||||
return new AgentInstanceResponse(
|
||||
info.instanceId(), info.displayName(), info.applicationId(),
|
||||
info.environmentId(),
|
||||
info.state().name(), info.routeIds(),
|
||||
info.registeredAt(), info.lastHeartbeat(),
|
||||
info.version(), info.capabilities(),
|
||||
@@ -41,7 +43,8 @@ public record AgentInstanceResponse(
|
||||
|
||||
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
|
||||
return new AgentInstanceResponse(
|
||||
instanceId, displayName, applicationId, status, routeIds, registeredAt, lastHeartbeat,
|
||||
instanceId, displayName, applicationId, environmentId,
|
||||
status, routeIds, registeredAt, lastHeartbeat,
|
||||
version, capabilities,
|
||||
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
|
||||
);
|
||||
|
||||
@@ -108,6 +108,11 @@ public class ClickHouseLogStore implements LogIndex {
|
||||
baseConditions.add("tenant_id = ?");
|
||||
baseParams.add(tenantId);
|
||||
|
||||
if (request.environment() != null && !request.environment().isEmpty()) {
|
||||
baseConditions.add("environment = ?");
|
||||
baseParams.add(request.environment());
|
||||
}
|
||||
|
||||
if (request.application() != null && !request.application().isEmpty()) {
|
||||
baseConditions.add("application = ?");
|
||||
baseParams.add(request.application());
|
||||
|
||||
@@ -182,6 +182,11 @@ public class ClickHouseSearchIndex implements SearchIndex {
|
||||
params.add(request.durationMax());
|
||||
}
|
||||
|
||||
if (request.environment() != null && !request.environment().isBlank()) {
|
||||
conditions.add("environment = ?");
|
||||
params.add(request.environment());
|
||||
}
|
||||
|
||||
// Global full-text search: exact ID match, full-text on execution + processor level
|
||||
if (request.text() != null && !request.text().isBlank()) {
|
||||
String term = escapeLike(request.text());
|
||||
|
||||
@@ -42,20 +42,20 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
// ── Stats (aggregate) ────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public ExecutionStats stats(Instant from, Instant to) {
|
||||
return queryStats("stats_1m_all", from, to, List.of(), true);
|
||||
public ExecutionStats stats(Instant from, Instant to, String environment) {
|
||||
return queryStats("stats_1m_all", from, to, List.of(), true, environment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExecutionStats statsForApp(Instant from, Instant to, String applicationId) {
|
||||
public ExecutionStats statsForApp(Instant from, Instant to, String applicationId, String environment) {
|
||||
return queryStats("stats_1m_app", from, to, List.of(
|
||||
new Filter("application_id", applicationId)), true);
|
||||
new Filter("application_id", applicationId)), true, environment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<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(
|
||||
new Filter("route_id", routeId)), true);
|
||||
new Filter("route_id", routeId)), true, environment);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -66,21 +66,21 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
// ── Timeseries ───────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount) {
|
||||
return queryTimeseries("stats_1m_all", from, to, bucketCount, List.of(), true);
|
||||
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, String environment) {
|
||||
return queryTimeseries("stats_1m_all", from, to, bucketCount, List.of(), true, environment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId) {
|
||||
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId, String environment) {
|
||||
return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of(
|
||||
new Filter("application_id", applicationId)), true);
|
||||
new Filter("application_id", applicationId)), true, environment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount,
|
||||
String routeId, List<String> agentIds) {
|
||||
String routeId, List<String> agentIds, String environment) {
|
||||
return queryTimeseries("stats_1m_route", from, to, bucketCount, List.of(
|
||||
new Filter("route_id", routeId)), true);
|
||||
new Filter("route_id", routeId)), true, environment);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -92,23 +92,23 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
// ── Grouped timeseries ───────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public Map<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,
|
||||
bucketCount, List.of());
|
||||
bucketCount, List.of(), environment);
|
||||
}
|
||||
|
||||
@Override
|
||||
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,
|
||||
bucketCount, List.of(new Filter("application_id", applicationId)));
|
||||
bucketCount, List.of(new Filter("application_id", applicationId)), environment);
|
||||
}
|
||||
|
||||
// ── SLA compliance (raw table — prepared statements OK) ──────────────
|
||||
|
||||
@Override
|
||||
public double slaCompliance(Instant from, Instant to, int thresholdMs,
|
||||
String applicationId, String routeId) {
|
||||
String applicationId, String routeId, String environment) {
|
||||
String sql = "SELECT " +
|
||||
"countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
|
||||
"countIf(status != 'RUNNING') AS total " +
|
||||
@@ -120,6 +120,10 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
params.add(tenantId);
|
||||
params.add(Timestamp.from(from));
|
||||
params.add(Timestamp.from(to));
|
||||
if (environment != null && !environment.isBlank()) {
|
||||
sql += " AND environment = ?";
|
||||
params.add(environment);
|
||||
}
|
||||
if (applicationId != null) {
|
||||
sql += " AND application_id = ?";
|
||||
params.add(applicationId);
|
||||
@@ -137,37 +141,59 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<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, " +
|
||||
"countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
|
||||
"countIf(status != 'RUNNING') AS total " +
|
||||
"FROM executions FINAL " +
|
||||
"WHERE tenant_id = ? AND start_time >= ? AND start_time < ? " +
|
||||
"GROUP BY application_id";
|
||||
"WHERE tenant_id = ? AND start_time >= ? AND start_time < ?";
|
||||
|
||||
List<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<>();
|
||||
jdbc.query(sql, (rs) -> {
|
||||
result.put(rs.getString("application_id"),
|
||||
new long[]{rs.getLong("compliant"), rs.getLong("total")});
|
||||
}, defaultThresholdMs, tenantId, Timestamp.from(from), Timestamp.from(to));
|
||||
}, params.toArray());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, long[]> slaCountsByRoute(Instant from, Instant to,
|
||||
String applicationId, int thresholdMs) {
|
||||
String applicationId, int thresholdMs, String environment) {
|
||||
String sql = "SELECT route_id, " +
|
||||
"countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
|
||||
"countIf(status != 'RUNNING') AS total " +
|
||||
"FROM executions FINAL " +
|
||||
"WHERE tenant_id = ? AND start_time >= ? AND start_time < ? " +
|
||||
"AND application_id = ? GROUP BY route_id";
|
||||
"AND application_id = ?";
|
||||
|
||||
List<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<>();
|
||||
jdbc.query(sql, (rs) -> {
|
||||
result.put(rs.getString("route_id"),
|
||||
new long[]{rs.getLong("compliant"), rs.getLong("total")});
|
||||
}, thresholdMs, tenantId, Timestamp.from(from), Timestamp.from(to), applicationId);
|
||||
}, params.toArray());
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -175,12 +201,16 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
|
||||
@Override
|
||||
public List<TopError> topErrors(Instant from, Instant to, String applicationId,
|
||||
String routeId, int limit) {
|
||||
String routeId, int limit, String environment) {
|
||||
StringBuilder where = new StringBuilder(
|
||||
"status = 'FAILED' AND start_time >= ? AND start_time < ?");
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(Timestamp.from(from));
|
||||
params.add(Timestamp.from(to));
|
||||
if (environment != null && !environment.isBlank()) {
|
||||
where.append(" AND environment = ?");
|
||||
params.add(environment);
|
||||
}
|
||||
if (applicationId != null) {
|
||||
where.append(" AND application_id = ?");
|
||||
params.add(applicationId);
|
||||
@@ -247,7 +277,7 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
}
|
||||
|
||||
@Override
|
||||
public int activeErrorTypes(Instant from, Instant to, String applicationId) {
|
||||
public int activeErrorTypes(Instant from, Instant to, String applicationId, String environment) {
|
||||
String sql = "SELECT COUNT(DISTINCT COALESCE(error_type, substring(error_message, 1, 200))) " +
|
||||
"FROM executions FINAL " +
|
||||
"WHERE tenant_id = ? AND status = 'FAILED' AND start_time >= ? AND start_time < ?";
|
||||
@@ -256,6 +286,10 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
params.add(tenantId);
|
||||
params.add(Timestamp.from(from));
|
||||
params.add(Timestamp.from(to));
|
||||
if (environment != null && !environment.isBlank()) {
|
||||
sql += " AND environment = ?";
|
||||
params.add(environment);
|
||||
}
|
||||
if (applicationId != null) {
|
||||
sql += " AND application_id = ?";
|
||||
params.add(applicationId);
|
||||
@@ -268,7 +302,7 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
// ── Punchcard (AggregatingMergeTree — literal SQL) ───────────────────
|
||||
|
||||
@Override
|
||||
public List<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 sql = "SELECT toDayOfWeek(bucket, 1) % 7 AS weekday, " +
|
||||
"toHour(bucket) AS hour, " +
|
||||
@@ -278,6 +312,9 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
" WHERE tenant_id = " + lit(tenantId) +
|
||||
" AND bucket >= " + lit(from) +
|
||||
" AND bucket < " + lit(to);
|
||||
if (environment != null && !environment.isBlank()) {
|
||||
sql += " AND environment = " + lit(environment);
|
||||
}
|
||||
if (applicationId != null) {
|
||||
sql += " AND application_id = " + lit(applicationId);
|
||||
}
|
||||
@@ -294,7 +331,7 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
|
||||
/**
|
||||
* Format an Instant as a ClickHouse DateTime literal.
|
||||
* Uses java.sql.Timestamp to match the JVM→ClickHouse timezone convention
|
||||
* Uses java.sql.Timestamp to match the JVM-ClickHouse timezone convention
|
||||
* used by the JDBC driver, then truncates to second precision for DateTime
|
||||
* column compatibility.
|
||||
*/
|
||||
@@ -318,7 +355,7 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
* Build -Merge combinator SQL for the given view and time range.
|
||||
*/
|
||||
private String buildStatsSql(String view, Instant rangeFrom, Instant rangeTo,
|
||||
List<Filter> filters, boolean hasRunning) {
|
||||
List<Filter> filters, boolean hasRunning, String environment) {
|
||||
String runningCol = hasRunning ? "countIfMerge(running_count)" : "0";
|
||||
String sql = "SELECT " +
|
||||
"countMerge(total_count) AS total_count, " +
|
||||
@@ -330,6 +367,9 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
" WHERE tenant_id = " + lit(tenantId) +
|
||||
" AND bucket >= " + lit(rangeFrom) +
|
||||
" AND bucket < " + lit(rangeTo);
|
||||
if (environment != null && !environment.isBlank()) {
|
||||
sql += " AND environment = " + lit(environment);
|
||||
}
|
||||
for (Filter f : filters) {
|
||||
sql += " AND " + f.column() + " = " + lit(f.value());
|
||||
}
|
||||
@@ -341,15 +381,15 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
* Uses literal SQL to avoid ClickHouse JDBC driver PreparedStatement issues.
|
||||
*/
|
||||
private ExecutionStats queryStats(String view, Instant from, Instant to,
|
||||
List<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;
|
||||
var currentResult = jdbc.query(sql, (rs, rowNum) -> {
|
||||
long tc = rs.getLong("total_count");
|
||||
long fc = rs.getLong("failed_count");
|
||||
long ds = rs.getLong("duration_sum"); // Nullable → 0 if null
|
||||
long ds = rs.getLong("duration_sum"); // Nullable -> 0 if null
|
||||
long p99 = (long) rs.getDouble("p99_duration"); // quantileMerge returns Float64
|
||||
long ac = rs.getLong("active_count");
|
||||
return new long[]{tc, fc, ds, p99, ac};
|
||||
@@ -364,7 +404,7 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
// Previous period (shifted back 24h)
|
||||
Instant prevFrom = from.minus(Duration.ofHours(24));
|
||||
Instant prevTo = to.minus(Duration.ofHours(24));
|
||||
String prevSql = buildStatsSql(view, prevFrom, prevTo, filters, hasRunning);
|
||||
String prevSql = buildStatsSql(view, prevFrom, prevTo, filters, hasRunning, environment);
|
||||
|
||||
long prevTotal = 0, prevFailed = 0, prevAvg = 0, prevP99 = 0;
|
||||
var prevResult = jdbc.query(prevSql, (rs, rowNum) -> {
|
||||
@@ -383,7 +423,7 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
|
||||
// Today total
|
||||
Instant todayStart = Instant.now().truncatedTo(ChronoUnit.DAYS);
|
||||
String todaySql = buildStatsSql(view, todayStart, Instant.now(), filters, hasRunning);
|
||||
String todaySql = buildStatsSql(view, todayStart, Instant.now(), filters, hasRunning, environment);
|
||||
|
||||
long totalToday = 0;
|
||||
var todayResult = jdbc.query(todaySql, (rs, rowNum) -> rs.getLong("total_count"));
|
||||
@@ -399,7 +439,7 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
*/
|
||||
private StatsTimeseries queryTimeseries(String view, Instant from, Instant to,
|
||||
int bucketCount, List<Filter> filters,
|
||||
boolean hasRunningCount) {
|
||||
boolean hasRunningCount, String environment) {
|
||||
long intervalSeconds = Duration.between(from, to).toSeconds() / Math.max(bucketCount, 1);
|
||||
if (intervalSeconds < 60) intervalSeconds = 60;
|
||||
|
||||
@@ -416,6 +456,9 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
" WHERE tenant_id = " + lit(tenantId) +
|
||||
" AND bucket >= " + lit(from) +
|
||||
" AND bucket < " + lit(to);
|
||||
if (environment != null && !environment.isBlank()) {
|
||||
sql += " AND environment = " + lit(environment);
|
||||
}
|
||||
for (Filter f : filters) {
|
||||
sql += " AND " + f.column() + " = " + lit(f.value());
|
||||
}
|
||||
@@ -439,7 +482,7 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
*/
|
||||
private Map<String, StatsTimeseries> queryGroupedTimeseries(
|
||||
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);
|
||||
if (intervalSeconds < 60) intervalSeconds = 60;
|
||||
@@ -456,6 +499,9 @@ public class ClickHouseStatsStore implements StatsStore {
|
||||
" WHERE tenant_id = " + lit(tenantId) +
|
||||
" AND bucket >= " + lit(from) +
|
||||
" AND bucket < " + lit(to);
|
||||
if (environment != null && !environment.isBlank()) {
|
||||
sql += " AND environment = " + lit(environment);
|
||||
}
|
||||
for (Filter f : filters) {
|
||||
sql += " AND " + f.column() + " = " + lit(f.value());
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class ClickHouseLogStoreIT {
|
||||
}
|
||||
|
||||
private LogSearchRequest req(String application) {
|
||||
return new LogSearchRequest(null, null, application, null, null, null, null, null, null, 100, "desc");
|
||||
return new LogSearchRequest(null, null, application, null, null, null, null, null, null, null, 100, "desc");
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
@@ -99,7 +99,7 @@ class ClickHouseLogStoreIT {
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, List.of("ERROR"), "my-app", null, null, null, null, null, null, 100, "desc"));
|
||||
null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).level()).isEqualTo("ERROR");
|
||||
@@ -116,7 +116,7 @@ class ClickHouseLogStoreIT {
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, List.of("WARN", "ERROR"), "my-app", null, null, null, null, null, null, 100, "desc"));
|
||||
null, List.of("WARN", "ERROR"), "my-app", null, null, null, null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(result.data()).hasSize(2);
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class ClickHouseLogStoreIT {
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
"order #12345", null, "my-app", null, null, null, null, null, null, 100, "desc"));
|
||||
"order #12345", null, "my-app", null, null, null, null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).message()).contains("order #12345");
|
||||
@@ -147,7 +147,7 @@ class ClickHouseLogStoreIT {
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, "exchange-abc", null, null, null, null, 100, "desc"));
|
||||
null, null, "my-app", null, "exchange-abc", null, null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).message()).isEqualTo("msg with exchange");
|
||||
@@ -170,7 +170,7 @@ class ClickHouseLogStoreIT {
|
||||
Instant to = Instant.parse("2026-03-31T13:00:00Z");
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, from, to, null, 100, "desc"));
|
||||
null, null, "my-app", null, null, null, null, from, to, null, 100, "desc"));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).message()).isEqualTo("noon");
|
||||
@@ -188,7 +188,7 @@ class ClickHouseLogStoreIT {
|
||||
|
||||
// No application filter — should return both
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, 100, "desc"));
|
||||
null, null, null, null, null, null, null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(result.data()).hasSize(2);
|
||||
}
|
||||
@@ -202,7 +202,7 @@ class ClickHouseLogStoreIT {
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, "OrderProcessor", null, null, null, 100, "desc"));
|
||||
null, null, "my-app", null, null, "OrderProcessor", null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).loggerName()).contains("OrderProcessor");
|
||||
@@ -221,7 +221,7 @@ class ClickHouseLogStoreIT {
|
||||
|
||||
// Page 1: limit 2
|
||||
LogSearchResponse page1 = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, null, 2, "desc"));
|
||||
null, null, "my-app", null, null, null, null, null, null, null, 2, "desc"));
|
||||
|
||||
assertThat(page1.data()).hasSize(2);
|
||||
assertThat(page1.hasMore()).isTrue();
|
||||
@@ -230,7 +230,7 @@ class ClickHouseLogStoreIT {
|
||||
|
||||
// Page 2: use cursor
|
||||
LogSearchResponse page2 = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, page1.nextCursor(), 2, "desc"));
|
||||
null, null, "my-app", null, null, null, null, null, null, page1.nextCursor(), 2, "desc"));
|
||||
|
||||
assertThat(page2.data()).hasSize(2);
|
||||
assertThat(page2.hasMore()).isTrue();
|
||||
@@ -238,7 +238,7 @@ class ClickHouseLogStoreIT {
|
||||
|
||||
// Page 3: last page
|
||||
LogSearchResponse page3 = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, page2.nextCursor(), 2, "desc"));
|
||||
null, null, "my-app", null, null, null, null, null, null, page2.nextCursor(), 2, "desc"));
|
||||
|
||||
assertThat(page3.data()).hasSize(1);
|
||||
assertThat(page3.hasMore()).isFalse();
|
||||
@@ -257,7 +257,7 @@ class ClickHouseLogStoreIT {
|
||||
|
||||
// Filter for ERROR only, but counts should include all levels
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, List.of("ERROR"), "my-app", null, null, null, null, null, null, 100, "desc"));
|
||||
null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.levelCounts()).containsEntry("INFO", 2L);
|
||||
@@ -275,7 +275,7 @@ class ClickHouseLogStoreIT {
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, null, 100, "asc"));
|
||||
null, null, "my-app", null, null, null, null, null, null, null, 100, "asc"));
|
||||
|
||||
assertThat(result.data()).hasSize(3);
|
||||
assertThat(result.data().get(0).message()).isEqualTo("msg-1");
|
||||
|
||||
@@ -118,7 +118,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_withNoFilters_returnsAllExecutions() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -130,7 +130,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_byStatus_filtersCorrectly() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
"FAILED", null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -145,7 +145,7 @@ class ClickHouseSearchIndexIT {
|
||||
// Time window covering exec-1 and exec-2 but not exec-3
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, baseTime, baseTime.plusMillis(1500), null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -158,7 +158,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_fullTextSearch_findsInErrorMessage() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, "NullPointerException", null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -170,7 +170,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_fullTextSearch_findsInInputBody() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, "12345", null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -182,7 +182,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_textInBody_searchesProcessorBodies() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, "Hello World", null, null,
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -194,7 +194,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_textInHeaders_searchesProcessorHeaders() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, "secret-token", null,
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -206,7 +206,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_textInErrors_searchesErrorFields() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, "Foo.bar",
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -218,7 +218,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_withHighlight_returnsSnippet() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, "NullPointerException", null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -230,7 +230,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_pagination_works() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 2, null, null);
|
||||
null, null, null, null, null, 0, 2, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -244,7 +244,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_byApplication_filtersCorrectly() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, "other-app", null, 0, 50, null, null);
|
||||
null, null, null, "other-app", null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -256,7 +256,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_byAgentIds_filtersCorrectly() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, List.of("agent-b"), 0, 50, null, null);
|
||||
null, null, null, null, List.of("agent-b"), 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -268,7 +268,7 @@ class ClickHouseSearchIndexIT {
|
||||
void count_returnsMatchingCount() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
"COMPLETED", null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
long count = searchIndex.count(request);
|
||||
|
||||
@@ -279,7 +279,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_multipleStatusFilter_works() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
"COMPLETED,FAILED", null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -290,7 +290,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_byCorrelationId_filtersCorrectly() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, "corr-1", null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -302,7 +302,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_byDurationRange_filtersCorrectly() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, 300L, 600L, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ class ClickHouseChunkPipelineIT {
|
||||
null, null, null, null, null, null,
|
||||
"ORD-123", null, null, null,
|
||||
null, null, null, null, null,
|
||||
0, 50, null, null));
|
||||
0, 50, null, null, null));
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.data().get(0).executionId()).isEqualTo("pipeline-1");
|
||||
assertThat(result.data().get(0).status()).isEqualTo("COMPLETED");
|
||||
@@ -173,7 +173,7 @@ class ClickHouseChunkPipelineIT {
|
||||
null, null, null, null, null, null,
|
||||
null, "ABC-123", null, null,
|
||||
null, null, null, null, null,
|
||||
0, 50, null, null));
|
||||
0, 50, null, null, null));
|
||||
assertThat(bodyResult.total()).isEqualTo(1);
|
||||
|
||||
// Verify iteration data in processor_executions
|
||||
|
||||
@@ -156,7 +156,7 @@ class ClickHouseStatsStoreIT {
|
||||
Instant from = BASE.minusSeconds(60);
|
||||
Instant to = BASE.plusSeconds(300);
|
||||
|
||||
ExecutionStats stats = store.stats(from, to);
|
||||
ExecutionStats stats = store.stats(from, to, null);
|
||||
|
||||
assertThat(stats.totalCount()).isEqualTo(10);
|
||||
assertThat(stats.failedCount()).isEqualTo(2);
|
||||
@@ -170,10 +170,10 @@ class ClickHouseStatsStoreIT {
|
||||
Instant from = BASE.minusSeconds(60);
|
||||
Instant to = BASE.plusSeconds(300);
|
||||
|
||||
ExecutionStats app1 = store.statsForApp(from, to, "app-1");
|
||||
ExecutionStats app1 = store.statsForApp(from, to, "app-1", null);
|
||||
assertThat(app1.totalCount()).isEqualTo(8);
|
||||
|
||||
ExecutionStats app2 = store.statsForApp(from, to, "app-2");
|
||||
ExecutionStats app2 = store.statsForApp(from, to, "app-2", null);
|
||||
assertThat(app2.totalCount()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ class ClickHouseStatsStoreIT {
|
||||
Instant from = BASE.minusSeconds(60);
|
||||
Instant to = BASE.plusSeconds(300);
|
||||
|
||||
ExecutionStats routeA = store.statsForRoute(from, to, "route-a", List.of());
|
||||
ExecutionStats routeA = store.statsForRoute(from, to, "route-a", List.of(), null);
|
||||
assertThat(routeA.totalCount()).isEqualTo(6);
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ class ClickHouseStatsStoreIT {
|
||||
Instant from = BASE.minusSeconds(60);
|
||||
Instant to = BASE.plusSeconds(300);
|
||||
|
||||
StatsTimeseries ts = store.timeseries(from, to, 5);
|
||||
StatsTimeseries ts = store.timeseries(from, to, 5, null);
|
||||
|
||||
assertThat(ts.buckets()).isNotEmpty();
|
||||
long totalAcrossBuckets = ts.buckets().stream()
|
||||
@@ -206,7 +206,7 @@ class ClickHouseStatsStoreIT {
|
||||
Instant from = BASE.minusSeconds(60);
|
||||
Instant to = BASE.plusSeconds(300);
|
||||
|
||||
StatsTimeseries ts = store.timeseriesForApp(from, to, 5, "app-1");
|
||||
StatsTimeseries ts = store.timeseriesForApp(from, to, 5, "app-1", null);
|
||||
|
||||
long totalAcrossBuckets = ts.buckets().stream()
|
||||
.mapToLong(StatsTimeseries.TimeseriesBucket::totalCount).sum();
|
||||
@@ -218,7 +218,7 @@ class ClickHouseStatsStoreIT {
|
||||
Instant from = BASE.minusSeconds(60);
|
||||
Instant to = BASE.plusSeconds(300);
|
||||
|
||||
Map<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");
|
||||
}
|
||||
@@ -228,7 +228,7 @@ class ClickHouseStatsStoreIT {
|
||||
Instant from = BASE.minusSeconds(60);
|
||||
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");
|
||||
}
|
||||
@@ -244,7 +244,7 @@ class ClickHouseStatsStoreIT {
|
||||
// compliant (<=250ms): exec-01(200), exec-05(100), exec-06(150), exec-07(50), exec-08(60) = 5
|
||||
// total non-running: 9
|
||||
// compliance = 5/9 * 100 ~ 55.56%
|
||||
double sla = store.slaCompliance(from, to, 250, null, null);
|
||||
double sla = store.slaCompliance(from, to, 250, null, null, null);
|
||||
assertThat(sla).isBetween(55.0, 56.0);
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ class ClickHouseStatsStoreIT {
|
||||
Instant from = BASE.minusSeconds(60);
|
||||
Instant to = BASE.plusSeconds(300);
|
||||
|
||||
List<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.get(0).errorType()).isEqualTo("NPE");
|
||||
@@ -269,7 +269,7 @@ class ClickHouseStatsStoreIT {
|
||||
Instant from = BASE.minusSeconds(60);
|
||||
Instant to = BASE.plusSeconds(300);
|
||||
|
||||
int count = store.activeErrorTypes(from, to, "app-1");
|
||||
int count = store.activeErrorTypes(from, to, "app-1", null);
|
||||
|
||||
assertThat(count).isEqualTo(1); // only "NPE"
|
||||
}
|
||||
@@ -281,7 +281,7 @@ class ClickHouseStatsStoreIT {
|
||||
Instant from = BASE.minusSeconds(60);
|
||||
Instant to = BASE.plusSeconds(300);
|
||||
|
||||
List<PunchcardCell> cells = store.punchcard(from, to, null);
|
||||
List<PunchcardCell> cells = store.punchcard(from, to, null, null);
|
||||
|
||||
assertThat(cells).isNotEmpty();
|
||||
long totalCount = cells.stream().mapToLong(PunchcardCell::totalCount).sum();
|
||||
@@ -294,7 +294,7 @@ class ClickHouseStatsStoreIT {
|
||||
Instant to = BASE.plusSeconds(300);
|
||||
|
||||
// threshold=250ms
|
||||
Map<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");
|
||||
// app-1: 8 total executions, all non-RUNNING
|
||||
@@ -313,7 +313,7 @@ class ClickHouseStatsStoreIT {
|
||||
Instant from = BASE.minusSeconds(60);
|
||||
Instant to = BASE.plusSeconds(300);
|
||||
|
||||
Map<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");
|
||||
// route-a: exec-01(200)OK, exec-02(300)NO, exec-03(400)NO, exec-04(500)NO,
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.util.List;
|
||||
* @param instanceId agent instance ID filter
|
||||
* @param exchangeId Camel exchange ID filter
|
||||
* @param logger logger name substring filter
|
||||
* @param environment optional environment filter (e.g. "dev", "staging", "prod")
|
||||
* @param from inclusive start of time range (required)
|
||||
* @param to inclusive end of time range (required)
|
||||
* @param cursor ISO timestamp cursor for keyset pagination
|
||||
@@ -25,6 +26,7 @@ public record LogSearchRequest(
|
||||
String instanceId,
|
||||
String exchangeId,
|
||||
String logger,
|
||||
String environment,
|
||||
Instant from,
|
||||
Instant to,
|
||||
String cursor,
|
||||
|
||||
@@ -28,6 +28,7 @@ import java.util.List;
|
||||
* @param limit page size (default 50, max 500)
|
||||
* @param sortField column to sort by (default: startTime)
|
||||
* @param sortDir sort direction: asc or desc (default: desc)
|
||||
* @param environment optional environment filter (e.g. "dev", "staging", "prod")
|
||||
*/
|
||||
public record SearchRequest(
|
||||
String status,
|
||||
@@ -48,7 +49,8 @@ public record SearchRequest(
|
||||
int offset,
|
||||
int limit,
|
||||
String sortField,
|
||||
String sortDir
|
||||
String sortDir,
|
||||
String environment
|
||||
) {
|
||||
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
@@ -90,7 +92,17 @@ public record SearchRequest(
|
||||
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||
text, textInBody, textInHeaders, textInErrors,
|
||||
routeId, instanceId, processorType, applicationId, resolvedInstanceIds,
|
||||
offset, limit, sortField, sortDir
|
||||
offset, limit, sortField, sortDir, environment
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a copy with the given environment filter. */
|
||||
public SearchRequest withEnvironment(String env) {
|
||||
return new SearchRequest(
|
||||
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||
text, textInBody, textInHeaders, textInErrors,
|
||||
routeId, instanceId, processorType, applicationId, instanceIds,
|
||||
offset, limit, sortField, sortDir, env
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,65 +30,126 @@ public class SearchService {
|
||||
}
|
||||
|
||||
public ExecutionStats stats(Instant from, Instant to) {
|
||||
return statsStore.stats(from, to);
|
||||
return statsStore.stats(from, to, null);
|
||||
}
|
||||
|
||||
public ExecutionStats stats(Instant from, Instant to, String environment) {
|
||||
return statsStore.stats(from, to, environment);
|
||||
}
|
||||
|
||||
public ExecutionStats statsForApp(Instant from, Instant to, String applicationId) {
|
||||
return statsStore.statsForApp(from, to, applicationId);
|
||||
return statsStore.statsForApp(from, to, applicationId, null);
|
||||
}
|
||||
|
||||
public ExecutionStats statsForApp(Instant from, Instant to, String applicationId, String environment) {
|
||||
return statsStore.statsForApp(from, to, applicationId, environment);
|
||||
}
|
||||
|
||||
public ExecutionStats stats(Instant from, Instant to, String routeId, List<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) {
|
||||
return statsStore.timeseries(from, to, bucketCount);
|
||||
return statsStore.timeseries(from, to, bucketCount, null);
|
||||
}
|
||||
|
||||
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, String environment) {
|
||||
return statsStore.timeseries(from, to, bucketCount, environment);
|
||||
}
|
||||
|
||||
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId) {
|
||||
return statsStore.timeseriesForApp(from, to, bucketCount, applicationId);
|
||||
return statsStore.timeseriesForApp(from, to, bucketCount, applicationId, null);
|
||||
}
|
||||
|
||||
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId, String environment) {
|
||||
return statsStore.timeseriesForApp(from, to, bucketCount, applicationId, environment);
|
||||
}
|
||||
|
||||
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount,
|
||||
String routeId, List<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 ────────────────────────────────────────
|
||||
|
||||
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,
|
||||
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,
|
||||
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) {
|
||||
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,
|
||||
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,
|
||||
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) {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,58 +11,58 @@ import java.util.Map;
|
||||
public interface StatsStore {
|
||||
|
||||
// Global stats (stats_1m_all)
|
||||
ExecutionStats stats(Instant from, Instant to);
|
||||
ExecutionStats stats(Instant from, Instant to, String environment);
|
||||
|
||||
// Per-app stats (stats_1m_app)
|
||||
ExecutionStats statsForApp(Instant from, Instant to, String applicationId);
|
||||
ExecutionStats statsForApp(Instant from, Instant to, String applicationId, String environment);
|
||||
|
||||
// Per-route stats (stats_1m_route), optionally scoped to specific agents
|
||||
ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds);
|
||||
ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds, String environment);
|
||||
|
||||
// Per-processor stats (stats_1m_processor)
|
||||
ExecutionStats statsForProcessor(Instant from, Instant to, String routeId, String processorType);
|
||||
|
||||
// Global timeseries
|
||||
StatsTimeseries timeseries(Instant from, Instant to, int bucketCount);
|
||||
StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, String environment);
|
||||
|
||||
// Per-app timeseries
|
||||
StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId);
|
||||
StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId, String environment);
|
||||
|
||||
// Per-route timeseries, optionally scoped to specific agents
|
||||
StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount,
|
||||
String routeId, List<String> agentIds);
|
||||
String routeId, List<String> agentIds, String environment);
|
||||
|
||||
// Per-processor timeseries
|
||||
StatsTimeseries timeseriesForProcessor(Instant from, Instant to, int bucketCount,
|
||||
String routeId, String processorType);
|
||||
|
||||
// Grouped timeseries by application (for L1 dashboard charts)
|
||||
Map<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)
|
||||
Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to, int bucketCount,
|
||||
String applicationId);
|
||||
String applicationId, String environment);
|
||||
|
||||
// SLA compliance: % of completed exchanges with duration <= thresholdMs
|
||||
double slaCompliance(Instant from, Instant to, int thresholdMs,
|
||||
String applicationId, String routeId);
|
||||
String applicationId, String routeId, String environment);
|
||||
|
||||
// Batch SLA counts by app: {appId -> [compliant, total]}
|
||||
Map<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]}
|
||||
Map<String, long[]> slaCountsByRoute(Instant from, Instant to, String applicationId,
|
||||
int thresholdMs);
|
||||
int thresholdMs, String environment);
|
||||
|
||||
// Top N errors with velocity trend
|
||||
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
|
||||
int activeErrorTypes(Instant from, Instant to, String applicationId);
|
||||
int activeErrorTypes(Instant from, Instant to, String applicationId, String environment);
|
||||
|
||||
// Punchcard: aggregate by weekday (0=Sun..6=Sat) x hour (0-23) over last 7 days
|
||||
List<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) {}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../client';
|
||||
import { config } from '../../config';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { useRefreshInterval } from './use-refresh-interval';
|
||||
|
||||
export function useAgents(status?: string, application?: string) {
|
||||
export function useAgents(status?: string, application?: string, environment?: string) {
|
||||
const refetchInterval = useRefreshInterval(10_000);
|
||||
return useQuery({
|
||||
queryKey: ['agents', status, application],
|
||||
queryKey: ['agents', status, application, environment],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/agents', {
|
||||
params: { query: { ...(status ? { status } : {}), ...(application ? { application } : {}) } },
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
if (application) params.set('application', application);
|
||||
if (environment) params.set('environment', environment);
|
||||
const qs = params.toString();
|
||||
const res = await fetch(`${config.apiBaseUrl}/agents${qs ? `?${qs}` : ''}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Cameleer-Protocol-Version': '1',
|
||||
},
|
||||
});
|
||||
if (error) throw new Error('Failed to load agents');
|
||||
return data!;
|
||||
if (!res.ok) throw new Error('Failed to load agents');
|
||||
return res.json();
|
||||
},
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
@@ -3,15 +3,16 @@ import { config } from '../../config';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { useRefreshInterval } from './use-refresh-interval';
|
||||
|
||||
export function useRouteCatalog(from?: string, to?: string) {
|
||||
export function useRouteCatalog(from?: string, to?: string, environment?: string) {
|
||||
const refetchInterval = useRefreshInterval(15_000);
|
||||
return useQuery({
|
||||
queryKey: ['routes', 'catalog', from, to],
|
||||
queryKey: ['routes', 'catalog', from, to, environment],
|
||||
queryFn: async () => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set('from', from);
|
||||
if (to) params.set('to', to);
|
||||
if (environment) params.set('environment', environment);
|
||||
const qs = params.toString();
|
||||
const res = await fetch(`${config.apiBaseUrl}/routes/catalog${qs ? `?${qs}` : ''}`, {
|
||||
headers: {
|
||||
|
||||
@@ -39,12 +39,12 @@ export interface GroupedTimeseries {
|
||||
[key: string]: { buckets: TimeseriesBucket[] };
|
||||
}
|
||||
|
||||
export function useTimeseriesByApp(from?: string, to?: string) {
|
||||
export function useTimeseriesByApp(from?: string, to?: string, environment?: string) {
|
||||
const refetchInterval = useRefreshInterval(30_000);
|
||||
return useQuery({
|
||||
queryKey: ['dashboard', 'timeseries-by-app', from, to],
|
||||
queryKey: ['dashboard', 'timeseries-by-app', from, to, environment],
|
||||
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-app', {
|
||||
from, to, buckets: '24',
|
||||
from, to, buckets: '24', environment,
|
||||
}),
|
||||
enabled: !!from,
|
||||
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
|
||||
@@ -54,12 +54,12 @@ export function useTimeseriesByApp(from?: string, to?: string) {
|
||||
|
||||
// ── Timeseries by route (L2 charts) ───────────────────────────────────
|
||||
|
||||
export function useTimeseriesByRoute(from?: string, to?: string, application?: string) {
|
||||
export function useTimeseriesByRoute(from?: string, to?: string, application?: string, environment?: string) {
|
||||
const refetchInterval = useRefreshInterval(30_000);
|
||||
return useQuery({
|
||||
queryKey: ['dashboard', 'timeseries-by-route', from, to, application],
|
||||
queryKey: ['dashboard', 'timeseries-by-route', from, to, application, environment],
|
||||
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-route', {
|
||||
from, to, application, buckets: '24',
|
||||
from, to, application, buckets: '24', environment,
|
||||
}),
|
||||
enabled: !!from && !!application,
|
||||
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
|
||||
@@ -79,12 +79,12 @@ export interface TopError {
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export function useTopErrors(from?: string, to?: string, application?: string, routeId?: string) {
|
||||
export function useTopErrors(from?: string, to?: string, application?: string, routeId?: string, environment?: string) {
|
||||
const refetchInterval = useRefreshInterval(10_000);
|
||||
return useQuery({
|
||||
queryKey: ['dashboard', 'top-errors', from, to, application, routeId],
|
||||
queryKey: ['dashboard', 'top-errors', from, to, application, routeId, environment],
|
||||
queryFn: () => fetchJson<TopError[]>('/search/errors/top', {
|
||||
from, to, application, routeId, limit: '5',
|
||||
from, to, application, routeId, limit: '5', environment,
|
||||
}),
|
||||
enabled: !!from,
|
||||
placeholderData: (prev: TopError[] | undefined) => prev,
|
||||
@@ -101,11 +101,11 @@ export interface PunchcardCell {
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
export function usePunchcard(application?: string) {
|
||||
export function usePunchcard(application?: string, environment?: string) {
|
||||
const refetchInterval = useRefreshInterval(60_000);
|
||||
return useQuery({
|
||||
queryKey: ['dashboard', 'punchcard', application],
|
||||
queryFn: () => fetchJson<PunchcardCell[]>('/search/stats/punchcard', { application }),
|
||||
queryKey: ['dashboard', 'punchcard', application, environment],
|
||||
queryFn: () => fetchJson<PunchcardCell[]>('/search/stats/punchcard', { application, environment }),
|
||||
placeholderData: (prev: PunchcardCell[] | undefined) => prev ?? [],
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
@@ -8,10 +8,11 @@ export function useExecutionStats(
|
||||
timeTo: string | undefined,
|
||||
routeId?: string,
|
||||
application?: string,
|
||||
environment?: string,
|
||||
) {
|
||||
const live = useLiveQuery(10_000);
|
||||
return useQuery({
|
||||
queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, application],
|
||||
queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, application, environment],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/search/stats', {
|
||||
params: {
|
||||
@@ -20,6 +21,7 @@ export function useExecutionStats(
|
||||
to: timeTo || undefined,
|
||||
routeId: routeId || undefined,
|
||||
application: application || undefined,
|
||||
environment: environment || undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -70,10 +72,11 @@ export function useStatsTimeseries(
|
||||
timeTo: string | undefined,
|
||||
routeId?: string,
|
||||
application?: string,
|
||||
environment?: string,
|
||||
) {
|
||||
const live = useLiveQuery(30_000);
|
||||
return useQuery({
|
||||
queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, application],
|
||||
queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, application, environment],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/search/stats/timeseries', {
|
||||
params: {
|
||||
@@ -83,6 +86,7 @@ export function useStatsTimeseries(
|
||||
buckets: 24,
|
||||
routeId: routeId || undefined,
|
||||
application: application || undefined,
|
||||
environment: environment || undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
10
ui/src/api/schema.d.ts
vendored
10
ui/src/api/schema.d.ts
vendored
@@ -1502,6 +1502,7 @@ export interface components {
|
||||
limit?: number;
|
||||
sortField?: string;
|
||||
sortDir?: string;
|
||||
environment?: string;
|
||||
};
|
||||
ExecutionSummary: {
|
||||
executionId: string;
|
||||
@@ -1960,6 +1961,7 @@ export interface components {
|
||||
instanceId: string;
|
||||
displayName: string;
|
||||
applicationId: string;
|
||||
environmentId?: string;
|
||||
status: string;
|
||||
routeIds: string[];
|
||||
/** Format: date-time */
|
||||
@@ -2773,6 +2775,7 @@ export interface operations {
|
||||
agentId?: string;
|
||||
processorType?: string;
|
||||
application?: string;
|
||||
environment?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
sortField?: string;
|
||||
@@ -3795,6 +3798,7 @@ export interface operations {
|
||||
to?: string;
|
||||
routeId?: string;
|
||||
application?: string;
|
||||
environment?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -3821,6 +3825,7 @@ export interface operations {
|
||||
buckets?: number;
|
||||
routeId?: string;
|
||||
application?: string;
|
||||
environment?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -3846,6 +3851,7 @@ export interface operations {
|
||||
to?: string;
|
||||
buckets?: number;
|
||||
application: string;
|
||||
environment?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -3872,6 +3878,7 @@ export interface operations {
|
||||
from: string;
|
||||
to?: string;
|
||||
buckets?: number;
|
||||
environment?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -3896,6 +3903,7 @@ export interface operations {
|
||||
parameters: {
|
||||
query?: {
|
||||
application?: string;
|
||||
environment?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -3921,6 +3929,7 @@ export interface operations {
|
||||
to?: string;
|
||||
application?: string;
|
||||
routeId?: string;
|
||||
environment?: string;
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
@@ -4334,6 +4343,7 @@ export interface operations {
|
||||
query?: {
|
||||
status?: string;
|
||||
application?: string;
|
||||
environment?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
||||
26
ui/src/components/EnvironmentSelector.module.css
Normal file
26
ui/src/components/EnvironmentSelector.module.css
Normal 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);
|
||||
}
|
||||
25
ui/src/components/EnvironmentSelector.tsx
Normal file
25
ui/src/components/EnvironmentSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||
import { Outlet, useNavigate, useLocation, useSearchParams } from 'react-router';
|
||||
import {
|
||||
AppShell,
|
||||
Sidebar,
|
||||
@@ -26,6 +26,7 @@ import { useAuthStore } from '../auth/auth-store';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ContentTabs } from './ContentTabs';
|
||||
import { EnvironmentSelector } from './EnvironmentSelector';
|
||||
import { useScope } from '../hooks/useScope';
|
||||
import {
|
||||
buildAppTreeNodes,
|
||||
@@ -271,12 +272,38 @@ const SK_COLLAPSED = 'sidebar:collapsed';
|
||||
function LayoutContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { timeRange, autoRefresh, refreshTimeRange } = useGlobalFilters();
|
||||
const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString());
|
||||
const { data: agents } = useAgents();
|
||||
|
||||
// --- Environment filtering -----------------------------------------
|
||||
const selectedEnv = searchParams.get('env') || undefined;
|
||||
const setSelectedEnv = useCallback((env: string | undefined) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (env) {
|
||||
next.set('env', env);
|
||||
} else {
|
||||
next.delete('env');
|
||||
}
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString(), selectedEnv);
|
||||
const { data: agents } = useAgents(undefined, undefined, selectedEnv);
|
||||
const { data: attributeKeys } = useAttributeKeys();
|
||||
|
||||
// Extract distinct environments from agents
|
||||
const environments: string[] = useMemo(() => {
|
||||
if (!agents) return [];
|
||||
const envSet = new Set<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) ----------------
|
||||
const isAdminPage = location.pathname.startsWith('/admin');
|
||||
const { data: adminUsers } = useUsers(isAdminPage);
|
||||
@@ -675,6 +702,7 @@ function LayoutContent() {
|
||||
<AppShell sidebar={sidebarElement}>
|
||||
<TopBar
|
||||
breadcrumb={breadcrumb}
|
||||
environment={selectedEnv}
|
||||
user={username ? { name: username } : undefined}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
@@ -689,6 +717,16 @@ function LayoutContent() {
|
||||
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 && (
|
||||
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user