diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/OpenApiConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/OpenApiConfig.java index d75e8258..a1547f41 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/OpenApiConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/OpenApiConfig.java @@ -34,7 +34,7 @@ public class OpenApiConfig { "ProcessorNode", "AppCatalogEntry", "RouteSummary", "AgentSummary", "RouteMetrics", "AgentEventResponse", "AgentInstanceResponse", - "ProcessorMetrics" + "ProcessorMetrics", "AgentMetricsResponse", "MetricBucket" ); @Bean 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 new file mode 100644 index 00000000..ada0c9b4 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentMetricsController.java @@ -0,0 +1,65 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.dto.AgentMetricsResponse; +import com.cameleer3.server.app.dto.MetricBucket; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; + +@RestController +@RequestMapping("/api/v1/agents/{agentId}/metrics") +public class AgentMetricsController { + + private final JdbcTemplate jdbc; + + public AgentMetricsController(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @GetMapping + public AgentMetricsResponse getMetrics( + @PathVariable String agentId, + @RequestParam String names, + @RequestParam(required = false) Instant from, + @RequestParam(required = false) Instant to, + @RequestParam(defaultValue = "60") int buckets) { + + if (from == null) from = Instant.now().minus(1, ChronoUnit.HOURS); + if (to == null) to = Instant.now(); + + List metricNames = Arrays.asList(names.split(",")); + long intervalMs = (to.toEpochMilli() - from.toEpochMilli()) / Math.max(buckets, 1); + String intervalStr = intervalMs + " milliseconds"; + + Map> result = new LinkedHashMap<>(); + for (String name : metricNames) { + result.put(name.trim(), new ArrayList<>()); + } + + String sql = """ + SELECT time_bucket(CAST(? AS interval), collected_at) AS bucket, + metric_name, + AVG(metric_value) AS avg_value + FROM agent_metrics + WHERE agent_id = ? + AND collected_at >= ? AND collected_at < ? + AND metric_name = ANY(?) + GROUP BY bucket, metric_name + ORDER BY bucket + """; + + String[] namesArray = metricNames.stream().map(String::trim).toArray(String[]::new); + jdbc.query(sql, 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 MetricBucket(bucket, value)); + }, intervalStr, agentId, from, to, namesArray); + + return new AgentMetricsResponse(result); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentMetricsResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentMetricsResponse.java new file mode 100644 index 00000000..3f3465e2 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentMetricsResponse.java @@ -0,0 +1,9 @@ +package com.cameleer3.server.app.dto; + +import java.util.List; +import java.util.Map; +import jakarta.validation.constraints.NotNull; + +public record AgentMetricsResponse( + @NotNull Map> metrics +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/MetricBucket.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/MetricBucket.java new file mode 100644 index 00000000..bbb17781 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/MetricBucket.java @@ -0,0 +1,9 @@ +package com.cameleer3.server.app.dto; + +import java.time.Instant; +import jakarta.validation.constraints.NotNull; + +public record MetricBucket( + @NotNull Instant time, + double value +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java index 473b33a6..4cbd2d2e 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java @@ -80,6 +80,7 @@ public class SecurityConfig { // Read-only data endpoints — viewer+ .requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/agents/*/metrics").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/agents/events-log").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")