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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

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

View File

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

View File

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

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 {
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} />
)}