feat: migrate UI to @cameleer/design-system, add backend endpoints
Backend: - Add agent_events table (V5) and lifecycle event recording - Add route catalog endpoint (GET /routes/catalog) - Add route metrics endpoint (GET /routes/metrics) - Add agent events endpoint (GET /agents/events-log) - Enrich AgentInstanceResponse with tps, errorRate, activeRoutes, uptimeSeconds - Add TimescaleDB retention/compression policies (V6) Frontend: - Replace custom Mission Control UI with @cameleer/design-system components - Rebuild all pages: Dashboard, ExchangeDetail, RoutesMetrics, AgentHealth, AgentInstance, RBAC, AuditLog, OIDC, DatabaseAdmin, OpenSearchAdmin, Swagger - New LayoutShell with design system AppShell, Sidebar, TopBar, CommandPalette - Consume design system from Gitea npm registry (@cameleer/design-system@0.0.1) - Add .npmrc for scoped registry, update Dockerfile with REGISTRY_TOKEN arg CI: - Pass REGISTRY_TOKEN build-arg to UI Docker build step Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,23 @@
|
||||
package com.cameleer3.server.app.agent;
|
||||
|
||||
import com.cameleer3.server.core.agent.AgentEventService;
|
||||
import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.agent.AgentState;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Periodic task that checks agent lifecycle and expires old commands.
|
||||
* <p>
|
||||
* Runs on a configurable fixed delay (default 10 seconds). Transitions
|
||||
* agents LIVE -> STALE -> DEAD based on heartbeat timing, and removes
|
||||
* expired pending commands.
|
||||
* expired pending commands. Records lifecycle events for state transitions.
|
||||
*/
|
||||
@Component
|
||||
public class AgentLifecycleMonitor {
|
||||
@@ -19,18 +25,46 @@ public class AgentLifecycleMonitor {
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class);
|
||||
|
||||
private final AgentRegistryService registryService;
|
||||
private final AgentEventService agentEventService;
|
||||
|
||||
public AgentLifecycleMonitor(AgentRegistryService registryService) {
|
||||
public AgentLifecycleMonitor(AgentRegistryService registryService,
|
||||
AgentEventService agentEventService) {
|
||||
this.registryService = registryService;
|
||||
this.agentEventService = agentEventService;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}")
|
||||
public void checkLifecycle() {
|
||||
try {
|
||||
// Snapshot states before lifecycle check
|
||||
Map<String, AgentState> statesBefore = new HashMap<>();
|
||||
for (AgentInfo agent : registryService.findAll()) {
|
||||
statesBefore.put(agent.id(), agent.state());
|
||||
}
|
||||
|
||||
registryService.checkLifecycle();
|
||||
registryService.expireOldCommands();
|
||||
|
||||
// Detect transitions and record events
|
||||
for (AgentInfo agent : registryService.findAll()) {
|
||||
AgentState before = statesBefore.get(agent.id());
|
||||
if (before != null && before != agent.state()) {
|
||||
String eventType = mapTransitionEvent(before, agent.state());
|
||||
if (eventType != null) {
|
||||
agentEventService.recordEvent(agent.id(), agent.group(), eventType,
|
||||
agent.name() + " " + before + " -> " + agent.state());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error during agent lifecycle check", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String mapTransitionEvent(AgentState from, AgentState to) {
|
||||
if (from == AgentState.LIVE && to == AgentState.STALE) return "WENT_STALE";
|
||||
if (from == AgentState.STALE && to == AgentState.DEAD) return "WENT_DEAD";
|
||||
if (from == AgentState.STALE && to == AgentState.LIVE) return "RECOVERED";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.cameleer3.server.app.config;
|
||||
|
||||
import com.cameleer3.server.core.agent.AgentEventRepository;
|
||||
import com.cameleer3.server.core.agent.AgentEventService;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Creates the {@link AgentRegistryService} bean.
|
||||
* Creates the {@link AgentRegistryService} and {@link AgentEventService} beans.
|
||||
* <p>
|
||||
* Follows the established pattern: core module plain class, app module bean config.
|
||||
*/
|
||||
@@ -20,4 +22,9 @@ public class AgentRegistryBeanConfig {
|
||||
config.getCommandExpiryMs()
|
||||
);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AgentEventService agentEventService(AgentEventRepository repository) {
|
||||
return new AgentEventService(repository);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ public class OpenApiConfig {
|
||||
"ExecutionSummary", "ExecutionDetail", "ExecutionStats",
|
||||
"StatsTimeseries", "TimeseriesBucket",
|
||||
"SearchResultExecutionSummary", "UserInfo",
|
||||
"ProcessorNode"
|
||||
"ProcessorNode",
|
||||
"AppCatalogEntry", "RouteSummary", "AgentSummary",
|
||||
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse"
|
||||
);
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.AgentEventResponse;
|
||||
import com.cameleer3.server.core.agent.AgentEventService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agents/events-log")
|
||||
@Tag(name = "Agent Events", description = "Agent lifecycle event log")
|
||||
public class AgentEventsController {
|
||||
|
||||
private final AgentEventService agentEventService;
|
||||
|
||||
public AgentEventsController(AgentEventService agentEventService) {
|
||||
this.agentEventService = agentEventService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Query agent events",
|
||||
description = "Returns agent lifecycle events, optionally filtered by app and/or agent ID")
|
||||
@ApiResponse(responseCode = "200", description = "Events returned")
|
||||
public ResponseEntity<List<AgentEventResponse>> getEvents(
|
||||
@RequestParam(required = false) String appId,
|
||||
@RequestParam(required = false) String agentId,
|
||||
@RequestParam(required = false) String from,
|
||||
@RequestParam(required = false) String to,
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
|
||||
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||
|
||||
var events = agentEventService.queryEvents(appId, agentId, fromInstant, toInstant, limit)
|
||||
.stream()
|
||||
.map(AgentEventResponse::from)
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(events);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.cameleer3.server.app.dto.AgentRegistrationRequest;
|
||||
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
|
||||
import com.cameleer3.server.app.dto.ErrorResponse;
|
||||
import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
||||
import com.cameleer3.server.core.agent.AgentEventService;
|
||||
import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.agent.AgentState;
|
||||
@@ -23,6 +24,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -31,8 +33,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent registration, heartbeat, listing, and token refresh endpoints.
|
||||
@@ -50,17 +57,23 @@ public class AgentRegistrationController {
|
||||
private final BootstrapTokenValidator bootstrapTokenValidator;
|
||||
private final JwtService jwtService;
|
||||
private final Ed25519SigningService ed25519SigningService;
|
||||
private final AgentEventService agentEventService;
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public AgentRegistrationController(AgentRegistryService registryService,
|
||||
AgentRegistryConfig config,
|
||||
BootstrapTokenValidator bootstrapTokenValidator,
|
||||
JwtService jwtService,
|
||||
Ed25519SigningService ed25519SigningService) {
|
||||
Ed25519SigningService ed25519SigningService,
|
||||
AgentEventService agentEventService,
|
||||
JdbcTemplate jdbc) {
|
||||
this.registryService = registryService;
|
||||
this.config = config;
|
||||
this.bootstrapTokenValidator = bootstrapTokenValidator;
|
||||
this.jwtService = jwtService;
|
||||
this.ed25519SigningService = ed25519SigningService;
|
||||
this.agentEventService = agentEventService;
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@@ -97,6 +110,9 @@ public class AgentRegistrationController {
|
||||
request.agentId(), request.name(), group, request.version(), routeIds, capabilities);
|
||||
log.info("Agent registered: {} (name={}, group={})", request.agentId(), request.name(), group);
|
||||
|
||||
agentEventService.recordEvent(request.agentId(), group, "REGISTERED",
|
||||
"Agent registered: " + request.name());
|
||||
|
||||
// Issue JWT tokens with AGENT role
|
||||
List<String> roles = List.of("AGENT");
|
||||
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles);
|
||||
@@ -171,7 +187,7 @@ public class AgentRegistrationController {
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all agents",
|
||||
description = "Returns all registered agents, optionally filtered by status and/or group")
|
||||
description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or group")
|
||||
@ApiResponse(responseCode = "200", description = "Agent list returned")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid status filter",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@@ -198,9 +214,52 @@ public class AgentRegistrationController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<AgentInstanceResponse> response = agents.stream()
|
||||
.map(AgentInstanceResponse::from)
|
||||
// Enrich with runtime metrics from continuous aggregates
|
||||
Map<String, double[]> agentMetrics = queryAgentMetrics();
|
||||
final List<AgentInfo> finalAgents = agents;
|
||||
|
||||
List<AgentInstanceResponse> response = finalAgents.stream()
|
||||
.map(a -> {
|
||||
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
|
||||
double[] m = agentMetrics.get(a.group());
|
||||
if (m != null) {
|
||||
long groupAgentCount = finalAgents.stream()
|
||||
.filter(ag -> ag.group().equals(a.group())).count();
|
||||
double agentTps = groupAgentCount > 0 ? m[0] / groupAgentCount : 0;
|
||||
double errorRate = m[1];
|
||||
int activeRoutes = (int) m[2];
|
||||
return dto.withMetrics(agentTps, errorRate, activeRoutes);
|
||||
}
|
||||
return dto;
|
||||
})
|
||||
.toList();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
private Map<String, double[]> queryAgentMetrics() {
|
||||
Map<String, double[]> result = new HashMap<>();
|
||||
Instant now = Instant.now();
|
||||
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
||||
try {
|
||||
jdbc.query(
|
||||
"SELECT group_name, " +
|
||||
"SUM(total_count) AS total, " +
|
||||
"SUM(failed_count) AS failed, " +
|
||||
"COUNT(DISTINCT route_id) AS active_routes " +
|
||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||
"GROUP BY group_name",
|
||||
rs -> {
|
||||
long total = rs.getLong("total");
|
||||
long failed = rs.getLong("failed");
|
||||
double tps = total / 60.0;
|
||||
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||
int activeRoutes = rs.getInt("active_routes");
|
||||
result.put(rs.getString("group_name"), new double[]{tps, errorRate, activeRoutes});
|
||||
},
|
||||
Timestamp.from(from1m), Timestamp.from(now));
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not query agent metrics: {}", e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.AgentSummary;
|
||||
import com.cameleer3.server.app.dto.AppCatalogEntry;
|
||||
import com.cameleer3.server.app.dto.RouteSummary;
|
||||
import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.agent.AgentState;
|
||||
import com.cameleer3.server.core.storage.StatsStore;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/routes")
|
||||
@Tag(name = "Route Catalog", description = "Route catalog and discovery")
|
||||
public class RouteCatalogController {
|
||||
|
||||
private final AgentRegistryService registryService;
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public RouteCatalogController(AgentRegistryService registryService, JdbcTemplate jdbc) {
|
||||
this.registryService = registryService;
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@GetMapping("/catalog")
|
||||
@Operation(summary = "Get route catalog",
|
||||
description = "Returns all applications with their routes, agents, and health status")
|
||||
@ApiResponse(responseCode = "200", description = "Catalog returned")
|
||||
public ResponseEntity<List<AppCatalogEntry>> getCatalog() {
|
||||
List<AgentInfo> allAgents = registryService.findAll();
|
||||
|
||||
// Group agents by application (group name)
|
||||
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
||||
.collect(Collectors.groupingBy(AgentInfo::group, LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
// Collect all distinct routes per app
|
||||
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
|
||||
for (var entry : agentsByApp.entrySet()) {
|
||||
Set<String> routes = new LinkedHashSet<>();
|
||||
for (AgentInfo agent : entry.getValue()) {
|
||||
if (agent.routeIds() != null) {
|
||||
routes.addAll(agent.routeIds());
|
||||
}
|
||||
}
|
||||
routesByApp.put(entry.getKey(), routes);
|
||||
}
|
||||
|
||||
// Query route-level stats for the last 24 hours
|
||||
Instant now = Instant.now();
|
||||
Instant from24h = now.minus(24, ChronoUnit.HOURS);
|
||||
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
||||
|
||||
// Route exchange counts from continuous aggregate
|
||||
Map<String, Long> routeExchangeCounts = new LinkedHashMap<>();
|
||||
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
||||
try {
|
||||
jdbc.query(
|
||||
"SELECT group_name, route_id, SUM(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||
"GROUP BY group_name, route_id",
|
||||
rs -> {
|
||||
String key = rs.getString("group_name") + "/" + rs.getString("route_id");
|
||||
routeExchangeCounts.put(key, rs.getLong("cnt"));
|
||||
Timestamp ts = rs.getTimestamp("last_seen");
|
||||
if (ts != null) routeLastSeen.put(key, ts.toInstant());
|
||||
},
|
||||
Timestamp.from(from24h), Timestamp.from(now));
|
||||
} catch (Exception e) {
|
||||
// Continuous aggregate may not exist yet
|
||||
}
|
||||
|
||||
// Per-agent TPS from the last minute
|
||||
Map<String, Double> agentTps = new LinkedHashMap<>();
|
||||
try {
|
||||
jdbc.query(
|
||||
"SELECT group_name, SUM(total_count) AS cnt " +
|
||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||
"GROUP BY group_name",
|
||||
rs -> {
|
||||
// This gives per-app TPS; we'll distribute among agents below
|
||||
},
|
||||
Timestamp.from(from1m), Timestamp.from(now));
|
||||
} catch (Exception e) {
|
||||
// Continuous aggregate may not exist yet
|
||||
}
|
||||
|
||||
// Build catalog entries
|
||||
List<AppCatalogEntry> catalog = new ArrayList<>();
|
||||
for (var entry : agentsByApp.entrySet()) {
|
||||
String appId = entry.getKey();
|
||||
List<AgentInfo> agents = entry.getValue();
|
||||
|
||||
// Routes
|
||||
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
|
||||
List<RouteSummary> routeSummaries = routeIds.stream()
|
||||
.map(routeId -> {
|
||||
String key = appId + "/" + routeId;
|
||||
long count = routeExchangeCounts.getOrDefault(key, 0L);
|
||||
Instant lastSeen = routeLastSeen.get(key);
|
||||
return new RouteSummary(routeId, count, lastSeen);
|
||||
})
|
||||
.toList();
|
||||
|
||||
// Agent summaries
|
||||
List<AgentSummary> agentSummaries = agents.stream()
|
||||
.map(a -> new AgentSummary(a.id(), a.name(), a.state().name().toLowerCase(), 0.0))
|
||||
.toList();
|
||||
|
||||
// Health = worst state among agents
|
||||
String health = computeWorstHealth(agents);
|
||||
|
||||
// Total exchange count for the app
|
||||
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
|
||||
|
||||
catalog.add(new AppCatalogEntry(appId, routeSummaries, agentSummaries,
|
||||
agents.size(), health, totalExchanges));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(catalog);
|
||||
}
|
||||
|
||||
private String computeWorstHealth(List<AgentInfo> agents) {
|
||||
boolean hasDead = false;
|
||||
boolean hasStale = false;
|
||||
for (AgentInfo a : agents) {
|
||||
if (a.state() == AgentState.DEAD) hasDead = true;
|
||||
if (a.state() == AgentState.STALE) hasStale = true;
|
||||
}
|
||||
if (hasDead) return "dead";
|
||||
if (hasStale) return "stale";
|
||||
return "live";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.RouteMetrics;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/routes")
|
||||
@Tag(name = "Route Metrics", description = "Route performance metrics")
|
||||
public class RouteMetricsController {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public RouteMetricsController(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@GetMapping("/metrics")
|
||||
@Operation(summary = "Get route metrics",
|
||||
description = "Returns aggregated performance metrics per route for the given time window")
|
||||
@ApiResponse(responseCode = "200", description = "Metrics returned")
|
||||
public ResponseEntity<List<RouteMetrics>> getMetrics(
|
||||
@RequestParam(required = false) String from,
|
||||
@RequestParam(required = false) String to,
|
||||
@RequestParam(required = false) String appId) {
|
||||
|
||||
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
|
||||
Instant fromInstant = from != null ? Instant.parse(from) : toInstant.minus(24, ChronoUnit.HOURS);
|
||||
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
|
||||
|
||||
var sql = new StringBuilder(
|
||||
"SELECT group_name, route_id, " +
|
||||
"SUM(total_count) AS total, " +
|
||||
"SUM(failed_count) AS failed, " +
|
||||
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_dur, " +
|
||||
"COALESCE(MAX(p99_duration), 0) AS p99_dur " +
|
||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ?");
|
||||
var params = new ArrayList<Object>();
|
||||
params.add(Timestamp.from(fromInstant));
|
||||
params.add(Timestamp.from(toInstant));
|
||||
|
||||
if (appId != null) {
|
||||
sql.append(" AND group_name = ?");
|
||||
params.add(appId);
|
||||
}
|
||||
sql.append(" GROUP BY group_name, route_id ORDER BY group_name, route_id");
|
||||
|
||||
// Key struct for sparkline lookup
|
||||
record RouteKey(String appId, String routeId) {}
|
||||
List<RouteKey> routeKeys = new ArrayList<>();
|
||||
|
||||
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
||||
String groupName = rs.getString("group_name");
|
||||
String routeId = rs.getString("route_id");
|
||||
long total = rs.getLong("total");
|
||||
long failed = rs.getLong("failed");
|
||||
double avgDur = rs.getDouble("avg_dur");
|
||||
double p99Dur = rs.getDouble("p99_dur");
|
||||
|
||||
double successRate = total > 0 ? (double) (total - failed) / total : 1.0;
|
||||
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||
double tps = windowSeconds > 0 ? (double) total / windowSeconds : 0.0;
|
||||
|
||||
routeKeys.add(new RouteKey(groupName, routeId));
|
||||
return new RouteMetrics(routeId, groupName, total, successRate,
|
||||
avgDur, p99Dur, errorRate, tps, List.of());
|
||||
}, params.toArray());
|
||||
|
||||
// Fetch sparklines (12 buckets over the time window)
|
||||
if (!metrics.isEmpty()) {
|
||||
int sparkBuckets = 12;
|
||||
long bucketSeconds = Math.max(windowSeconds / sparkBuckets, 60);
|
||||
|
||||
for (int i = 0; i < metrics.size(); i++) {
|
||||
RouteMetrics m = metrics.get(i);
|
||||
try {
|
||||
List<Double> sparkline = jdbc.query(
|
||||
"SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " +
|
||||
"COALESCE(SUM(total_count), 0) AS cnt " +
|
||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||
"AND group_name = ? AND route_id = ? " +
|
||||
"GROUP BY period ORDER BY period",
|
||||
(rs, rowNum) -> rs.getDouble("cnt"),
|
||||
bucketSeconds, Timestamp.from(fromInstant), Timestamp.from(toInstant),
|
||||
m.appId(), m.routeId());
|
||||
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
|
||||
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
|
||||
m.errorRate(), m.throughputPerSec(), sparkline));
|
||||
} catch (Exception e) {
|
||||
// Leave sparkline empty on error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(metrics);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.cameleer3.server.app.dto;
|
||||
|
||||
import com.cameleer3.server.core.agent.AgentEventRecord;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Schema(description = "Agent lifecycle event")
|
||||
public record AgentEventResponse(
|
||||
@NotNull long id,
|
||||
@NotNull String agentId,
|
||||
@NotNull String appId,
|
||||
@NotNull String eventType,
|
||||
String detail,
|
||||
@NotNull Instant timestamp
|
||||
) {
|
||||
public static AgentEventResponse from(AgentEventRecord record) {
|
||||
return new AgentEventResponse(
|
||||
record.id(), record.agentId(), record.appId(),
|
||||
record.eventType(), record.detail(), record.timestamp()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "Agent instance summary")
|
||||
@Schema(description = "Agent instance summary with runtime metrics")
|
||||
public record AgentInstanceResponse(
|
||||
@NotNull String id,
|
||||
@NotNull String name,
|
||||
@@ -15,13 +16,29 @@ public record AgentInstanceResponse(
|
||||
@NotNull String status,
|
||||
@NotNull List<String> routeIds,
|
||||
@NotNull Instant registeredAt,
|
||||
@NotNull Instant lastHeartbeat
|
||||
@NotNull Instant lastHeartbeat,
|
||||
double tps,
|
||||
double errorRate,
|
||||
int activeRoutes,
|
||||
int totalRoutes,
|
||||
long uptimeSeconds
|
||||
) {
|
||||
public static AgentInstanceResponse from(AgentInfo info) {
|
||||
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
|
||||
return new AgentInstanceResponse(
|
||||
info.id(), info.name(), info.group(),
|
||||
info.state().name(), info.routeIds(),
|
||||
info.registeredAt(), info.lastHeartbeat()
|
||||
info.registeredAt(), info.lastHeartbeat(),
|
||||
0.0, 0.0,
|
||||
0, info.routeIds() != null ? info.routeIds().size() : 0,
|
||||
uptime
|
||||
);
|
||||
}
|
||||
|
||||
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
|
||||
return new AgentInstanceResponse(
|
||||
id, name, group, status, routeIds, registeredAt, lastHeartbeat,
|
||||
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cameleer3.server.app.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
@Schema(description = "Summary of an agent instance for sidebar display")
|
||||
public record AgentSummary(
|
||||
@NotNull String id,
|
||||
@NotNull String name,
|
||||
@NotNull String status,
|
||||
@NotNull double tps
|
||||
) {}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cameleer3.server.app.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "Application catalog entry with routes and agents")
|
||||
public record AppCatalogEntry(
|
||||
@NotNull String appId,
|
||||
@NotNull List<RouteSummary> routes,
|
||||
@NotNull List<AgentSummary> agents,
|
||||
@NotNull int agentCount,
|
||||
@NotNull String health,
|
||||
@NotNull long exchangeCount
|
||||
) {}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.cameleer3.server.app.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "Aggregated route performance metrics")
|
||||
public record RouteMetrics(
|
||||
@NotNull String routeId,
|
||||
@NotNull String appId,
|
||||
@NotNull long exchangeCount,
|
||||
@NotNull double successRate,
|
||||
@NotNull double avgDurationMs,
|
||||
@NotNull double p99DurationMs,
|
||||
@NotNull double errorRate,
|
||||
@NotNull double throughputPerSec,
|
||||
@NotNull List<Double> sparkline
|
||||
) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cameleer3.server.app.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Schema(description = "Summary of a route within an application")
|
||||
public record RouteSummary(
|
||||
@NotNull String routeId,
|
||||
@NotNull long exchangeCount,
|
||||
Instant lastSeen
|
||||
) {}
|
||||
@@ -81,6 +81,8 @@ public class SecurityConfig {
|
||||
.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").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")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
|
||||
// Admin endpoints
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.cameleer3.server.app.storage;
|
||||
|
||||
import com.cameleer3.server.core.agent.AgentEventRecord;
|
||||
import com.cameleer3.server.core.agent.AgentEventRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class PostgresAgentEventRepository implements AgentEventRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public PostgresAgentEventRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void insert(String agentId, String appId, String eventType, String detail) {
|
||||
jdbc.update(
|
||||
"INSERT INTO agent_events (agent_id, app_id, event_type, detail) VALUES (?, ?, ?, ?)",
|
||||
agentId, appId, eventType, detail);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AgentEventRecord> query(String appId, String agentId, Instant from, Instant to, int limit) {
|
||||
var sql = new StringBuilder("SELECT id, agent_id, app_id, event_type, detail, timestamp FROM agent_events WHERE 1=1");
|
||||
var params = new ArrayList<Object>();
|
||||
|
||||
if (appId != null) {
|
||||
sql.append(" AND app_id = ?");
|
||||
params.add(appId);
|
||||
}
|
||||
if (agentId != null) {
|
||||
sql.append(" AND agent_id = ?");
|
||||
params.add(agentId);
|
||||
}
|
||||
if (from != null) {
|
||||
sql.append(" AND timestamp >= ?");
|
||||
params.add(Timestamp.from(from));
|
||||
}
|
||||
if (to != null) {
|
||||
sql.append(" AND timestamp < ?");
|
||||
params.add(Timestamp.from(to));
|
||||
}
|
||||
sql.append(" ORDER BY timestamp DESC LIMIT ?");
|
||||
params.add(limit);
|
||||
|
||||
return jdbc.query(sql.toString(), (rs, rowNum) -> new AgentEventRecord(
|
||||
rs.getLong("id"),
|
||||
rs.getString("agent_id"),
|
||||
rs.getString("app_id"),
|
||||
rs.getString("event_type"),
|
||||
rs.getString("detail"),
|
||||
rs.getTimestamp("timestamp").toInstant()
|
||||
), params.toArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Agent lifecycle events for tracking registration, state transitions, etc.
|
||||
CREATE TABLE agent_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
app_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_agent_events_agent ON agent_events(agent_id, timestamp DESC);
|
||||
CREATE INDEX idx_agent_events_app ON agent_events(app_id, timestamp DESC);
|
||||
CREATE INDEX idx_agent_events_time ON agent_events(timestamp DESC);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Retention: drop agent_metrics chunks older than 90 days
|
||||
SELECT add_retention_policy('agent_metrics', INTERVAL '90 days', if_not_exists => true);
|
||||
|
||||
-- Compression: compress agent_metrics chunks older than 7 days
|
||||
ALTER TABLE agent_metrics SET (timescaledb.compress);
|
||||
SELECT add_compression_policy('agent_metrics', INTERVAL '7 days', if_not_exists => true);
|
||||
Reference in New Issue
Block a user