diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentMetricsController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentMetricsController.java index 78edd94f..5b1d4752 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentMetricsController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentMetricsController.java @@ -27,15 +27,17 @@ public class AgentMetricsController { @RequestParam String names, @RequestParam(required = false) Instant from, @RequestParam(required = false) Instant to, - @RequestParam(defaultValue = "60") int buckets) { + @RequestParam(defaultValue = "60") int buckets, + @RequestParam(defaultValue = "gauge") String mode) { if (from == null) from = Instant.now().minus(1, ChronoUnit.HOURS); if (to == null) to = Instant.now(); List metricNames = Arrays.asList(names.split(",")); - Map> raw = - metricsQueryStore.queryTimeSeries(agentId, metricNames, from, to, buckets); + Map> raw = "delta".equalsIgnoreCase(mode) + ? metricsQueryStore.queryTimeSeriesDelta(agentId, metricNames, from, to, buckets) + : metricsQueryStore.queryTimeSeries(agentId, metricNames, from, to, buckets); Map> result = raw.entrySet().stream() .collect(Collectors.toMap( diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseMetricsQueryStore.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseMetricsQueryStore.java index ac22f724..fd18a0a6 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseMetricsQueryStore.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseMetricsQueryStore.java @@ -69,4 +69,57 @@ public class ClickHouseMetricsQueryStore implements MetricsQueryStore { return result; } + + @Override + public Map> queryTimeSeriesDelta( + String instanceId, List metricNames, + Instant from, Instant to, int buckets) { + + long intervalSeconds = Math.max(60, + (to.getEpochSecond() - from.getEpochSecond()) / Math.max(buckets, 1)); + + Map> result = new LinkedHashMap<>(); + for (String name : metricNames) { + result.put(name.trim(), new ArrayList<>()); + } + + String[] namesArray = metricNames.stream().map(String::trim).toArray(String[]::new); + String placeholders = String.join(", ", Collections.nCopies(namesArray.length, "?")); + + String finalSql = """ + SELECT bucket, metric_name, + greatest(0, max_val - lag(max_val, 1, max_val) + OVER (PARTITION BY metric_name ORDER BY bucket)) AS avg_value + FROM ( + SELECT toStartOfInterval(collected_at, INTERVAL %d SECOND) AS bucket, + metric_name, + max(metric_value) AS max_val + FROM agent_metrics + WHERE tenant_id = ? + AND instance_id = ? + AND collected_at >= ? + AND collected_at < ? + AND metric_name IN (%s) + GROUP BY bucket, metric_name + ) + ORDER BY bucket + """.formatted(intervalSeconds, placeholders); + + List params = new ArrayList<>(); + params.add(tenantId); + params.add(instanceId); + params.add(java.sql.Timestamp.from(from)); + params.add(java.sql.Timestamp.from(to)); + Collections.addAll(params, namesArray); + + jdbc.query(finalSql, rs -> { + String metricName = rs.getString("metric_name"); + Instant bucket = rs.getTimestamp("bucket").toInstant(); + double value = rs.getDouble("avg_value"); + result.computeIfAbsent(metricName, k -> new ArrayList<>()) + .add(new MetricTimeSeries.Bucket(bucket, value)); + }, params.toArray()); + + return result; + } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/MetricsQueryStore.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/MetricsQueryStore.java index ff1f5fb1..fdf8e875 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/MetricsQueryStore.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/MetricsQueryStore.java @@ -11,4 +11,11 @@ public interface MetricsQueryStore { Map> queryTimeSeries( String instanceId, List metricNames, Instant from, Instant to, int buckets); + + /** Counter mode: returns per-bucket deltas (max - previous max, floored at 0). */ + default Map> queryTimeSeriesDelta( + String instanceId, List metricNames, + Instant from, Instant to, int buckets) { + return queryTimeSeries(instanceId, metricNames, from, to, buckets); + } } diff --git a/ui/src/api/queries/agent-metrics.ts b/ui/src/api/queries/agent-metrics.ts index 9970c943..2a4acec3 100644 --- a/ui/src/api/queries/agent-metrics.ts +++ b/ui/src/api/queries/agent-metrics.ts @@ -9,15 +9,17 @@ export function useAgentMetrics( buckets = 60, from?: string, to?: string, + mode: 'gauge' | 'delta' = 'gauge', ) { const refetchInterval = useRefreshInterval(30_000); return useQuery({ - queryKey: ['agent-metrics', agentId, names.join(','), buckets, from, to], + queryKey: ['agent-metrics', agentId, names.join(','), buckets, from, to, mode], queryFn: async () => { const token = useAuthStore.getState().accessToken; const params = new URLSearchParams({ names: names.join(','), buckets: String(buckets), + mode, }); if (from) params.set('from', from); if (to) params.set('to', to); diff --git a/ui/src/pages/AgentInstance/AgentInstance.tsx b/ui/src/pages/AgentInstance/AgentInstance.tsx index 38486fc3..f9acbf02 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.tsx +++ b/ui/src/pages/AgentInstance/AgentInstance.tsx @@ -75,7 +75,7 @@ export default function AgentInstance() { const { data: agentMetrics } = useAgentMetrics( agent?.instanceId || null, ['cameleer.chunks.exported.count', 'cameleer.chunks.dropped.count'], - 60, timeFrom, timeTo, + 60, timeFrom, timeTo, 'delta', ); const feedEvents = useMemo(() => {