diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index e63eed7c..11806285 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -120,6 +120,7 @@ jobs:
done
docker buildx build --platform linux/amd64 \
-f ui/Dockerfile \
+ --build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
$TAGS \
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache \
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache,mode=max \
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java
index 36d48205..ed5dda7b 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java
@@ -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.
*
* 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 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;
+ }
}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java
index f59e536f..f2732907 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java
@@ -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.
*
* 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);
+ }
}
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 2cdc9cc4..970045e8 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
@@ -31,7 +31,9 @@ public class OpenApiConfig {
"ExecutionSummary", "ExecutionDetail", "ExecutionStats",
"StatsTimeseries", "TimeseriesBucket",
"SearchResultExecutionSummary", "UserInfo",
- "ProcessorNode"
+ "ProcessorNode",
+ "AppCatalogEntry", "RouteSummary", "AgentSummary",
+ "RouteMetrics", "AgentEventResponse", "AgentInstanceResponse"
);
@Bean
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentEventsController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentEventsController.java
new file mode 100644
index 00000000..b0419bcf
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentEventsController.java
@@ -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> 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);
+ }
+}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java
index b0d81fd4..c0fb72eb 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java
@@ -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 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 response = agents.stream()
- .map(AgentInstanceResponse::from)
+ // Enrich with runtime metrics from continuous aggregates
+ Map agentMetrics = queryAgentMetrics();
+ final List finalAgents = agents;
+
+ List 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 queryAgentMetrics() {
+ Map 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;
+ }
}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteCatalogController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteCatalogController.java
new file mode 100644
index 00000000..b69c653a
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteCatalogController.java
@@ -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> getCatalog() {
+ List allAgents = registryService.findAll();
+
+ // Group agents by application (group name)
+ Map> agentsByApp = allAgents.stream()
+ .collect(Collectors.groupingBy(AgentInfo::group, LinkedHashMap::new, Collectors.toList()));
+
+ // Collect all distinct routes per app
+ Map> routesByApp = new LinkedHashMap<>();
+ for (var entry : agentsByApp.entrySet()) {
+ Set 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 routeExchangeCounts = new LinkedHashMap<>();
+ Map 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 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 catalog = new ArrayList<>();
+ for (var entry : agentsByApp.entrySet()) {
+ String appId = entry.getKey();
+ List agents = entry.getValue();
+
+ // Routes
+ Set routeIds = routesByApp.getOrDefault(appId, Set.of());
+ List 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 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 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";
+ }
+}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteMetricsController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteMetricsController.java
new file mode 100644
index 00000000..9c3d48fc
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteMetricsController.java
@@ -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> 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