chore: rename cameleer3 to cameleer
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

Rename Java packages from com.cameleer3 to com.cameleer, module
directories from cameleer3-* to cameleer-*, and all references
throughout workflows, Dockerfiles, docs, migrations, and pom.xml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-15 15:28:42 +02:00
parent 1077293343
commit cb3ebfea7c
569 changed files with 4356 additions and 3245 deletions

203
cameleer-server-app/pom.xml Normal file
View File

@@ -0,0 +1,203 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-server-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>cameleer-server-app</artifactId>
<name>Cameleer Server App</name>
<description>Spring Boot web app with REST controllers and SSE</description>
<dependencies>
<dependency>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-server-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.9.7</version>
<classifier>all</classifier>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.6</version>
</dependency>
<dependency>
<groupId>org.eclipse.elk</groupId>
<artifactId>org.eclipse.elk.core</artifactId>
<version>0.11.0</version>
</dependency>
<dependency>
<groupId>org.eclipse.elk</groupId>
<artifactId>org.eclipse.elk.alg.layered</artifactId>
<version>0.11.0</version>
</dependency>
<dependency>
<groupId>org.jfree</groupId>
<artifactId>org.jfree.svg</artifactId>
<version>5.0.7</version>
</dependency>
<dependency>
<groupId>org.eclipse.xtext</groupId>
<artifactId>org.eclipse.xtext.xbase.lib</artifactId>
<version>2.37.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.47</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>oauth2-oidc-sdk</artifactId>
<version>11.23.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-clickhouse</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-core</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-zerodep</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-ui-dist</id>
<phase>generate-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/classes/static</outputDirectory>
<resources>
<resource>
<directory>${project.basedir}/../ui/dist</directory>
<filtering>false</filtering>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<forkCount>1</forkCount>
<reuseForks>false</reuseForks>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<forkCount>1</forkCount>
<reuseForks>true</reuseForks>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,28 @@
package com.cameleer.server.app;
import com.cameleer.server.app.config.AgentRegistryConfig;
import com.cameleer.server.app.config.IngestionConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Main entry point for the Cameleer Server application.
* <p>
* Scans {@code com.cameleer.server.app} and {@code com.cameleer.server.core} packages.
*/
@SpringBootApplication(scanBasePackages = {
"com.cameleer.server.app",
"com.cameleer.server.core"
})
@EnableAsync
@EnableScheduling
@EnableConfigurationProperties({IngestionConfig.class, AgentRegistryConfig.class})
public class CameleerServerApplication {
public static void main(String[] args) {
SpringApplication.run(CameleerServerApplication.class, args);
}
}

View File

@@ -0,0 +1,75 @@
package com.cameleer.server.app.agent;
import com.cameleer.server.app.metrics.ServerMetrics;
import com.cameleer.server.core.agent.AgentEventService;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.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. Records lifecycle events for state transitions.
*/
@Component
public class AgentLifecycleMonitor {
private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class);
private final AgentRegistryService registryService;
private final AgentEventService agentEventService;
private final ServerMetrics serverMetrics;
public AgentLifecycleMonitor(AgentRegistryService registryService,
AgentEventService agentEventService,
ServerMetrics serverMetrics) {
this.registryService = registryService;
this.agentEventService = agentEventService;
this.serverMetrics = serverMetrics;
}
@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.instanceId(), agent.state());
}
registryService.checkLifecycle();
registryService.expireOldCommands();
// Detect transitions and record events
for (AgentInfo agent : registryService.findAll()) {
AgentState before = statesBefore.get(agent.instanceId());
if (before != null && before != agent.state()) {
String eventType = mapTransitionEvent(before, agent.state());
if (eventType != null) {
agentEventService.recordEvent(agent.instanceId(), agent.applicationId(), eventType,
agent.displayName() + " " + before + " -> " + agent.state());
serverMetrics.recordAgentTransition(eventType);
}
}
}
} 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;
}
}

View File

@@ -0,0 +1,178 @@
package com.cameleer.server.app.agent;
import com.cameleer.server.app.config.AgentRegistryConfig;
import com.cameleer.server.core.agent.AgentCommand;
import com.cameleer.server.core.agent.AgentEventListener;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Manages per-agent SSE connections and delivers commands via Server-Sent Events.
* <p>
* Implements {@link AgentEventListener} so the core {@link AgentRegistryService}
* can notify this component when a command is ready for delivery, without depending
* on Spring or SSE classes.
*/
@Component
public class SseConnectionManager implements AgentEventListener {
private static final Logger log = LoggerFactory.getLogger(SseConnectionManager.class);
private final ConcurrentHashMap<String, SseEmitter> emitters = new ConcurrentHashMap<>();
private final AgentRegistryService registryService;
private final AgentRegistryConfig config;
private final SsePayloadSigner ssePayloadSigner;
private final ObjectMapper objectMapper;
public SseConnectionManager(AgentRegistryService registryService, AgentRegistryConfig config,
SsePayloadSigner ssePayloadSigner, ObjectMapper objectMapper) {
this.registryService = registryService;
this.config = config;
this.ssePayloadSigner = ssePayloadSigner;
this.objectMapper = objectMapper;
}
@PostConstruct
void init() {
registryService.setEventListener(this);
log.info("SseConnectionManager registered as AgentEventListener");
}
/**
* Create an SSE connection for the given agent.
* Replaces any existing connection (completing the old emitter).
*
* @param agentId the agent identifier
* @return the new SseEmitter
*/
public SseEmitter connect(String agentId) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
SseEmitter old = emitters.put(agentId, emitter);
if (old != null) {
log.debug("Replacing existing SSE connection for agent {}", agentId);
old.complete();
}
// Remove from map only if the emitter is still the current one (reference equality)
emitter.onCompletion(() -> {
emitters.remove(agentId, emitter);
log.debug("SSE connection completed for agent {}", agentId);
});
emitter.onTimeout(() -> {
emitters.remove(agentId, emitter);
log.debug("SSE connection timed out for agent {}", agentId);
});
emitter.onError(ex -> {
emitters.remove(agentId, emitter);
log.debug("SSE connection error for agent {}: {}", agentId, ex.getMessage());
});
log.info("SSE connection established for agent {}", agentId);
return emitter;
}
/**
* Send an event to a specific agent's SSE stream.
*
* @param agentId the target agent
* @param eventId the event ID (for Last-Event-ID reconnection)
* @param eventType the SSE event name
* @param data the event data (serialized as JSON)
* @return true if the event was sent successfully, false if the agent is not connected or send failed
*/
public boolean sendEvent(String agentId, String eventId, String eventType, Object data) {
SseEmitter emitter = emitters.get(agentId);
if (emitter == null) {
return false;
}
try {
emitter.send(SseEmitter.event()
.id(eventId)
.name(eventType)
.data(data, MediaType.APPLICATION_JSON));
return true;
} catch (IOException e) {
log.debug("Failed to send SSE event to agent {}: {}", agentId, e.getMessage());
emitters.remove(agentId, emitter);
return false;
}
}
/**
* Send a ping keepalive comment to all connected agents.
*/
public void sendPingToAll() {
for (Map.Entry<String, SseEmitter> entry : emitters.entrySet()) {
String agentId = entry.getKey();
SseEmitter emitter = entry.getValue();
try {
emitter.send(SseEmitter.event().comment("ping"));
} catch (IOException e) {
log.debug("Ping failed for agent {}, removing connection", agentId);
emitters.remove(agentId, emitter);
}
}
}
/**
* Check if an agent has an active SSE connection.
*/
public int getConnectionCount() {
return emitters.size();
}
public boolean isConnected(String agentId) {
return emitters.containsKey(agentId);
}
/**
* Called by the registry when a command is ready for an agent.
* Attempts to deliver via SSE; if successful, marks as DELIVERED.
* If the agent is not connected, the command stays PENDING.
*/
@Override
public void onCommandReady(String agentId, AgentCommand command) {
String eventType = command.type().name().toLowerCase().replace('_', '-');
String signedPayload = ssePayloadSigner.signPayload(command.payload());
// Parse to JsonNode so SseEmitter serializes the tree correctly (avoids double-quoting a raw string)
Object data;
try {
data = objectMapper.readTree(signedPayload);
} catch (Exception e) {
log.warn("Failed to parse signed payload as JSON, sending raw string", e);
data = signedPayload;
}
boolean sent = sendEvent(agentId, command.id(), eventType, data);
if (sent) {
registryService.markDelivered(agentId, command.id());
log.debug("Command {} ({}) delivered to agent {} via SSE", command.id(), eventType, agentId);
} else {
log.debug("Agent {} not connected, command {} stays PENDING", agentId, command.id());
}
}
/**
* Scheduled ping keepalive to all connected agents.
*/
@Scheduled(fixedDelayString = "${agent-registry.ping-interval-ms:15000}")
void pingAll() {
if (!emitters.isEmpty()) {
sendPingToAll();
}
}
}

View File

@@ -0,0 +1,77 @@
package com.cameleer.server.app.agent;
import com.cameleer.server.core.security.Ed25519SigningService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* Signs SSE command payloads with Ed25519 before delivery.
* <p>
* The signature is computed over the original JSON payload string (without the
* signature field). The resulting Base64-encoded signature is added as a
* {@code "signature"} field to the JSON before returning.
* <p>
* Agents verify the signature by:
* <ol>
* <li>Extracting and removing the {@code "signature"} field from the received JSON</li>
* <li>Serializing the remaining fields back to a JSON string</li>
* <li>Verifying the signature against that string using the server's Ed25519 public key</li>
* </ol>
* In practice, agents should verify against the original payload — the signature is
* computed over the exact JSON string as received by the server.
*/
@Component
public class SsePayloadSigner {
private static final Logger log = LoggerFactory.getLogger(SsePayloadSigner.class);
private final Ed25519SigningService ed25519SigningService;
private final ObjectMapper objectMapper;
public SsePayloadSigner(Ed25519SigningService ed25519SigningService, ObjectMapper objectMapper) {
this.ed25519SigningService = ed25519SigningService;
this.objectMapper = objectMapper;
}
/**
* Signs the given JSON payload and returns a new JSON string with a {@code "signature"} field added.
* <p>
* The signature is computed over the original payload string (before adding the signature field).
*
* @param jsonPayload the JSON string to sign
* @return the signed JSON string with a "signature" field, or the original payload if null/empty/blank
*/
public String signPayload(String jsonPayload) {
if (jsonPayload == null) {
log.warn("Attempted to sign null payload, returning null");
return null;
}
if (jsonPayload.isEmpty() || jsonPayload.isBlank()) {
log.warn("Attempted to sign empty/blank payload, returning as-is");
return jsonPayload;
}
try {
// 1. Sign the original payload string
String signatureBase64 = ed25519SigningService.sign(jsonPayload);
// 2. Parse payload, add signature field, serialize back
JsonNode node = objectMapper.readTree(jsonPayload);
if (node instanceof ObjectNode objectNode) {
objectNode.put("signature", signatureBase64);
return objectMapper.writeValueAsString(objectNode);
} else {
// Payload is not a JSON object (e.g., array or primitive) -- cannot add field
log.warn("Payload is not a JSON object, returning unsigned: {}", jsonPayload);
return jsonPayload;
}
} catch (Exception e) {
log.error("Failed to sign payload, returning unsigned", e);
return jsonPayload;
}
}
}

View File

@@ -0,0 +1,26 @@
package com.cameleer.server.app.analytics;
import com.cameleer.server.app.storage.ClickHouseUsageTracker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
public class UsageFlushScheduler {
private static final Logger log = LoggerFactory.getLogger(UsageFlushScheduler.class);
private final ClickHouseUsageTracker tracker;
public UsageFlushScheduler(ClickHouseUsageTracker tracker) {
this.tracker = tracker;
}
@Scheduled(fixedDelayString = "${cameleer.usage.flush-interval-ms:5000}")
public void flush() {
try {
tracker.flush();
} catch (Exception e) {
log.warn("Usage event flush failed: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,88 @@
package com.cameleer.server.app.analytics;
import com.cameleer.server.core.analytics.UsageEvent;
import com.cameleer.server.core.analytics.UsageTracker;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.servlet.HandlerInterceptor;
import java.time.Instant;
import java.util.regex.Pattern;
/**
* Tracks authenticated UI user requests for usage analytics.
* Skips agent requests, health checks, data ingestion, and static assets.
*/
public class UsageTrackingInterceptor implements HandlerInterceptor {
private static final String START_ATTR = "usage.startNanos";
// Patterns for normalizing dynamic path segments
private static final Pattern EXCHANGE_ID = Pattern.compile(
"/[A-F0-9]{15,}-[A-F0-9]{16}(?=/|$)", Pattern.CASE_INSENSITIVE);
private static final Pattern UUID = Pattern.compile(
"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=/|$)", Pattern.CASE_INSENSITIVE);
private static final Pattern HEX_HASH = Pattern.compile(
"/[0-9a-f]{32,64}(?=/|$)", Pattern.CASE_INSENSITIVE);
private static final Pattern NUMERIC_ID = Pattern.compile(
"(?<=/)(\\d{2,})(?=/|$)");
// Agent instance IDs like "cameleer-sample-598867949d-g7nt4-1"
private static final Pattern INSTANCE_ID = Pattern.compile(
"(?<=/agents/)[^/]+(?=/)", Pattern.CASE_INSENSITIVE);
private final UsageTracker usageTracker;
public UsageTrackingInterceptor(UsageTracker usageTracker) {
this.usageTracker = usageTracker;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
request.setAttribute(START_ATTR, System.nanoTime());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
String username = extractUsername();
if (username == null) return; // unauthenticated or agent request
Long startNanos = (Long) request.getAttribute(START_ATTR);
long durationMs = startNanos != null ? (System.nanoTime() - startNanos) / 1_000_000 : 0;
String path = request.getRequestURI();
String queryString = request.getQueryString();
usageTracker.track(new UsageEvent(
Instant.now(),
username,
request.getMethod(),
path,
normalizePath(path),
response.getStatus(),
durationMs,
queryString
));
}
private String extractUsername() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getName() == null) return null;
String name = auth.getName();
// Only track UI users (user:admin), not agents
if (!name.startsWith("user:")) return null;
return name;
}
static String normalizePath(String path) {
String normalized = EXCHANGE_ID.matcher(path).replaceAll("/{id}");
normalized = UUID.matcher(normalized).replaceAll("/{id}");
normalized = HEX_HASH.matcher(normalized).replaceAll("/{hash}");
normalized = INSTANCE_ID.matcher(normalized).replaceAll("{id}");
normalized = NUMERIC_ID.matcher(normalized).replaceAll("{id}");
return normalized;
}
}

View File

@@ -0,0 +1,37 @@
package com.cameleer.server.app.config;
import com.cameleer.server.core.agent.AgentEventRepository;
import com.cameleer.server.core.agent.AgentEventService;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.RouteStateRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Creates the {@link AgentRegistryService}, {@link AgentEventService},
* and {@link RouteStateRegistry} beans.
* <p>
* Follows the established pattern: core module plain class, app module bean config.
*/
@Configuration
public class AgentRegistryBeanConfig {
@Bean
public AgentRegistryService agentRegistryService(AgentRegistryConfig config) {
return new AgentRegistryService(
config.getStaleThresholdMs(),
config.getDeadThresholdMs(),
config.getCommandExpiryMs()
);
}
@Bean
public AgentEventService agentEventService(AgentEventRepository repository) {
return new AgentEventService(repository);
}
@Bean
public RouteStateRegistry routeStateRegistry() {
return new RouteStateRegistry();
}
}

View File

@@ -0,0 +1,68 @@
package com.cameleer.server.app.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Configuration properties for the agent registry.
* Bound from the {@code cameleer.server.agentregistry.*} namespace in application.yml.
* <p>
* Registered via {@code @EnableConfigurationProperties} on the application class.
*/
@ConfigurationProperties(prefix = "cameleer.server.agentregistry")
public class AgentRegistryConfig {
private long heartbeatIntervalMs = 30_000;
private long staleThresholdMs = 90_000;
private long deadThresholdMs = 300_000;
private long pingIntervalMs = 15_000;
private long commandExpiryMs = 60_000;
private long lifecycleCheckIntervalMs = 10_000;
public long getHeartbeatIntervalMs() {
return heartbeatIntervalMs;
}
public void setHeartbeatIntervalMs(long heartbeatIntervalMs) {
this.heartbeatIntervalMs = heartbeatIntervalMs;
}
public long getStaleThresholdMs() {
return staleThresholdMs;
}
public void setStaleThresholdMs(long staleThresholdMs) {
this.staleThresholdMs = staleThresholdMs;
}
public long getDeadThresholdMs() {
return deadThresholdMs;
}
public void setDeadThresholdMs(long deadThresholdMs) {
this.deadThresholdMs = deadThresholdMs;
}
public long getPingIntervalMs() {
return pingIntervalMs;
}
public void setPingIntervalMs(long pingIntervalMs) {
this.pingIntervalMs = pingIntervalMs;
}
public long getCommandExpiryMs() {
return commandExpiryMs;
}
public void setCommandExpiryMs(long commandExpiryMs) {
this.commandExpiryMs = commandExpiryMs;
}
public long getLifecycleCheckIntervalMs() {
return lifecycleCheckIntervalMs;
}
public void setLifecycleCheckIntervalMs(long lifecycleCheckIntervalMs) {
this.lifecycleCheckIntervalMs = lifecycleCheckIntervalMs;
}
}

View File

@@ -0,0 +1,52 @@
package com.cameleer.server.app.config;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
@Configuration
@EnableConfigurationProperties(ClickHouseProperties.class)
public class ClickHouseConfig {
/**
* Explicit primary PG DataSource. Required because adding a second DataSource
* (ClickHouse) prevents Spring Boot auto-configuration from creating the default one.
*/
@Bean
@Primary
public DataSource dataSource(DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().build();
}
@Bean
@Primary
public JdbcTemplate jdbcTemplate(@Qualifier("dataSource") DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
@Bean(name = "clickHouseDataSource")
public DataSource clickHouseDataSource(ClickHouseProperties props) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(props.getUrl());
ds.setUsername(props.getUsername());
ds.setPassword(props.getPassword());
ds.setMaximumPoolSize(props.getPoolSize());
ds.setMinimumIdle(5);
ds.setConnectionTimeout(5000);
ds.setPoolName("clickhouse-pool");
return ds;
}
@Bean(name = "clickHouseJdbcTemplate")
public JdbcTemplate clickHouseJdbcTemplate(
@Qualifier("clickHouseDataSource") DataSource ds) {
return new JdbcTemplate(ds);
}
}

View File

@@ -0,0 +1,24 @@
package com.cameleer.server.app.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "cameleer.server.clickhouse")
public class ClickHouseProperties {
private String url = "jdbc:clickhouse://localhost:8123/cameleer";
private String username = "default";
private String password = "";
private int poolSize = 50;
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public int getPoolSize() { return poolSize; }
public void setPoolSize(int poolSize) { this.poolSize = poolSize; }
}

View File

@@ -0,0 +1,53 @@
package com.cameleer.server.app.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
@Component
public class ClickHouseSchemaInitializer {
private static final Logger log = LoggerFactory.getLogger(ClickHouseSchemaInitializer.class);
private final JdbcTemplate clickHouseJdbc;
public ClickHouseSchemaInitializer(
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
this.clickHouseJdbc = clickHouseJdbc;
}
@EventListener(ApplicationReadyEvent.class)
public void initializeSchema() {
try {
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource script = resolver.getResource("classpath:clickhouse/init.sql");
String sql = script.getContentAsString(StandardCharsets.UTF_8);
log.info("Executing ClickHouse schema: {}", script.getFilename());
for (String statement : sql.split(";")) {
String trimmed = statement.trim();
// Skip empty segments and comment-only segments
String withoutComments = trimmed.lines()
.filter(line -> !line.stripLeading().startsWith("--"))
.map(String::trim)
.filter(line -> !line.isEmpty())
.reduce("", (a, b) -> a + b);
if (!withoutComments.isEmpty()) {
clickHouseJdbc.execute(trimmed);
}
}
log.info("ClickHouse schema initialization complete");
} catch (Exception e) {
log.error("ClickHouse schema initialization failed — server will continue but ClickHouse features may not work", e);
}
}
}

View File

@@ -0,0 +1,18 @@
package com.cameleer.server.app.config;
import com.cameleer.server.app.diagram.ElkDiagramRenderer;
import com.cameleer.server.core.diagram.DiagramRenderer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Creates beans for diagram rendering.
*/
@Configuration
public class DiagramBeanConfig {
@Bean
public DiagramRenderer diagramRenderer() {
return new ElkDiagramRenderer();
}
}

View File

@@ -0,0 +1,40 @@
package com.cameleer.server.app.config;
import com.cameleer.server.core.ingestion.BufferedLogEntry;
import com.cameleer.server.core.ingestion.ChunkAccumulator;
import com.cameleer.server.core.ingestion.MergedExecution;
import com.cameleer.server.core.ingestion.WriteBuffer;
import com.cameleer.server.core.storage.model.MetricsSnapshot;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Creates write buffer beans for the ingestion pipeline.
* <p>
* Each {@link WriteBuffer} instance is shared between the
* {@link com.cameleer.server.core.ingestion.IngestionService} (producer side)
* and the flush scheduler (consumer side).
*/
@Configuration
public class IngestionBeanConfig {
@Bean
public WriteBuffer<MetricsSnapshot> metricsBuffer(IngestionConfig config) {
return new WriteBuffer<>(config.getBufferCapacity());
}
@Bean
public WriteBuffer<MergedExecution> executionBuffer(IngestionConfig config) {
return new WriteBuffer<>(config.getBufferCapacity());
}
@Bean
public WriteBuffer<ChunkAccumulator.ProcessorBatch> processorBatchBuffer(IngestionConfig config) {
return new WriteBuffer<>(config.getBufferCapacity());
}
@Bean
public WriteBuffer<BufferedLogEntry> logBuffer(IngestionConfig config) {
return new WriteBuffer<>(config.getBufferCapacity());
}
}

View File

@@ -0,0 +1,41 @@
package com.cameleer.server.app.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Configuration properties for the ingestion write buffer.
* Bound from the {@code cameleer.server.ingestion.*} namespace in application.yml.
* <p>
* Registered via {@code @EnableConfigurationProperties} on the application class.
*/
@ConfigurationProperties(prefix = "cameleer.server.ingestion")
public class IngestionConfig {
private int bufferCapacity = 50_000;
private int batchSize = 100;
private long flushIntervalMs = 1_000;
public int getBufferCapacity() {
return bufferCapacity;
}
public void setBufferCapacity(int bufferCapacity) {
this.bufferCapacity = bufferCapacity;
}
public int getBatchSize() {
return batchSize;
}
public void setBatchSize(int batchSize) {
this.batchSize = batchSize;
}
public long getFlushIntervalMs() {
return flushIntervalMs;
}
public void setFlushIntervalMs(long flushIntervalMs) {
this.flushIntervalMs = flushIntervalMs;
}
}

View File

@@ -0,0 +1,68 @@
package com.cameleer.server.app.config;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Files;
import java.nio.file.Path;
@Configuration
public class LicenseBeanConfig {
private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class);
@Value("${cameleer.server.license.token:}")
private String licenseToken;
@Value("${cameleer.server.license.file:}")
private String licenseFile;
@Value("${cameleer.server.license.publickey:}")
private String licensePublicKey;
@Bean
public LicenseGate licenseGate() {
LicenseGate gate = new LicenseGate();
String token = resolveLicenseToken();
if (token == null || token.isBlank()) {
log.info("No license configured — running in open mode (all features enabled)");
return gate;
}
if (licensePublicKey == null || licensePublicKey.isBlank()) {
log.warn("License token provided but no public key configured (CAMELEER_SERVER_LICENSE_PUBLICKEY). Running in open mode.");
return gate;
}
try {
LicenseValidator validator = new LicenseValidator(licensePublicKey);
LicenseInfo info = validator.validate(token);
gate.load(info);
} catch (Exception e) {
log.error("Failed to validate license: {}. Running in open mode.", e.getMessage());
}
return gate;
}
private String resolveLicenseToken() {
if (licenseToken != null && !licenseToken.isBlank()) {
return licenseToken;
}
if (licenseFile != null && !licenseFile.isBlank()) {
try {
return Files.readString(Path.of(licenseFile)).trim();
} catch (Exception e) {
log.warn("Failed to read license file {}: {}", licenseFile, e.getMessage());
}
}
return null;
}
}

View File

@@ -0,0 +1,94 @@
package com.cameleer.server.app.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Paths;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.media.ArraySchema;
import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.servers.Server;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@Configuration
@SecurityScheme(name = "bearer", type = SecuritySchemeType.HTTP,
scheme = "bearer", bearerFormat = "JWT")
public class OpenApiConfig {
/**
* Core domain models that always have all fields populated.
* Mark all their properties as required so the generated TypeScript
* types are non-optional.
*/
private static final Set<String> ALL_FIELDS_REQUIRED = Set.of(
"ExecutionSummary", "ExecutionDetail", "ExecutionStats",
"StatsTimeseries", "TimeseriesBucket",
"SearchResultExecutionSummary", "UserInfo",
"ProcessorNode",
"AppCatalogEntry", "RouteSummary", "AgentSummary",
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse",
"ProcessorMetrics", "AgentMetricsResponse", "MetricBucket"
);
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info().title("Cameleer Server API").version("1.0"))
.addSecurityItem(new SecurityRequirement().addList("bearer"))
.servers(List.of(new Server().url("/api/v1").description("Relative")));
}
@Bean
public OpenApiCustomizer pathPrefixStripper() {
return openApi -> {
var original = openApi.getPaths();
if (original == null) return;
String prefix = "/api/v1";
var stripped = new Paths();
for (var entry : original.entrySet()) {
String path = entry.getKey();
stripped.addPathItem(
path.startsWith(prefix) ? path.substring(prefix.length()) : path,
entry.getValue());
}
openApi.setPaths(stripped);
};
}
@Bean
@SuppressWarnings("unchecked")
public OpenApiCustomizer schemaCustomizer() {
return openApi -> {
var schemas = openApi.getComponents().getSchemas();
if (schemas == null) return;
// Add children to ProcessorNode if missing (recursive self-reference)
if (schemas.containsKey("ProcessorNode")) {
Schema<Object> processorNode = schemas.get("ProcessorNode");
if (processorNode.getProperties() != null
&& !processorNode.getProperties().containsKey("children")) {
Schema<?> selfRef = new Schema<>().$ref("#/components/schemas/ProcessorNode");
ArraySchema childrenArray = new ArraySchema().items(selfRef);
processorNode.addProperty("children", childrenArray);
}
}
// Mark all fields as required for core domain models
for (String schemaName : ALL_FIELDS_REQUIRED) {
if (schemas.containsKey(schemaName)) {
Schema<Object> schema = schemas.get(schemaName);
if (schema.getProperties() != null) {
schema.setRequired(new ArrayList<>(schema.getProperties().keySet()));
}
}
}
};
}
}

View File

@@ -0,0 +1,27 @@
package com.cameleer.server.app.config;
import com.cameleer.server.app.storage.PostgresClaimMappingRepository;
import com.cameleer.server.core.rbac.ClaimMappingRepository;
import com.cameleer.server.core.rbac.ClaimMappingService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* Creates the {@link ClaimMappingRepository} and {@link ClaimMappingService} beans.
* <p>
* Follows the established pattern: core module plain class, app module bean config.
*/
@Configuration
public class RbacBeanConfig {
@Bean
public ClaimMappingRepository claimMappingRepository(JdbcTemplate jdbcTemplate) {
return new PostgresClaimMappingRepository(jdbcTemplate);
}
@Bean
public ClaimMappingService claimMappingService() {
return new ClaimMappingService();
}
}

View File

@@ -0,0 +1,77 @@
package com.cameleer.server.app.config;
import com.cameleer.server.app.storage.PostgresAppRepository;
import com.cameleer.server.app.storage.PostgresAppVersionRepository;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.app.storage.PostgresEnvironmentRepository;
import com.cameleer.server.core.runtime.AppRepository;
import com.cameleer.server.core.runtime.AppService;
import com.cameleer.server.core.runtime.AppVersionRepository;
import com.cameleer.server.core.runtime.DeploymentRepository;
import com.cameleer.server.core.runtime.DeploymentService;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import com.cameleer.server.core.runtime.EnvironmentService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* Creates runtime management beans: repositories, services, and async executor.
* <p>
* Follows the established pattern: core module plain class, app module bean config.
*/
@Configuration
public class RuntimeBeanConfig {
@Bean
public EnvironmentRepository environmentRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
return new PostgresEnvironmentRepository(jdbc, objectMapper);
}
@Bean
public AppRepository appRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
return new PostgresAppRepository(jdbc, objectMapper);
}
@Bean
public AppVersionRepository appVersionRepository(JdbcTemplate jdbc) {
return new PostgresAppVersionRepository(jdbc);
}
@Bean
public DeploymentRepository deploymentRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
return new PostgresDeploymentRepository(jdbc, objectMapper);
}
@Bean
public EnvironmentService environmentService(EnvironmentRepository repo) {
return new EnvironmentService(repo);
}
@Bean
public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo,
@Value("${cameleer.server.runtime.jarstoragepath:/data/jars}") String jarStoragePath) {
return new AppService(appRepo, versionRepo, jarStoragePath);
}
@Bean
public DeploymentService deploymentService(DeploymentRepository deployRepo, AppService appService, EnvironmentService envService) {
return new DeploymentService(deployRepo, appService, envService);
}
@Bean(name = "deploymentTaskExecutor")
public Executor deploymentTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("deploy-");
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1,19 @@
package com.cameleer.server.app.config;
import com.cameleer.server.core.search.SearchService;
import com.cameleer.server.core.storage.SearchIndex;
import com.cameleer.server.core.storage.StatsStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Creates beans for the search layer.
*/
@Configuration
public class SearchBeanConfig {
@Bean
public SearchService searchService(SearchIndex searchIndex, StatsStore statsStore) {
return new SearchService(searchIndex, statsStore);
}
}

View File

@@ -0,0 +1,20 @@
package com.cameleer.server.app.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class ServerCapabilitiesHealthIndicator implements HealthIndicator {
@Value("${cameleer.server.security.infrastructureendpoints:true}")
private boolean infrastructureEndpoints;
@Override
public Health health() {
return Health.up()
.withDetail("infrastructureEndpoints", infrastructureEndpoints)
.build();
}
}

View File

@@ -0,0 +1,187 @@
package com.cameleer.server.app.config;
import com.cameleer.server.app.metrics.ServerMetrics;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.storage.ClickHouseAgentEventRepository;
import com.cameleer.server.app.storage.ClickHouseUsageTracker;
import com.cameleer.server.app.storage.ClickHouseDiagramStore;
import com.cameleer.server.app.storage.ClickHouseMetricsQueryStore;
import com.cameleer.server.app.storage.ClickHouseMetricsStore;
import com.cameleer.server.app.storage.ClickHouseStatsStore;
import com.cameleer.server.core.admin.AuditRepository;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.agent.AgentEventRepository;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.detail.DetailService;
import com.cameleer.server.core.indexing.SearchIndexer;
import com.cameleer.server.app.ingestion.ExecutionFlushScheduler;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.app.storage.ClickHouseExecutionStore;
import com.cameleer.server.core.ingestion.BufferedLogEntry;
import com.cameleer.server.core.ingestion.ChunkAccumulator;
import com.cameleer.server.core.ingestion.IngestionService;
import com.cameleer.server.core.ingestion.MergedExecution;
import com.cameleer.server.core.ingestion.WriteBuffer;
import com.cameleer.server.core.storage.*;
import com.cameleer.server.core.storage.LogIndex;
import com.cameleer.server.core.storage.StatsStore;
import com.cameleer.server.core.storage.model.MetricsSnapshot;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class StorageBeanConfig {
@Bean
public DetailService detailService(ExecutionStore executionStore) {
return new DetailService(executionStore);
}
@Bean(destroyMethod = "shutdown")
public SearchIndexer searchIndexer(ExecutionStore executionStore, SearchIndex searchIndex,
@Value("${cameleer.server.indexer.debouncems:2000}") long debounceMs,
@Value("${cameleer.server.indexer.queuesize:10000}") int queueSize) {
return new SearchIndexer(executionStore, searchIndex, debounceMs, queueSize);
}
@Bean
public AuditService auditService(AuditRepository auditRepository) {
return new AuditService(auditRepository);
}
@Bean
public IngestionService ingestionService(ExecutionStore executionStore,
DiagramStore diagramStore,
WriteBuffer<MetricsSnapshot> metricsBuffer,
SearchIndexer searchIndexer,
@Value("${cameleer.server.ingestion.bodysizelimit:16384}") int bodySizeLimit) {
return new IngestionService(executionStore, diagramStore, metricsBuffer,
searchIndexer::onExecutionUpdated, bodySizeLimit);
}
@Bean
public MetricsStore clickHouseMetricsStore(
TenantProperties tenantProperties,
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
return new ClickHouseMetricsStore(tenantProperties.getId(), clickHouseJdbc);
}
@Bean
public MetricsQueryStore clickHouseMetricsQueryStore(
TenantProperties tenantProperties,
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
return new ClickHouseMetricsQueryStore(tenantProperties.getId(), clickHouseJdbc);
}
// ── Execution Store ──────────────────────────────────────────────────
@Bean
public ClickHouseExecutionStore clickHouseExecutionStore(
TenantProperties tenantProperties,
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
return new ClickHouseExecutionStore(tenantProperties.getId(), clickHouseJdbc);
}
@Bean
public ChunkAccumulator chunkAccumulator(
TenantProperties tenantProperties,
WriteBuffer<MergedExecution> executionBuffer,
WriteBuffer<ChunkAccumulator.ProcessorBatch> processorBatchBuffer,
DiagramStore diagramStore,
AgentRegistryService registryService) {
return new ChunkAccumulator(
tenantProperties.getId(),
executionBuffer::offerOrWarn,
processorBatchBuffer::offerOrWarn,
diagramStore,
java.time.Duration.ofMinutes(5),
instanceId -> {
AgentInfo agent = registryService.findById(instanceId);
return agent != null && agent.environmentId() != null
? agent.environmentId() : "default";
});
}
@Bean
public ExecutionFlushScheduler executionFlushScheduler(
WriteBuffer<MergedExecution> executionBuffer,
WriteBuffer<ChunkAccumulator.ProcessorBatch> processorBatchBuffer,
WriteBuffer<BufferedLogEntry> logBuffer,
ClickHouseExecutionStore executionStore,
ClickHouseLogStore logStore,
ChunkAccumulator accumulator,
IngestionConfig config,
ServerMetrics serverMetrics) {
return new ExecutionFlushScheduler(executionBuffer, processorBatchBuffer,
logBuffer, executionStore, logStore, accumulator, config, serverMetrics);
}
@Bean
public SearchIndex clickHouseSearchIndex(
TenantProperties tenantProperties,
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
return new ClickHouseSearchIndex(tenantProperties.getId(), clickHouseJdbc);
}
// ── ClickHouse Stats Store ─────────────────────────────────────────
@Bean
public StatsStore clickHouseStatsStore(
TenantProperties tenantProperties,
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
return new ClickHouseStatsStore(tenantProperties.getId(), clickHouseJdbc);
}
// ── ClickHouse Diagram Store ──────────────────────────────────────
@Bean
public DiagramStore clickHouseDiagramStore(
TenantProperties tenantProperties,
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
return new ClickHouseDiagramStore(tenantProperties.getId(), clickHouseJdbc);
}
// ── ClickHouse Agent Event Repository ─────────────────────────────
@Bean
public AgentEventRepository clickHouseAgentEventRepository(
TenantProperties tenantProperties,
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
return new ClickHouseAgentEventRepository(tenantProperties.getId(), clickHouseJdbc);
}
// ── ClickHouse Log Store ──────────────────────────────────────────
@Bean
public ClickHouseLogStore clickHouseLogStore(
TenantProperties tenantProperties,
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
return new ClickHouseLogStore(tenantProperties.getId(), clickHouseJdbc);
}
// ── Usage Analytics ──────────────────────────────────────────────
@Bean
public ClickHouseUsageTracker clickHouseUsageTracker(
TenantProperties tenantProperties,
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
return new ClickHouseUsageTracker(tenantProperties.getId(), clickHouseJdbc,
new com.cameleer.server.core.ingestion.WriteBuffer<>(5000));
}
@Bean
public com.cameleer.server.app.analytics.UsageTrackingInterceptor usageTrackingInterceptor(
ClickHouseUsageTracker usageTracker) {
return new com.cameleer.server.app.analytics.UsageTrackingInterceptor(usageTracker);
}
@Bean
public com.cameleer.server.app.analytics.UsageFlushScheduler usageFlushScheduler(
ClickHouseUsageTracker usageTracker) {
return new com.cameleer.server.app.analytics.UsageFlushScheduler(usageTracker);
}
}

View File

@@ -0,0 +1,19 @@
package com.cameleer.server.app.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "cameleer.server.tenant")
public class TenantProperties {
private String id = "default";
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}

View File

@@ -0,0 +1,63 @@
package com.cameleer.server.app.config;
import com.cameleer.server.app.analytics.UsageTrackingInterceptor;
import com.cameleer.server.app.interceptor.AuditInterceptor;
import com.cameleer.server.app.interceptor.ProtocolVersionInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC configuration.
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final ProtocolVersionInterceptor protocolVersionInterceptor;
private final AuditInterceptor auditInterceptor;
private final UsageTrackingInterceptor usageTrackingInterceptor;
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor,
AuditInterceptor auditInterceptor,
@org.springframework.lang.Nullable UsageTrackingInterceptor usageTrackingInterceptor) {
this.protocolVersionInterceptor = protocolVersionInterceptor;
this.auditInterceptor = auditInterceptor;
this.usageTrackingInterceptor = usageTrackingInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(protocolVersionInterceptor)
.addPathPatterns("/api/v1/data/**", "/api/v1/agents/**")
.excludePathPatterns(
"/api/v1/health",
"/api/v1/api-docs/**",
"/api/v1/swagger-ui/**",
"/api/v1/swagger-ui.html",
"/api/v1/agents/*/events",
"/api/v1/agents/register",
"/api/v1/agents/*/refresh"
);
// Usage analytics: tracks authenticated UI user requests
if (usageTrackingInterceptor != null) {
registry.addInterceptor(usageTrackingInterceptor)
.addPathPatterns("/api/v1/**")
.excludePathPatterns(
"/api/v1/data/**",
"/api/v1/agents/*/heartbeat",
"/api/v1/agents/*/events",
"/api/v1/health"
);
}
// Safety-net audit: catches any unaudited POST/PUT/DELETE
registry.addInterceptor(auditInterceptor)
.addPathPatterns("/api/v1/**")
.excludePathPatterns(
"/api/v1/data/**",
"/api/v1/agents/*/heartbeat",
"/api/v1/health"
);
}
}

View File

@@ -0,0 +1,315 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.agent.SseConnectionManager;
import com.cameleer.server.app.dto.CommandAckRequest;
import com.cameleer.server.app.dto.CommandBroadcastResponse;
import com.cameleer.server.app.dto.CommandGroupResponse;
import com.cameleer.server.app.dto.CommandRequest;
import com.cameleer.server.app.dto.CommandSingleResponse;
import com.cameleer.server.app.dto.ReplayRequest;
import com.cameleer.server.app.dto.ReplayResponse;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.agent.AgentCommand;
import com.cameleer.server.core.agent.AgentEventService;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.AgentState;
import com.cameleer.server.core.agent.CommandReply;
import com.cameleer.server.core.agent.CommandType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* Command push endpoints for sending commands to agents via SSE.
* <p>
* Supports three targeting levels:
* <ul>
* <li>Single agent: POST /api/v1/agents/{id}/commands</li>
* <li>Group: POST /api/v1/agents/groups/{group}/commands</li>
* <li>Broadcast: POST /api/v1/agents/commands</li>
* </ul>
*/
@RestController
@RequestMapping("/api/v1/agents")
@Tag(name = "Agent Commands", description = "Command push endpoints for agent communication")
public class AgentCommandController {
private static final Logger log = LoggerFactory.getLogger(AgentCommandController.class);
private final AgentRegistryService registryService;
private final SseConnectionManager connectionManager;
private final ObjectMapper objectMapper;
private final AgentEventService agentEventService;
private final AuditService auditService;
public AgentCommandController(AgentRegistryService registryService,
SseConnectionManager connectionManager,
ObjectMapper objectMapper,
AgentEventService agentEventService,
AuditService auditService) {
this.registryService = registryService;
this.connectionManager = connectionManager;
this.objectMapper = objectMapper;
this.agentEventService = agentEventService;
this.auditService = auditService;
}
@PostMapping("/{id}/commands")
@Operation(summary = "Send command to a specific agent",
description = "Sends a command to the specified agent via SSE")
@ApiResponse(responseCode = "202", description = "Command accepted")
@ApiResponse(responseCode = "400", description = "Invalid command payload")
@ApiResponse(responseCode = "404", description = "Agent not registered")
public ResponseEntity<CommandSingleResponse> sendCommand(@PathVariable String id,
@RequestBody CommandRequest request,
HttpServletRequest httpRequest) throws JsonProcessingException {
AgentInfo agent = registryService.findById(id);
if (agent == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
}
CommandType type = mapCommandType(request.type());
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
AgentCommand command = registryService.addCommand(id, type, payloadJson);
String status = connectionManager.isConnected(id) ? "DELIVERED" : "PENDING";
auditService.log("send_agent_command", AuditCategory.AGENT, id,
java.util.Map.of("type", request.type(), "status", status),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(new CommandSingleResponse(command.id(), status));
}
@PostMapping("/groups/{group}/commands")
@Operation(summary = "Send command to all agents in a group",
description = "Sends a command to all LIVE agents in the specified group and waits for responses")
@ApiResponse(responseCode = "200", description = "Commands dispatched and responses collected")
@ApiResponse(responseCode = "400", description = "Invalid command payload")
public ResponseEntity<CommandGroupResponse> sendGroupCommand(@PathVariable String group,
@RequestParam(required = false) String environment,
@RequestBody CommandRequest request,
HttpServletRequest httpRequest) throws JsonProcessingException {
CommandType type = mapCommandType(request.type());
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
Map<String, CompletableFuture<CommandReply>> futures =
registryService.addGroupCommandWithReplies(group, environment, type, payloadJson);
if (futures.isEmpty()) {
auditService.log("broadcast_group_command", AuditCategory.AGENT, group,
java.util.Map.of("type", request.type(), "agentCount", 0),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new CommandGroupResponse(true, 0, 0, List.of(), List.of()));
}
// Wait with shared 10-second deadline
long deadline = System.currentTimeMillis() + 10_000;
List<CommandGroupResponse.AgentResponse> responses = new ArrayList<>();
List<String> timedOut = new ArrayList<>();
for (var entry : futures.entrySet()) {
long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) {
timedOut.add(entry.getKey());
entry.getValue().cancel(false);
continue;
}
try {
CommandReply reply = entry.getValue().get(remaining, TimeUnit.MILLISECONDS);
responses.add(new CommandGroupResponse.AgentResponse(
entry.getKey(), reply.status(), reply.message()));
} catch (TimeoutException e) {
timedOut.add(entry.getKey());
entry.getValue().cancel(false);
} catch (Exception e) {
responses.add(new CommandGroupResponse.AgentResponse(
entry.getKey(), "ERROR", e.getMessage()));
}
}
boolean allSuccess = timedOut.isEmpty() &&
responses.stream().allMatch(r -> "SUCCESS".equals(r.status()));
auditService.log("broadcast_group_command", AuditCategory.AGENT, group,
java.util.Map.of("type", request.type(), "agentCount", futures.size(),
"responded", responses.size(), "timedOut", timedOut.size()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new CommandGroupResponse(
allSuccess, futures.size(), responses.size(), responses, timedOut));
}
@PostMapping("/commands")
@Operation(summary = "Broadcast command to all live agents",
description = "Sends a command to all agents currently in LIVE state")
@ApiResponse(responseCode = "202", description = "Commands accepted")
@ApiResponse(responseCode = "400", description = "Invalid command payload")
public ResponseEntity<CommandBroadcastResponse> broadcastCommand(@RequestParam(required = false) String environment,
@RequestBody CommandRequest request,
HttpServletRequest httpRequest) throws JsonProcessingException {
CommandType type = mapCommandType(request.type());
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
List<AgentInfo> liveAgents = registryService.findByState(AgentState.LIVE);
if (environment != null) {
liveAgents = liveAgents.stream()
.filter(a -> environment.equals(a.environmentId()))
.toList();
}
List<String> commandIds = new ArrayList<>();
for (AgentInfo agent : liveAgents) {
AgentCommand command = registryService.addCommand(agent.instanceId(), type, payloadJson);
commandIds.add(command.id());
}
auditService.log("broadcast_all_command", AuditCategory.AGENT, null,
java.util.Map.of("type", request.type(), "agentCount", liveAgents.size()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(new CommandBroadcastResponse(commandIds, liveAgents.size()));
}
@PostMapping("/{id}/commands/{commandId}/ack")
@Operation(summary = "Acknowledge command receipt",
description = "Agent acknowledges that it has received and processed a command, with result status and message")
@ApiResponse(responseCode = "200", description = "Command acknowledged")
@ApiResponse(responseCode = "404", description = "Command not found")
public ResponseEntity<Void> acknowledgeCommand(@PathVariable String id,
@PathVariable String commandId,
@RequestBody(required = false) CommandAckRequest body) {
boolean acknowledged = registryService.acknowledgeCommand(id, commandId);
if (!acknowledged) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Command not found: " + commandId);
}
// Complete any pending reply future (for synchronous request-reply commands like TEST_EXPRESSION)
registryService.completeReply(commandId,
body != null ? body.status() : "SUCCESS",
body != null ? body.message() : null,
body != null ? body.data() : null);
// Record command result in agent event log
if (body != null && body.status() != null) {
AgentInfo agent = registryService.findById(id);
String application = agent != null ? agent.applicationId() : "unknown";
agentEventService.recordEvent(id, application, "COMMAND_" + body.status(),
"Command " + commandId + ": " + body.message());
log.debug("Command {} ack from agent {}: {} - {}", commandId, id, body.status(), body.message());
}
return ResponseEntity.ok().build();
}
@PostMapping("/{id}/replay")
@Operation(summary = "Replay an exchange on a specific agent (synchronous)",
description = "Sends a replay command and waits for the agent to complete the replay. "
+ "Returns the replay result including status, replayExchangeId, and duration.")
@ApiResponse(responseCode = "200", description = "Replay completed (check status for success/failure)")
@ApiResponse(responseCode = "404", description = "Agent not found or not connected")
@ApiResponse(responseCode = "504", description = "Agent did not respond in time")
public ResponseEntity<ReplayResponse> replayExchange(@PathVariable String id,
@RequestBody ReplayRequest request,
HttpServletRequest httpRequest) {
AgentInfo agent = registryService.findById(id);
if (agent == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
}
// Build protocol-compliant replay payload
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("routeId", request.routeId());
Map<String, Object> exchange = new LinkedHashMap<>();
exchange.put("body", request.body() != null ? request.body() : "");
exchange.put("headers", request.headers() != null ? request.headers() : Map.of());
payload.put("exchange", exchange);
if (request.originalExchangeId() != null) {
payload.put("originalExchangeId", request.originalExchangeId());
}
payload.put("nonce", UUID.randomUUID().toString());
String payloadJson;
try {
payloadJson = objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException e) {
log.error("Failed to serialize replay payload", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ReplayResponse("FAILURE", "Failed to serialize request", null));
}
CompletableFuture<CommandReply> future = registryService.addCommandWithReply(
id, CommandType.REPLAY, payloadJson);
Map<String, Object> auditDetails = new LinkedHashMap<>();
auditDetails.put("routeId", request.routeId());
if (request.originalExchangeId() != null) {
auditDetails.put("originalExchangeId", request.originalExchangeId());
}
try {
CommandReply reply = future.orTimeout(30, TimeUnit.SECONDS).join();
auditDetails.put("replyStatus", reply.status());
auditDetails.put("replyMessage", reply.message() != null ? reply.message() : "");
auditService.log("replay_exchange", AuditCategory.AGENT, id, auditDetails,
"SUCCESS".equals(reply.status()) ? AuditResult.SUCCESS : AuditResult.FAILURE, httpRequest);
return ResponseEntity.ok(new ReplayResponse(reply.status(), reply.message(), reply.data()));
} catch (CompletionException e) {
if (e.getCause() instanceof TimeoutException) {
auditDetails.put("error", "timeout");
auditService.log("replay_exchange", AuditCategory.AGENT, id, auditDetails,
AuditResult.FAILURE, httpRequest);
return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT)
.body(new ReplayResponse("FAILURE", "Agent did not respond within 30 seconds", null));
}
auditDetails.put("error", e.getCause().getMessage());
auditService.log("replay_exchange", AuditCategory.AGENT, id, auditDetails,
AuditResult.FAILURE, httpRequest);
log.error("Error awaiting replay reply from agent {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ReplayResponse("FAILURE", "Internal error: " + e.getCause().getMessage(), null));
}
}
private CommandType mapCommandType(String typeStr) {
return switch (typeStr) {
case "config-update" -> CommandType.CONFIG_UPDATE;
case "deep-trace" -> CommandType.DEEP_TRACE;
case "replay" -> CommandType.REPLAY;
case "set-traced-processors" -> CommandType.SET_TRACED_PROCESSORS;
case "test-expression" -> CommandType.TEST_EXPRESSION;
case "route-control" -> CommandType.ROUTE_CONTROL;
default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Invalid command type: " + typeStr + ". Valid: config-update, deep-trace, replay, set-traced-processors, test-expression, route-control");
};
}
}

View File

@@ -0,0 +1,50 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.AgentEventResponse;
import com.cameleer.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 environment,
@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, environment, fromInstant, toInstant, limit)
.stream()
.map(AgentEventResponse::from)
.toList();
return ResponseEntity.ok(events);
}
}

View File

@@ -0,0 +1,53 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.AgentMetricsResponse;
import com.cameleer.server.app.dto.MetricBucket;
import com.cameleer.server.core.storage.MetricsQueryStore;
import com.cameleer.server.core.storage.model.MetricTimeSeries;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/v1/agents/{agentId}/metrics")
public class AgentMetricsController {
private final MetricsQueryStore metricsQueryStore;
public AgentMetricsController(MetricsQueryStore metricsQueryStore) {
this.metricsQueryStore = metricsQueryStore;
}
@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,
@RequestParam(defaultValue = "gauge") String mode) {
if (from == null) from = Instant.now().minus(1, ChronoUnit.HOURS);
if (to == null) to = Instant.now();
List<String> metricNames = Arrays.asList(names.split(","));
Map<String, List<MetricTimeSeries.Bucket>> raw = "delta".equalsIgnoreCase(mode)
? metricsQueryStore.queryTimeSeriesDelta(agentId, metricNames, from, to, buckets)
: metricsQueryStore.queryTimeSeries(agentId, metricNames, from, to, buckets);
Map<String, List<MetricBucket>> result = raw.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue().stream()
.map(b -> new MetricBucket(b.time(), b.value()))
.toList(),
(a, b) -> a,
LinkedHashMap::new));
return new AgentMetricsResponse(result);
}
}

View File

@@ -0,0 +1,382 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.config.AgentRegistryConfig;
import com.cameleer.server.app.dto.AgentInstanceResponse;
import com.cameleer.server.app.dto.AgentRefreshRequest;
import com.cameleer.server.app.dto.AgentRefreshResponse;
import com.cameleer.server.app.dto.AgentRegistrationRequest;
import com.cameleer.server.app.dto.AgentRegistrationResponse;
import com.cameleer.server.app.dto.ErrorResponse;
import com.cameleer.common.model.HeartbeatRequest;
import com.cameleer.server.app.security.BootstrapTokenValidator;
import com.cameleer.server.app.security.JwtAuthenticationFilter;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.agent.AgentEventService;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.AgentState;
import com.cameleer.server.core.agent.RouteStateRegistry;
import com.cameleer.server.core.security.Ed25519SigningService;
import com.cameleer.server.core.security.InvalidTokenException;
import com.cameleer.server.core.security.JwtService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
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;
import org.springframework.web.bind.annotation.RequestBody;
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.
*/
@RestController
@RequestMapping("/api/v1/agents")
@Tag(name = "Agent Management", description = "Agent registration and lifecycle endpoints")
public class AgentRegistrationController {
private static final Logger log = LoggerFactory.getLogger(AgentRegistrationController.class);
private static final String BEARER_PREFIX = "Bearer ";
private final AgentRegistryService registryService;
private final AgentRegistryConfig config;
private final BootstrapTokenValidator bootstrapTokenValidator;
private final JwtService jwtService;
private final Ed25519SigningService ed25519SigningService;
private final AgentEventService agentEventService;
private final AuditService auditService;
private final JdbcTemplate jdbc;
private final RouteStateRegistry routeStateRegistry;
public AgentRegistrationController(AgentRegistryService registryService,
AgentRegistryConfig config,
BootstrapTokenValidator bootstrapTokenValidator,
JwtService jwtService,
Ed25519SigningService ed25519SigningService,
AgentEventService agentEventService,
AuditService auditService,
@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc,
RouteStateRegistry routeStateRegistry) {
this.registryService = registryService;
this.config = config;
this.bootstrapTokenValidator = bootstrapTokenValidator;
this.jwtService = jwtService;
this.ed25519SigningService = ed25519SigningService;
this.agentEventService = agentEventService;
this.auditService = auditService;
this.jdbc = jdbc;
this.routeStateRegistry = routeStateRegistry;
}
@PostMapping("/register")
@Operation(summary = "Register an agent",
description = "Registers a new agent or re-registers an existing one. "
+ "Requires bootstrap token in Authorization header.")
@ApiResponse(responseCode = "200", description = "Agent registered successfully")
@ApiResponse(responseCode = "400", description = "Invalid registration payload",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@ApiResponse(responseCode = "401", description = "Missing or invalid bootstrap token")
public ResponseEntity<AgentRegistrationResponse> register(
@RequestBody AgentRegistrationRequest request,
HttpServletRequest httpRequest) {
// Validate bootstrap token
String authHeader = httpRequest.getHeader("Authorization");
String bootstrapToken = null;
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
bootstrapToken = authHeader.substring(BEARER_PREFIX.length());
}
if (bootstrapToken == null || !bootstrapTokenValidator.validate(bootstrapToken)) {
return ResponseEntity.status(401).build();
}
if (request.instanceId() == null || request.instanceId().isBlank()) {
return ResponseEntity.badRequest().build();
}
String application = request.applicationId() != null ? request.applicationId() : "default";
String environmentId = request.environmentId() != null ? request.environmentId() : "default";
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
boolean reRegistration = registryService.findById(request.instanceId()) != null;
AgentInfo agent = registryService.register(
request.instanceId(), request.instanceId(), application, environmentId,
request.version(), routeIds, capabilities);
if (reRegistration) {
log.info("Agent re-registered: {} (application={}, routes={}, capabilities={})",
request.instanceId(), application, routeIds.size(), capabilities.keySet());
agentEventService.recordEvent(request.instanceId(), application, "RE_REGISTERED",
"Agent re-registered with " + routeIds.size() + " routes");
} else {
log.info("Agent registered: {} (application={}, routes={})",
request.instanceId(), application, routeIds.size());
agentEventService.recordEvent(request.instanceId(), application, "REGISTERED",
"Agent registered: " + request.instanceId());
}
auditService.log(request.instanceId(), reRegistration ? "agent_reregister" : "agent_register",
AuditCategory.AGENT, request.instanceId(),
Map.of("application", application, "routeCount", routeIds.size(),
"reRegistration", reRegistration),
AuditResult.SUCCESS, httpRequest);
// Issue JWT tokens with AGENT role + environment
List<String> roles = List.of("AGENT");
String accessToken = jwtService.createAccessToken(request.instanceId(), application, environmentId, roles);
String refreshToken = jwtService.createRefreshToken(request.instanceId(), application, environmentId, roles);
String sseEndpoint = ServletUriComponentsBuilder.fromCurrentContextPath()
.path("/api/v1/agents/{id}/events")
.buildAndExpand(agent.instanceId())
.toUriString();
return ResponseEntity.ok(new AgentRegistrationResponse(
agent.instanceId(),
sseEndpoint,
config.getHeartbeatIntervalMs(),
ed25519SigningService.getPublicKeyBase64(),
accessToken,
refreshToken
));
}
@PostMapping("/{id}/refresh")
@Operation(summary = "Refresh access token",
description = "Issues a new access JWT from a valid refresh token")
@ApiResponse(responseCode = "200", description = "New access token issued")
@ApiResponse(responseCode = "401", description = "Invalid or expired refresh token")
@ApiResponse(responseCode = "404", description = "Agent not found")
public ResponseEntity<AgentRefreshResponse> refresh(@PathVariable String id,
@RequestBody AgentRefreshRequest request,
HttpServletRequest httpRequest) {
if (request.refreshToken() == null || request.refreshToken().isBlank()) {
return ResponseEntity.status(401).build();
}
// Validate refresh token
JwtService.JwtValidationResult result;
try {
result = jwtService.validateRefreshToken(request.refreshToken());
} catch (InvalidTokenException e) {
log.debug("Refresh token validation failed: {}", e.getMessage());
return ResponseEntity.status(401).build();
}
String agentId = result.subject();
// Verify agent ID in path matches token
if (!id.equals(agentId)) {
log.debug("Refresh token agent ID mismatch: path={}, token={}", id, agentId);
return ResponseEntity.status(401).build();
}
// Preserve roles and application from refresh token
List<String> roles = result.roles().isEmpty()
? List.of("AGENT") : result.roles();
String application = result.application() != null ? result.application() : "default";
// Try to get application + environment from registry (agent may not be registered after server restart)
String environment = result.environment() != null ? result.environment() : "default";
AgentInfo agent = registryService.findById(agentId);
if (agent != null) {
application = agent.applicationId();
environment = agent.environmentId();
}
String newAccessToken = jwtService.createAccessToken(agentId, application, environment, roles);
String newRefreshToken = jwtService.createRefreshToken(agentId, application, environment, roles);
auditService.log(agentId, "agent_token_refresh", AuditCategory.AUTH, agentId,
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
}
@PostMapping("/{id}/heartbeat")
@Operation(summary = "Agent heartbeat ping",
description = "Updates the agent's last heartbeat timestamp. Auto-registers the agent if not in registry (e.g. after server restart).")
@ApiResponse(responseCode = "200", description = "Heartbeat accepted")
public ResponseEntity<Void> heartbeat(@PathVariable String id,
@RequestBody(required = false) HeartbeatRequest request,
HttpServletRequest httpRequest) {
Map<String, Object> capabilities = request != null ? request.getCapabilities() : null;
String heartbeatEnv = request != null ? request.getEnvironmentId() : null;
boolean found = registryService.heartbeat(id, capabilities);
if (!found) {
// Auto-heal: re-register agent from heartbeat body + JWT claims after server restart
var jwtResult = (JwtService.JwtValidationResult) httpRequest.getAttribute(
JwtAuthenticationFilter.JWT_RESULT_ATTR);
if (jwtResult != null) {
String application = jwtResult.application() != null ? jwtResult.application() : "default";
// Prefer environment from heartbeat body (most current), fall back to JWT claim
String env = heartbeatEnv != null ? heartbeatEnv
: jwtResult.environment() != null ? jwtResult.environment() : "default";
Map<String, Object> caps = capabilities != null ? capabilities : Map.of();
registryService.register(id, id, application, env, "unknown",
List.of(), caps);
registryService.heartbeat(id);
log.info("Auto-registered agent {} (app={}, env={}) from heartbeat after server restart", id, application, env);
} else {
return ResponseEntity.notFound().build();
}
}
if (request != null && request.getRouteStates() != null && !request.getRouteStates().isEmpty()) {
AgentInfo agent = registryService.findById(id);
if (agent != null) {
for (var entry : request.getRouteStates().entrySet()) {
RouteStateRegistry.RouteState state = parseRouteState(entry.getValue());
if (state != null) {
routeStateRegistry.setState(agent.applicationId(), entry.getKey(), state);
}
}
}
}
return ResponseEntity.ok().build();
}
private RouteStateRegistry.RouteState parseRouteState(String state) {
if (state == null) return null;
return switch (state) {
case "Started" -> RouteStateRegistry.RouteState.STARTED;
case "Stopped" -> RouteStateRegistry.RouteState.STOPPED;
case "Suspended" -> RouteStateRegistry.RouteState.SUSPENDED;
default -> null;
};
}
@PostMapping("/{id}/deregister")
@Operation(summary = "Deregister agent",
description = "Removes the agent from the registry. Called by agents during graceful shutdown.")
@ApiResponse(responseCode = "200", description = "Agent deregistered")
@ApiResponse(responseCode = "404", description = "Agent not registered")
public ResponseEntity<Void> deregister(@PathVariable String id, HttpServletRequest httpRequest) {
AgentInfo agent = registryService.findById(id);
if (agent == null) {
return ResponseEntity.notFound().build();
}
String applicationId = agent.applicationId();
registryService.deregister(id);
agentEventService.recordEvent(id, applicationId, "DEREGISTERED", "Agent deregistered");
auditService.log(id, "agent_deregister", AuditCategory.AGENT, id, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@GetMapping
@Operation(summary = "List all agents",
description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or application")
@ApiResponse(responseCode = "200", description = "Agent list returned")
@ApiResponse(responseCode = "400", description = "Invalid status filter",
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 environment) {
List<AgentInfo> agents;
if (status != null) {
try {
AgentState stateFilter = AgentState.valueOf(status.toUpperCase());
agents = registryService.findByState(stateFilter);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
} else {
agents = registryService.findAll();
}
// Apply application filter if specified
if (application != null && !application.isBlank()) {
agents = agents.stream()
.filter(a -> application.equals(a.applicationId()))
.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;
List<AgentInstanceResponse> response = finalAgents.stream()
.map(a -> {
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
double[] m = agentMetrics.get(a.applicationId());
if (m != null) {
long appAgentCount = finalAgents.stream()
.filter(ag -> ag.applicationId().equals(a.applicationId())).count();
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 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 {
// Literal SQL — ClickHouse JDBC driver wraps prepared statements in sub-queries
// that strip AggregateFunction column types, breaking -Merge combinators
jdbc.query(
"SELECT application_id, " +
"uniqMerge(total_count) AS total, " +
"uniqIfMerge(failed_count) AS failed, " +
"COUNT(DISTINCT route_id) AS active_routes " +
"FROM stats_1m_route WHERE bucket >= " + lit(from1m) + " AND bucket < " + lit(now) +
" GROUP BY application_id",
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("application_id"), new double[]{tps, errorRate, activeRoutes});
});
} catch (Exception e) {
log.debug("Could not query agent metrics: {}", e.getMessage());
}
return result;
}
/** Format an Instant as a ClickHouse DateTime literal. */
private static String lit(Instant instant) {
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(java.time.ZoneOffset.UTC)
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
}
}

View File

@@ -0,0 +1,84 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.agent.SseConnectionManager;
import com.cameleer.server.app.security.JwtAuthenticationFilter;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.JwtService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import java.util.Map;
/**
* SSE endpoint for real-time event streaming to agents.
* <p>
* Agents connect to {@code GET /api/v1/agents/{id}/events} to receive
* config-update, deep-trace, and replay commands as Server-Sent Events.
*/
@RestController
@RequestMapping("/api/v1/agents")
@Tag(name = "Agent SSE", description = "Server-Sent Events endpoint for agent communication")
public class AgentSseController {
private static final Logger log = LoggerFactory.getLogger(AgentSseController.class);
private final SseConnectionManager connectionManager;
private final AgentRegistryService registryService;
public AgentSseController(SseConnectionManager connectionManager,
AgentRegistryService registryService) {
this.connectionManager = connectionManager;
this.registryService = registryService;
}
@GetMapping(value = "/{id}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@Operation(summary = "Open SSE event stream",
description = "Opens a Server-Sent Events stream for the specified agent. "
+ "Commands (config-update, deep-trace, replay) are pushed as events. "
+ "Ping keepalive comments sent every 15 seconds.")
@ApiResponse(responseCode = "200", description = "SSE stream opened")
@ApiResponse(responseCode = "404", description = "Agent not registered and cannot be auto-registered")
public SseEmitter events(
@PathVariable String id,
@Parameter(description = "Last received event ID (no replay, acknowledged only)")
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId,
HttpServletRequest httpRequest) {
AgentInfo agent = registryService.findById(id);
if (agent == null) {
// Auto-heal: re-register agent from JWT claims after server restart
var jwtResult = (JwtService.JwtValidationResult) httpRequest.getAttribute(
JwtAuthenticationFilter.JWT_RESULT_ATTR);
if (jwtResult != null) {
String application = jwtResult.application() != null ? jwtResult.application() : "default";
String env = jwtResult.environment() != null ? jwtResult.environment() : "default";
registryService.register(id, id, application, env, "unknown", List.of(), Map.of());
log.info("Auto-registered agent {} (app={}, env={}) from SSE connect after server restart", id, application, env);
} else {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
}
}
if (lastEventId != null) {
log.debug("Agent {} reconnecting with Last-Event-ID: {} (no replay)", id, lastEventId);
}
return connectionManager.connect(id);
}
}

View File

@@ -0,0 +1,21 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.ErrorResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
/**
* Global exception handler that ensures error responses use the typed {@link ErrorResponse} schema.
*/
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
String reason = ex.getReason();
return ResponseEntity.status(ex.getStatusCode())
.body(new ErrorResponse(reason != null ? reason : "Unknown error"));
}
}

View File

@@ -0,0 +1,157 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.runtime.App;
import com.cameleer.server.core.runtime.AppService;
import com.cameleer.server.core.runtime.AppVersion;
import com.cameleer.server.core.runtime.RuntimeType;
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.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* App CRUD and JAR upload endpoints.
* All app-scoped endpoints accept the app slug (not UUID) as path variable.
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}.
*/
@RestController
@RequestMapping("/api/v1/apps")
@Tag(name = "App Management", description = "Application lifecycle and JAR uploads")
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
public class AppController {
private final AppService appService;
public AppController(AppService appService) {
this.appService = appService;
}
@GetMapping
@Operation(summary = "List apps by environment")
@ApiResponse(responseCode = "200", description = "App list returned")
public ResponseEntity<List<App>> listApps(@RequestParam(required = false) UUID environmentId) {
if (environmentId != null) {
return ResponseEntity.ok(appService.listByEnvironment(environmentId));
}
return ResponseEntity.ok(appService.listAll());
}
@GetMapping("/{appSlug}")
@Operation(summary = "Get app by slug")
@ApiResponse(responseCode = "200", description = "App found")
@ApiResponse(responseCode = "404", description = "App not found")
public ResponseEntity<App> getApp(@PathVariable String appSlug) {
try {
return ResponseEntity.ok(appService.getBySlug(appSlug));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping
@Operation(summary = "Create a new app")
@ApiResponse(responseCode = "201", description = "App created")
@ApiResponse(responseCode = "400", description = "Slug already exists in environment")
public ResponseEntity<App> createApp(@RequestBody CreateAppRequest request) {
try {
UUID id = appService.createApp(request.environmentId(), request.slug(), request.displayName());
return ResponseEntity.status(201).body(appService.getById(id));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping("/{appSlug}/versions")
@Operation(summary = "List app versions")
@ApiResponse(responseCode = "200", description = "Version list returned")
public ResponseEntity<List<AppVersion>> listVersions(@PathVariable String appSlug) {
try {
App app = appService.getBySlug(appSlug);
return ResponseEntity.ok(appService.listVersions(app.id()));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping(value = "/{appSlug}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "Upload a JAR for a new app version")
@ApiResponse(responseCode = "201", description = "JAR uploaded and version created")
@ApiResponse(responseCode = "404", description = "App not found")
public ResponseEntity<AppVersion> uploadJar(@PathVariable String appSlug,
@RequestParam("file") MultipartFile file) throws IOException {
try {
App app = appService.getBySlug(appSlug);
AppVersion version = appService.uploadJar(app.id(), file.getOriginalFilename(), file.getInputStream(), file.getSize());
return ResponseEntity.status(201).body(version);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{appSlug}")
@Operation(summary = "Delete an app")
@ApiResponse(responseCode = "204", description = "App deleted")
public ResponseEntity<Void> deleteApp(@PathVariable String appSlug) {
try {
App app = appService.getBySlug(appSlug);
appService.deleteApp(app.id());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
private static final java.util.regex.Pattern CUSTOM_ARGS_PATTERN =
java.util.regex.Pattern.compile("^[-a-zA-Z0-9_.=:/\\s+\"']*$");
private void validateContainerConfig(Map<String, Object> config) {
Object customArgs = config.get("customArgs");
if (customArgs instanceof String s && !s.isBlank() && !CUSTOM_ARGS_PATTERN.matcher(s).matches()) {
throw new IllegalArgumentException("customArgs contains invalid characters. Only JVM-style arguments are allowed.");
}
Object runtimeType = config.get("runtimeType");
if (runtimeType instanceof String s && !s.isBlank() && RuntimeType.fromString(s) == null) {
throw new IllegalArgumentException("Invalid runtimeType: " + s +
". Must be one of: auto, spring-boot, quarkus, plain-java, native");
}
}
@PutMapping("/{appSlug}/container-config")
@Operation(summary = "Update container config for an app")
@ApiResponse(responseCode = "200", description = "Container config updated")
@ApiResponse(responseCode = "400", description = "Invalid configuration")
@ApiResponse(responseCode = "404", description = "App not found")
public ResponseEntity<App> updateContainerConfig(@PathVariable String appSlug,
@RequestBody Map<String, Object> containerConfig) {
try {
validateContainerConfig(containerConfig);
App app = appService.getBySlug(appSlug);
appService.updateContainerConfig(app.id(), containerConfig);
return ResponseEntity.ok(appService.getById(app.id()));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.badRequest().build();
}
}
public record CreateAppRequest(UUID environmentId, String slug, String displayName) {}
}

View File

@@ -0,0 +1,79 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.AppSettingsRequest;
import com.cameleer.server.core.admin.AppSettings;
import com.cameleer.server.core.admin.AppSettingsRepository;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/admin/app-settings")
@PreAuthorize("hasAnyRole('ADMIN', 'OPERATOR')")
@Tag(name = "App Settings", description = "Per-application dashboard settings (ADMIN/OPERATOR)")
public class AppSettingsController {
private final AppSettingsRepository repository;
private final AuditService auditService;
public AppSettingsController(AppSettingsRepository repository, AuditService auditService) {
this.repository = repository;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "List all application settings")
public ResponseEntity<List<AppSettings>> getAll() {
return ResponseEntity.ok(repository.findAll());
}
@GetMapping("/{appId}")
@Operation(summary = "Get settings for a specific application (returns defaults if not configured)")
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId) {
AppSettings settings = repository.findByApplicationId(appId).orElse(AppSettings.defaults(appId));
return ResponseEntity.ok(settings);
}
@PutMapping("/{appId}")
@Operation(summary = "Create or update settings for an application")
public ResponseEntity<AppSettings> update(@PathVariable String appId,
@Valid @RequestBody AppSettingsRequest request,
HttpServletRequest httpRequest) {
List<String> errors = request.validate();
if (!errors.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
}
AppSettings saved = repository.save(request.toSettings(appId));
auditService.log("update_app_settings", AuditCategory.CONFIG, appId,
Map.of("settings", saved), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(saved);
}
@DeleteMapping("/{appId}")
@Operation(summary = "Delete application settings (reverts to defaults)")
public ResponseEntity<Void> delete(@PathVariable String appId, HttpServletRequest httpRequest) {
repository.delete(appId);
auditService.log("delete_app_settings", AuditCategory.CONFIG, appId,
Map.of(), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,287 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.dto.AppConfigResponse;
import com.cameleer.server.app.dto.CommandGroupResponse;
import com.cameleer.server.app.dto.ConfigUpdateResponse;
import com.cameleer.server.app.dto.TestExpressionRequest;
import com.cameleer.server.app.dto.TestExpressionResponse;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.admin.SensitiveKeysConfig;
import com.cameleer.server.core.admin.SensitiveKeysMerger;
import com.cameleer.server.core.admin.SensitiveKeysRepository;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.AgentState;
import com.cameleer.server.core.agent.CommandReply;
import com.cameleer.server.core.agent.CommandType;
import com.cameleer.server.core.storage.DiagramStore;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* Per-application configuration management.
* Agents fetch config at startup; the UI modifies config which is persisted and pushed to agents via SSE.
*/
@RestController
@RequestMapping("/api/v1/config")
@Tag(name = "Application Config", description = "Per-application observability configuration")
public class ApplicationConfigController {
private static final Logger log = LoggerFactory.getLogger(ApplicationConfigController.class);
private final PostgresApplicationConfigRepository configRepository;
private final AgentRegistryService registryService;
private final ObjectMapper objectMapper;
private final AuditService auditService;
private final DiagramStore diagramStore;
private final SensitiveKeysRepository sensitiveKeysRepository;
public ApplicationConfigController(PostgresApplicationConfigRepository configRepository,
AgentRegistryService registryService,
ObjectMapper objectMapper,
AuditService auditService,
DiagramStore diagramStore,
SensitiveKeysRepository sensitiveKeysRepository) {
this.configRepository = configRepository;
this.registryService = registryService;
this.objectMapper = objectMapper;
this.auditService = auditService;
this.diagramStore = diagramStore;
this.sensitiveKeysRepository = sensitiveKeysRepository;
}
@GetMapping
@Operation(summary = "List all application configs",
description = "Returns stored configurations for all applications")
@ApiResponse(responseCode = "200", description = "Configs returned")
public ResponseEntity<List<ApplicationConfig>> listConfigs(HttpServletRequest httpRequest) {
auditService.log("view_app_configs", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(configRepository.findAll());
}
@GetMapping("/{application}")
@Operation(summary = "Get application config",
description = "Returns the current configuration for an application with merged sensitive keys.")
@ApiResponse(responseCode = "200", description = "Config returned")
public ResponseEntity<AppConfigResponse> getConfig(@PathVariable String application,
HttpServletRequest httpRequest) {
auditService.log("view_app_config", AuditCategory.CONFIG, application, null, AuditResult.SUCCESS, httpRequest);
ApplicationConfig config = configRepository.findByApplication(application)
.orElse(defaultConfig(application));
List<String> globalKeys = sensitiveKeysRepository.find()
.map(SensitiveKeysConfig::keys)
.orElse(null);
List<String> merged = SensitiveKeysMerger.merge(globalKeys, extractSensitiveKeys(config));
return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged));
}
@PutMapping("/{application}")
@Operation(summary = "Update application config",
description = "Saves config and pushes CONFIG_UPDATE to all LIVE agents of this application")
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
public ResponseEntity<ConfigUpdateResponse> updateConfig(@PathVariable String application,
@RequestParam(required = false) String environment,
@RequestBody ApplicationConfig config,
Authentication auth,
HttpServletRequest httpRequest) {
String updatedBy = auth != null ? auth.getName() : "system";
config.setApplication(application);
ApplicationConfig saved = configRepository.save(application, config, updatedBy);
// Merge global + per-app sensitive keys for the SSE push payload
List<String> globalKeys = sensitiveKeysRepository.find()
.map(SensitiveKeysConfig::keys)
.orElse(null);
List<String> perAppKeys = extractSensitiveKeys(saved);
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
// Push with merged sensitive keys injected into the payload
CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(application, environment, saved, mergedKeys);
log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded",
saved.getVersion(), application, pushResult.total(), pushResult.responded());
auditService.log("update_app_config", AuditCategory.CONFIG, application,
Map.of("version", saved.getVersion(), "agentsPushed", pushResult.total(),
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult));
}
@GetMapping("/{application}/processor-routes")
@Operation(summary = "Get processor to route mapping",
description = "Returns a map of processorId → routeId for all processors seen in this application")
@ApiResponse(responseCode = "200", description = "Mapping returned")
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@PathVariable String application) {
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application));
}
@PostMapping("/{application}/test-expression")
@Operation(summary = "Test a tap expression against sample data via a live agent")
@ApiResponse(responseCode = "200", description = "Expression evaluated successfully")
@ApiResponse(responseCode = "404", description = "No live agent available for this application")
@ApiResponse(responseCode = "504", description = "Agent did not respond in time")
public ResponseEntity<TestExpressionResponse> testExpression(
@PathVariable String application,
@RequestParam(required = false) String environment,
@RequestBody TestExpressionRequest request) {
// Find a LIVE agent for this application, optionally filtered by environment
var candidates = registryService.findAll().stream()
.filter(a -> application.equals(a.applicationId()))
.filter(a -> a.state() == AgentState.LIVE);
if (environment != null) {
candidates = candidates.filter(a -> environment.equals(a.environmentId()));
}
AgentInfo agent = candidates.findFirst().orElse(null);
if (agent == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new TestExpressionResponse(null, "No live agent available for application: " + application));
}
// Build payload JSON
String payloadJson;
try {
payloadJson = objectMapper.writeValueAsString(Map.of(
"expression", request.expression() != null ? request.expression() : "",
"language", request.language() != null ? request.language() : "",
"body", request.body() != null ? request.body() : "",
"target", request.target() != null ? request.target() : ""
));
} catch (JsonProcessingException e) {
log.error("Failed to serialize test-expression payload", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new TestExpressionResponse(null, "Failed to serialize request"));
}
// Send command and await reply
CompletableFuture<CommandReply> future = registryService.addCommandWithReply(
agent.instanceId(), CommandType.TEST_EXPRESSION, payloadJson);
try {
CommandReply reply = future.orTimeout(5, TimeUnit.SECONDS).join();
if ("SUCCESS".equals(reply.status())) {
return ResponseEntity.ok(new TestExpressionResponse(reply.data(), null));
} else {
return ResponseEntity.ok(new TestExpressionResponse(null, reply.message()));
}
} catch (CompletionException e) {
if (e.getCause() instanceof TimeoutException) {
return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT)
.body(new TestExpressionResponse(null, "Agent did not respond within 5 seconds"));
}
log.error("Error awaiting test-expression reply from agent {}", agent.instanceId(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new TestExpressionResponse(null, "Internal error: " + e.getCause().getMessage()));
}
}
/**
* Extracts sensitiveKeys from ApplicationConfig via JsonNode to avoid compile-time
* dependency on getSensitiveKeys() which may not be in the published cameleer-common jar yet.
*/
private List<String> extractSensitiveKeys(ApplicationConfig config) {
try {
com.fasterxml.jackson.databind.JsonNode node = objectMapper.valueToTree(config);
com.fasterxml.jackson.databind.JsonNode keysNode = node.get("sensitiveKeys");
if (keysNode == null || keysNode.isNull() || !keysNode.isArray()) {
return null;
}
return objectMapper.convertValue(keysNode, new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {});
} catch (Exception e) {
return null;
}
}
/**
* Push config to agents with merged sensitive keys injected into the JSON payload.
*/
private CommandGroupResponse pushConfigToAgentsWithMergedKeys(String application, String environment,
ApplicationConfig config, List<String> mergedKeys) {
String payloadJson;
try {
// Serialize config to a mutable map, inject merged keys
@SuppressWarnings("unchecked")
Map<String, Object> configMap = objectMapper.convertValue(config, Map.class);
configMap.put("sensitiveKeys", mergedKeys);
payloadJson = objectMapper.writeValueAsString(configMap);
} catch (Exception e) {
log.error("Failed to serialize config with merged keys for push", e);
return new CommandGroupResponse(false, 0, 0, List.of(), List.of());
}
Map<String, CompletableFuture<CommandReply>> futures =
registryService.addGroupCommandWithReplies(application, environment, CommandType.CONFIG_UPDATE, payloadJson);
if (futures.isEmpty()) {
return new CommandGroupResponse(true, 0, 0, List.of(), List.of());
}
long deadline = System.currentTimeMillis() + 10_000;
List<CommandGroupResponse.AgentResponse> responses = new ArrayList<>();
List<String> timedOut = new ArrayList<>();
for (var entry : futures.entrySet()) {
long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) {
timedOut.add(entry.getKey());
entry.getValue().cancel(false);
continue;
}
try {
CommandReply reply = entry.getValue().get(remaining, TimeUnit.MILLISECONDS);
responses.add(new CommandGroupResponse.AgentResponse(
entry.getKey(), reply.status(), reply.message()));
} catch (TimeoutException e) {
timedOut.add(entry.getKey());
entry.getValue().cancel(false);
} catch (Exception e) {
responses.add(new CommandGroupResponse.AgentResponse(
entry.getKey(), "ERROR", e.getMessage()));
}
}
boolean allSuccess = timedOut.isEmpty() &&
responses.stream().allMatch(r -> "SUCCESS".equals(r.status()));
return new CommandGroupResponse(allSuccess, futures.size(), responses.size(), responses, timedOut);
}
private static ApplicationConfig defaultConfig(String application) {
ApplicationConfig config = new ApplicationConfig();
config.setApplication(application);
config.setVersion(0);
config.setMetricsEnabled(true);
config.setSamplingRate(1.0);
config.setTracedProcessors(Map.of());
config.setApplicationLogLevel("INFO");
config.setAgentLogLevel("INFO");
config.setEngineLevel("REGULAR");
config.setPayloadCaptureMode("BOTH");
return config;
}
}

View File

@@ -0,0 +1,74 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.AuditLogPageResponse;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditRepository;
import com.cameleer.server.core.admin.AuditRepository.AuditPage;
import com.cameleer.server.core.admin.AuditRepository.AuditQuery;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
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;
@RestController
@RequestMapping("/api/v1/admin/audit")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Audit Log", description = "Audit log viewer (ADMIN only)")
public class AuditLogController {
private final AuditRepository auditRepository;
private final AuditService auditService;
public AuditLogController(AuditRepository auditRepository, AuditService auditService) {
this.auditRepository = auditRepository;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "Search audit log entries with pagination")
public ResponseEntity<AuditLogPageResponse> getAuditLog(
HttpServletRequest httpRequest,
@RequestParam(required = false) String username,
@RequestParam(required = false) String category,
@RequestParam(required = false) String search,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,
@RequestParam(defaultValue = "timestamp") String sort,
@RequestParam(defaultValue = "desc") String order,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {
size = Math.min(size, 100);
Instant fromInstant = from != null ? from : Instant.now().minus(java.time.Duration.ofDays(7));
Instant toInstant = to != null ? to : Instant.now();
AuditCategory cat = null;
if (category != null && !category.isEmpty()) {
try {
cat = AuditCategory.valueOf(category.toUpperCase());
} catch (IllegalArgumentException ignored) {
// invalid category is treated as no filter
}
}
auditService.log("view_audit_log", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest);
AuditQuery query = new AuditQuery(username, cat, search, fromInstant, toInstant, sort, order, page, size);
AuditPage result = auditRepository.find(query);
int totalPages = Math.max(1, (int) Math.ceil((double) result.totalCount() / size));
return ResponseEntity.ok(new AuditLogPageResponse(
result.items(), result.totalCount(), page, size, totalPages));
}
}

View File

@@ -0,0 +1,369 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.config.TenantProperties;
import com.cameleer.server.app.dto.AgentSummary;
import com.cameleer.server.app.dto.CatalogApp;
import com.cameleer.server.app.dto.RouteSummary;
import com.cameleer.common.graph.RouteGraph;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.AgentState;
import com.cameleer.server.core.agent.RouteStateRegistry;
import com.cameleer.server.core.runtime.*;
import com.cameleer.server.core.storage.DiagramStore;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
/**
* Unified catalog endpoint that merges App records (PostgreSQL) with live agent data
* and ClickHouse stats. Replaces the separate RouteCatalogController.
*/
@RestController
@RequestMapping("/api/v1/catalog")
@Tag(name = "Catalog", description = "Unified application catalog")
public class CatalogController {
private static final Logger log = LoggerFactory.getLogger(CatalogController.class);
private final AgentRegistryService registryService;
private final DiagramStore diagramStore;
private final JdbcTemplate jdbc;
private final RouteStateRegistry routeStateRegistry;
private final AppService appService;
private final EnvironmentService envService;
private final DeploymentRepository deploymentRepo;
private final TenantProperties tenantProperties;
@Value("${cameleer.server.catalog.discoveryttldays:7}")
private int discoveryTtlDays;
public CatalogController(AgentRegistryService registryService,
DiagramStore diagramStore,
@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc,
RouteStateRegistry routeStateRegistry,
AppService appService,
EnvironmentService envService,
DeploymentRepository deploymentRepo,
TenantProperties tenantProperties) {
this.registryService = registryService;
this.diagramStore = diagramStore;
this.jdbc = jdbc;
this.routeStateRegistry = routeStateRegistry;
this.appService = appService;
this.envService = envService;
this.deploymentRepo = deploymentRepo;
this.tenantProperties = tenantProperties;
}
@GetMapping
@Operation(summary = "Get unified catalog",
description = "Returns all applications (managed + unmanaged) with live agent data, routes, and deployment status")
@ApiResponse(responseCode = "200", description = "Catalog returned")
public ResponseEntity<List<CatalogApp>> getCatalog(
@RequestParam(required = false) String environment,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to) {
// 1. Resolve environment
Environment env = null;
if (environment != null && !environment.isBlank()) {
try {
env = envService.getBySlug(environment);
} catch (IllegalArgumentException e) {
return ResponseEntity.ok(List.of());
}
}
// 2. Get managed apps from PostgreSQL
List<App> managedApps = env != null
? appService.listByEnvironment(env.id())
: appService.listAll();
Map<String, App> appsBySlug = managedApps.stream()
.collect(Collectors.toMap(App::slug, a -> a, (a, b) -> a));
// 3. Get active deployments for managed apps
Map<UUID, Deployment> activeDeployments = new HashMap<>();
for (App app : managedApps) {
UUID envId = env != null ? env.id() : app.environmentId();
deploymentRepo.findActiveByAppIdAndEnvironmentId(app.id(), envId)
.ifPresent(d -> activeDeployments.put(app.id(), d));
}
// 4. Get agents, filter by environment
List<AgentInfo> allAgents = registryService.findAll();
if (environment != null && !environment.isBlank()) {
allAgents = allAgents.stream()
.filter(a -> environment.equals(a.environmentId()))
.toList();
}
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
.collect(Collectors.groupingBy(AgentInfo::applicationId, LinkedHashMap::new, Collectors.toList()));
// 5. Collect routes per app from agents
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);
}
// 6. ClickHouse exchange counts
Instant now = Instant.now();
Instant rangeFrom = from != null ? Instant.parse(from) : now.minus(24, ChronoUnit.HOURS);
Instant rangeTo = to != null ? Instant.parse(to) : now;
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, uniqMerge(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");
routeExchangeCounts.put(key, rs.getLong("cnt"));
Timestamp ts = rs.getTimestamp("last_seen");
if (ts != null) routeLastSeen.put(key, ts.toInstant());
});
} catch (Exception e) {
log.warn("Failed to query route exchange counts: {}", e.getMessage());
}
// Merge ClickHouse routes into routesByApp
for (var countEntry : routeExchangeCounts.entrySet()) {
String[] parts = countEntry.getKey().split("/", 2);
if (parts.length == 2) {
routesByApp.computeIfAbsent(parts[0], k -> new LinkedHashSet<>()).add(parts[1]);
}
}
// 7. Build unified catalog
Set<String> allSlugs = new LinkedHashSet<>(appsBySlug.keySet());
allSlugs.addAll(agentsByApp.keySet());
allSlugs.addAll(routesByApp.keySet());
String envSlug = env != null ? env.slug() : "";
List<CatalogApp> catalog = new ArrayList<>();
for (String slug : allSlugs) {
App app = appsBySlug.get(slug);
List<AgentInfo> agents = agentsByApp.getOrDefault(slug, List.of());
// Auto-cleanup: skip discovered apps with no live agents and no recent data
if (app == null && agents.isEmpty()) {
Set<String> routes = routesByApp.getOrDefault(slug, Set.of());
boolean hasRecentData = routes.stream().anyMatch(routeId -> {
Instant lastSeen = routeLastSeen.get(slug + "/" + routeId);
return lastSeen != null && lastSeen.isAfter(Instant.now().minus(discoveryTtlDays, ChronoUnit.DAYS));
});
if (!hasRecentData) continue;
}
Set<String> routeIds = routesByApp.getOrDefault(slug, Set.of());
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
// Routes
List<RouteSummary> routeSummaries = routeIds.stream()
.map(routeId -> {
String key = slug + "/" + routeId;
long count = routeExchangeCounts.getOrDefault(key, 0L);
Instant lastSeen = routeLastSeen.get(key);
String fromUri = resolveFromEndpointUri(routeId, agentIds);
String state = routeStateRegistry.getState(slug, routeId).name().toLowerCase();
String routeState = "started".equals(state) ? null : state;
return new RouteSummary(routeId, count, lastSeen, fromUri, routeState);
})
.toList();
// Agent summaries
List<AgentSummary> agentSummaries = agents.stream()
.map(a -> new AgentSummary(a.instanceId(), a.displayName(), a.state().name().toLowerCase(), 0.0))
.toList();
// Agent health
String agentHealth = agents.isEmpty() ? "offline" : computeWorstHealth(agents);
// Total exchanges
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
// Deployment summary (managed apps only)
CatalogApp.DeploymentSummary deploymentSummary = null;
DeploymentStatus deployStatus = null;
if (app != null) {
Deployment dep = activeDeployments.get(app.id());
if (dep != null) {
deployStatus = dep.status();
int healthy = 0, total = 0;
if (dep.replicaStates() != null) {
total = dep.replicaStates().size();
healthy = (int) dep.replicaStates().stream()
.filter(r -> "RUNNING".equals(r.get("status")))
.count();
}
int version = 0;
try {
var versions = appService.listVersions(app.id());
version = versions.stream()
.filter(v -> v.id().equals(dep.appVersionId()))
.map(AppVersion::version)
.findFirst().orElse(0);
} catch (Exception ignored) {}
deploymentSummary = new CatalogApp.DeploymentSummary(
dep.status().name(),
healthy + "/" + total,
version
);
}
}
// Composite health + tooltip
String health = compositeHealth(app != null ? deployStatus : null, agentHealth);
String healthTooltip = buildHealthTooltip(app != null, deployStatus, agentHealth, agents.size());
String displayName = app != null ? app.displayName() : slug;
String appEnvSlug = envSlug;
if (app != null && appEnvSlug.isEmpty()) {
try {
appEnvSlug = envService.getById(app.environmentId()).slug();
} catch (Exception ignored) {}
}
catalog.add(new CatalogApp(
slug, displayName, app != null, appEnvSlug,
health, healthTooltip, agents.size(), routeSummaries, agentSummaries,
totalExchanges, deploymentSummary
));
}
return ResponseEntity.ok(catalog);
}
private String resolveFromEndpointUri(String routeId, List<String> agentIds) {
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
.flatMap(diagramStore::findByContentHash)
.map(RouteGraph::getRoot)
.map(root -> root.getEndpointUri())
.orElse(null);
}
private static String lit(Instant instant) {
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(java.time.ZoneOffset.UTC)
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
}
private static String lit(String value) {
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
}
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";
}
private String compositeHealth(DeploymentStatus deployStatus, String agentHealth) {
if (deployStatus == null) return agentHealth; // unmanaged or no deployment
return switch (deployStatus) {
case STARTING -> "running";
case STOPPING, DEGRADED -> "stale";
case STOPPED -> "dead";
case FAILED -> "error";
case RUNNING -> "offline".equals(agentHealth) ? "stale" : agentHealth;
};
}
private String buildHealthTooltip(boolean managed, DeploymentStatus deployStatus, String agentHealth, int agentCount) {
if (!managed) {
return "Agents: " + agentHealth + " (" + agentCount + " connected)";
}
if (deployStatus == null) {
return "No deployment";
}
String depPart = "Deployment: " + deployStatus.name();
if (deployStatus == DeploymentStatus.RUNNING || deployStatus == DeploymentStatus.DEGRADED) {
return depPart + ", Agents: " + agentHealth + " (" + agentCount + " connected)";
}
return depPart;
}
@DeleteMapping("/{applicationId}")
@Operation(summary = "Dismiss application and purge all data")
@ApiResponse(responseCode = "204", description = "Application dismissed")
@ApiResponse(responseCode = "409", description = "Cannot dismiss — live agents connected")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> dismissApplication(@PathVariable String applicationId) {
// Check for live agents
List<AgentInfo> liveAgents = registryService.findAll().stream()
.filter(a -> applicationId.equals(a.applicationId()))
.filter(a -> a.state() != AgentState.DEAD)
.toList();
if (!liveAgents.isEmpty()) {
return ResponseEntity.status(409).build();
}
// Get tenant ID for scoped deletion
String tenantId = tenantProperties.getId();
// Delete ClickHouse data
deleteClickHouseData(tenantId, applicationId);
// Delete managed app if exists (PostgreSQL)
try {
App app = appService.getBySlug(applicationId);
appService.deleteApp(app.id());
log.info("Dismissed managed app '{}' — deleted PG record and all CH data", applicationId);
} catch (IllegalArgumentException e) {
log.info("Dismissed discovered app '{}' — deleted all CH data", applicationId);
}
return ResponseEntity.noContent().build();
}
private void deleteClickHouseData(String tenantId, String applicationId) {
String[] tablesWithAppId = {
"executions", "processor_executions", "route_diagrams", "agent_events",
"stats_1m_app", "stats_1m_route", "stats_1m_processor_type", "stats_1m_processor",
"stats_1m_processor_detail"
};
for (String table : tablesWithAppId) {
try {
jdbc.execute("ALTER TABLE " + table + " DELETE WHERE tenant_id = " + lit(tenantId) +
" AND application_id = " + lit(applicationId));
} catch (Exception e) {
log.warn("Failed to delete from CH table '{}' for app '{}': {}", table, applicationId, e.getMessage());
}
}
// logs table uses 'application' instead of 'application_id'
try {
jdbc.execute("ALTER TABLE logs DELETE WHERE tenant_id = " + lit(tenantId) +
" AND application = " + lit(applicationId));
} catch (Exception e) {
log.warn("Failed to delete from CH table 'logs' for app '{}': {}", applicationId, e.getMessage());
}
}
}

View File

@@ -0,0 +1,69 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.ingestion.ChunkAccumulator;
import com.cameleer.common.model.ExecutionChunk;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Ingestion endpoint for execution chunk data (ClickHouse pipeline).
* <p>
* Accepts single or array {@link ExecutionChunk} payloads and feeds them
* into the {@link ChunkAccumulator}.
*/
@RestController
@RequestMapping("/api/v1/data")
@ConditionalOnBean(ChunkAccumulator.class)
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
public class ChunkIngestionController {
private static final Logger log = LoggerFactory.getLogger(ChunkIngestionController.class);
private final ChunkAccumulator accumulator;
private final ObjectMapper objectMapper;
public ChunkIngestionController(ChunkAccumulator accumulator) {
this.accumulator = accumulator;
this.objectMapper = new ObjectMapper();
this.objectMapper.registerModule(new JavaTimeModule());
this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
@PostMapping("/executions")
@Operation(summary = "Ingest execution chunk")
public ResponseEntity<Void> ingestChunks(@RequestBody String body) {
try {
String trimmed = body.strip();
List<ExecutionChunk> chunks;
if (trimmed.startsWith("[")) {
chunks = objectMapper.readValue(trimmed, new TypeReference<List<ExecutionChunk>>() {});
} else {
ExecutionChunk single = objectMapper.readValue(trimmed, ExecutionChunk.class);
chunks = List.of(single);
}
for (ExecutionChunk chunk : chunks) {
accumulator.onChunk(chunk);
}
return ResponseEntity.accepted().build();
} catch (Exception e) {
log.warn("Failed to parse execution chunk payload: {}", e.getMessage());
return ResponseEntity.badRequest().build();
}
}
}

View File

@@ -0,0 +1,132 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.rbac.ClaimMappingRepository;
import com.cameleer.server.core.rbac.ClaimMappingRule;
import com.cameleer.server.core.rbac.ClaimMappingService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/admin/claim-mappings")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Claim Mapping Admin", description = "Manage OIDC claim-to-role/group mapping rules")
public class ClaimMappingAdminController {
private final ClaimMappingRepository repository;
private final ClaimMappingService claimMappingService;
public ClaimMappingAdminController(ClaimMappingRepository repository,
ClaimMappingService claimMappingService) {
this.repository = repository;
this.claimMappingService = claimMappingService;
}
@GetMapping
@Operation(summary = "List all claim mapping rules")
public List<ClaimMappingRule> list() {
return repository.findAll();
}
@GetMapping("/{id}")
@Operation(summary = "Get a claim mapping rule by ID")
public ResponseEntity<ClaimMappingRule> get(@PathVariable UUID id) {
return repository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
record CreateRuleRequest(String claim, String matchType, String matchValue,
String action, String target, int priority) {}
@PostMapping
@Operation(summary = "Create a claim mapping rule")
public ResponseEntity<ClaimMappingRule> create(@RequestBody CreateRuleRequest request) {
UUID id = repository.create(
request.claim(), request.matchType(), request.matchValue(),
request.action(), request.target(), request.priority());
return repository.findById(id)
.map(rule -> ResponseEntity.created(URI.create("/api/v1/admin/claim-mappings/" + id)).body(rule))
.orElse(ResponseEntity.internalServerError().build());
}
@PutMapping("/{id}")
@Operation(summary = "Update a claim mapping rule")
public ResponseEntity<ClaimMappingRule> update(@PathVariable UUID id, @RequestBody CreateRuleRequest request) {
if (repository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
repository.update(id, request.claim(), request.matchType(), request.matchValue(),
request.action(), request.target(), request.priority());
return repository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.internalServerError().build());
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete a claim mapping rule")
public ResponseEntity<Void> delete(@PathVariable UUID id) {
if (repository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
repository.delete(id);
return ResponseEntity.noContent().build();
}
record MatchedRuleResponse(String ruleId, int priority, String claim, String matchType,
String matchValue, String action, String target) {}
record TestResponse(List<MatchedRuleResponse> matchedRules, List<String> effectiveRoles,
List<String> effectiveGroups, boolean fallback) {}
record TestRuleRequest(String id, String claim, String matchType, String matchValue,
String action, String target, int priority) {}
record TestRequest(List<TestRuleRequest> rules, Map<String, Object> claims) {}
@PostMapping("/test")
@Operation(summary = "Test claim mapping rules against a set of claims (accepts unsaved rules)")
public TestResponse test(@RequestBody TestRequest request) {
// Build a lookup from synthetic UUID → original string ID (supports temp- prefixed IDs)
Map<UUID, String> idLookup = new HashMap<>();
List<ClaimMappingRule> rules = request.rules().stream()
.map(r -> {
UUID uuid = UUID.randomUUID();
idLookup.put(uuid, r.id());
return new ClaimMappingRule(uuid, r.claim(), r.matchType(), r.matchValue(),
r.action(), r.target(), r.priority(), null);
})
.toList();
List<ClaimMappingService.MappingResult> results = claimMappingService.evaluate(rules, request.claims());
List<MatchedRuleResponse> matched = results.stream()
.map(r -> new MatchedRuleResponse(
idLookup.get(r.rule().id()), r.rule().priority(), r.rule().claim(),
r.rule().matchType(), r.rule().matchValue(),
r.rule().action(), r.rule().target()))
.toList();
List<String> effectiveRoles = results.stream()
.filter(r -> "assignRole".equals(r.rule().action()))
.map(r -> r.rule().target())
.distinct()
.toList();
List<String> effectiveGroups = results.stream()
.filter(r -> "addToGroup".equals(r.rule().action()))
.map(r -> r.rule().target())
.distinct()
.toList();
return new TestResponse(matched, effectiveRoles, effectiveGroups, results.isEmpty());
}
}

View File

@@ -0,0 +1,172 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.ClickHousePerformanceResponse;
import com.cameleer.server.app.dto.ClickHouseQueryInfo;
import com.cameleer.server.app.dto.ClickHouseStatusResponse;
import com.cameleer.server.app.dto.ClickHouseTableInfo;
import com.cameleer.server.app.dto.IndexerPipelineResponse;
import com.cameleer.server.core.indexing.SearchIndexerStats;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@ConditionalOnProperty(
name = "cameleer.server.security.infrastructureendpoints",
havingValue = "true",
matchIfMissing = true
)
@RestController
@RequestMapping("/api/v1/admin/clickhouse")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "ClickHouse Admin", description = "ClickHouse monitoring and diagnostics (ADMIN only)")
public class ClickHouseAdminController {
private final JdbcTemplate clickHouseJdbc;
private final SearchIndexerStats indexerStats;
private final String clickHouseUrl;
public ClickHouseAdminController(
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc,
SearchIndexerStats indexerStats,
@Value("${cameleer.server.clickhouse.url:}") String clickHouseUrl) {
this.clickHouseJdbc = clickHouseJdbc;
this.indexerStats = indexerStats;
this.clickHouseUrl = clickHouseUrl;
}
@GetMapping("/status")
@Operation(summary = "ClickHouse cluster status")
public ClickHouseStatusResponse getStatus() {
try {
var row = clickHouseJdbc.queryForMap(
"SELECT version() AS version, formatReadableTimeDelta(uptime()) AS uptime");
return new ClickHouseStatusResponse(true,
(String) row.get("version"),
(String) row.get("uptime"),
clickHouseUrl);
} catch (Exception e) {
return new ClickHouseStatusResponse(false, null, null, clickHouseUrl);
}
}
@GetMapping("/tables")
@Operation(summary = "List ClickHouse tables with sizes")
public List<ClickHouseTableInfo> getTables() {
return clickHouseJdbc.query("""
SELECT t.name, t.engine,
t.total_rows AS row_count,
formatReadableSize(t.total_bytes) AS data_size,
t.total_bytes AS data_size_bytes,
ifNull(p.partition_count, 0) AS partition_count
FROM system.tables t
LEFT JOIN (
SELECT table, countDistinct(partition) AS partition_count
FROM system.parts
WHERE database = currentDatabase() AND active
GROUP BY table
) p ON t.name = p.table
WHERE t.database = currentDatabase()
ORDER BY t.total_bytes DESC NULLS LAST
""",
(rs, rowNum) -> new ClickHouseTableInfo(
rs.getString("name"),
rs.getString("engine"),
rs.getLong("row_count"),
rs.getString("data_size"),
rs.getLong("data_size_bytes"),
rs.getInt("partition_count")));
}
@GetMapping("/performance")
@Operation(summary = "ClickHouse storage and performance metrics")
public ClickHousePerformanceResponse getPerformance() {
try {
var row = clickHouseJdbc.queryForMap("""
SELECT
formatReadableSize(sum(bytes_on_disk)) AS disk_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
if(sum(data_uncompressed_bytes) > 0,
round(sum(bytes_on_disk) / sum(data_uncompressed_bytes), 3), 0) AS compression_ratio,
sum(rows) AS total_rows,
count() AS part_count
FROM system.parts
WHERE database = currentDatabase() AND active
""");
String memory = "N/A";
try {
memory = clickHouseJdbc.queryForObject(
"SELECT formatReadableSize(value) FROM system.metrics WHERE metric = 'MemoryTracking'",
String.class);
} catch (Exception ignored) {}
int currentQueries = 0;
try {
Integer q = clickHouseJdbc.queryForObject(
"SELECT toInt32(value) FROM system.metrics WHERE metric = 'Query'",
Integer.class);
if (q != null) currentQueries = q;
} catch (Exception ignored) {}
return new ClickHousePerformanceResponse(
(String) row.get("disk_size"),
(String) row.get("uncompressed_size"),
((Number) row.get("compression_ratio")).doubleValue(),
((Number) row.get("total_rows")).longValue(),
((Number) row.get("part_count")).intValue(),
memory != null ? memory : "N/A",
currentQueries);
} catch (Exception e) {
return new ClickHousePerformanceResponse("N/A", "N/A", 0, 0, 0, "N/A", 0);
}
}
@GetMapping("/queries")
@Operation(summary = "Active ClickHouse queries")
public List<ClickHouseQueryInfo> getQueries() {
try {
return clickHouseJdbc.query("""
SELECT
query_id,
round(elapsed, 2) AS elapsed_seconds,
formatReadableSize(memory_usage) AS memory,
read_rows,
substring(query, 1, 200) AS query
FROM system.processes
WHERE is_initial_query = 1
AND query NOT LIKE '%system.processes%'
ORDER BY elapsed DESC
""",
(rs, rowNum) -> new ClickHouseQueryInfo(
rs.getString("query_id"),
rs.getDouble("elapsed_seconds"),
rs.getString("memory"),
rs.getLong("read_rows"),
rs.getString("query")));
} catch (Exception e) {
return List.of();
}
}
@GetMapping("/pipeline")
@Operation(summary = "Search indexer pipeline statistics")
public IndexerPipelineResponse getPipeline() {
return new IndexerPipelineResponse(
indexerStats.getQueueDepth(),
indexerStats.getMaxQueueSize(),
indexerStats.getFailedCount(),
indexerStats.getIndexedCount(),
indexerStats.getDebounceMs(),
indexerStats.getIndexingRate(),
indexerStats.getLastIndexedAt());
}
}

View File

@@ -0,0 +1,137 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.ActiveQueryResponse;
import com.cameleer.server.app.dto.ConnectionPoolResponse;
import com.cameleer.server.app.dto.DatabaseStatusResponse;
import com.cameleer.server.app.dto.TableSizeResponse;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.HikariPoolMXBean;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import javax.sql.DataSource;
import java.util.List;
@ConditionalOnProperty(
name = "cameleer.server.security.infrastructureendpoints",
havingValue = "true",
matchIfMissing = true
)
@RestController
@RequestMapping("/api/v1/admin/database")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Database Admin", description = "Database monitoring and management (ADMIN only)")
public class DatabaseAdminController {
private final JdbcTemplate jdbc;
private final DataSource dataSource;
private final AuditService auditService;
public DatabaseAdminController(JdbcTemplate jdbc, DataSource dataSource,
AuditService auditService) {
this.jdbc = jdbc;
this.dataSource = dataSource;
this.auditService = auditService;
}
@GetMapping("/status")
@Operation(summary = "Get database connection status and version")
public ResponseEntity<DatabaseStatusResponse> getStatus() {
try {
String version = jdbc.queryForObject("SELECT version()", String.class);
String schema = jdbc.queryForObject("SELECT current_schema()", String.class);
String host = extractHost(dataSource);
return ResponseEntity.ok(new DatabaseStatusResponse(true, version, host, schema));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new DatabaseStatusResponse(false, null, null, null));
}
}
@GetMapping("/pool")
@Operation(summary = "Get HikariCP connection pool stats")
public ResponseEntity<ConnectionPoolResponse> getPool() {
HikariDataSource hds = (HikariDataSource) dataSource;
HikariPoolMXBean pool = hds.getHikariPoolMXBean();
return ResponseEntity.ok(new ConnectionPoolResponse(
pool.getActiveConnections(), pool.getIdleConnections(),
pool.getThreadsAwaitingConnection(), hds.getConnectionTimeout(),
hds.getMaximumPoolSize()));
}
@GetMapping("/tables")
@Operation(summary = "Get table sizes and row counts")
public ResponseEntity<List<TableSizeResponse>> getTables() {
var tables = jdbc.query("""
SELECT relname AS table_name,
n_live_tup AS row_count,
pg_size_pretty(pg_total_relation_size(relid)) AS data_size,
pg_total_relation_size(relid) AS data_size_bytes,
pg_size_pretty(pg_indexes_size(relid)) AS index_size,
pg_indexes_size(relid) AS index_size_bytes
FROM pg_stat_user_tables
WHERE schemaname = current_schema()
ORDER BY pg_total_relation_size(relid) DESC
""", (rs, row) -> new TableSizeResponse(
rs.getString("table_name"), rs.getLong("row_count"),
rs.getString("data_size"), rs.getString("index_size"),
rs.getLong("data_size_bytes"), rs.getLong("index_size_bytes")));
return ResponseEntity.ok(tables);
}
@GetMapping("/queries")
@Operation(summary = "Get active queries")
public ResponseEntity<List<ActiveQueryResponse>> getQueries() {
var queries = jdbc.query("""
SELECT pid, EXTRACT(EPOCH FROM (now() - query_start)) AS duration_seconds,
state, query
FROM pg_stat_activity
WHERE state != 'idle' AND pid != pg_backend_pid() AND datname = current_database()
AND application_name = current_setting('application_name')
ORDER BY query_start ASC
""", (rs, row) -> new ActiveQueryResponse(
rs.getInt("pid"), rs.getDouble("duration_seconds"),
rs.getString("state"), rs.getString("query")));
return ResponseEntity.ok(queries);
}
@PostMapping("/queries/{pid}/kill")
@Operation(summary = "Terminate a query by PID")
public ResponseEntity<Void> killQuery(@PathVariable int pid, HttpServletRequest request) {
var exists = jdbc.queryForObject(
"SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE pid = ? AND pid != pg_backend_pid() AND application_name = current_setting('application_name'))",
Boolean.class, pid);
if (!Boolean.TRUE.equals(exists)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No active query with PID " + pid);
}
jdbc.queryForObject("SELECT pg_terminate_backend(?)", Boolean.class, pid);
auditService.log("kill_query", AuditCategory.INFRA, "PID " + pid, null, AuditResult.SUCCESS, request);
return ResponseEntity.ok().build();
}
private String extractHost(DataSource ds) {
try {
if (ds instanceof HikariDataSource hds) {
return hds.getJdbcUrl();
}
return "unknown";
} catch (Exception e) {
return "unknown";
}
}
}

View File

@@ -0,0 +1,135 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.runtime.DeploymentExecutor;
import com.cameleer.server.core.runtime.*;
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.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Deployment management: deploy, stop, promote, and view logs.
* All app-scoped endpoints accept the app slug (not UUID) as path variable.
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}.
*/
@RestController
@RequestMapping("/api/v1/apps/{appSlug}/deployments")
@Tag(name = "Deployment Management", description = "Deploy, stop, restart, promote, and view logs")
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
public class DeploymentController {
private final DeploymentService deploymentService;
private final DeploymentExecutor deploymentExecutor;
private final RuntimeOrchestrator orchestrator;
private final AppService appService;
public DeploymentController(DeploymentService deploymentService,
DeploymentExecutor deploymentExecutor,
RuntimeOrchestrator orchestrator,
AppService appService) {
this.deploymentService = deploymentService;
this.deploymentExecutor = deploymentExecutor;
this.orchestrator = orchestrator;
this.appService = appService;
}
@GetMapping
@Operation(summary = "List deployments for an app")
@ApiResponse(responseCode = "200", description = "Deployment list returned")
public ResponseEntity<List<Deployment>> listDeployments(@PathVariable String appSlug) {
try {
App app = appService.getBySlug(appSlug);
return ResponseEntity.ok(deploymentService.listByApp(app.id()));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@GetMapping("/{deploymentId}")
@Operation(summary = "Get deployment by ID")
@ApiResponse(responseCode = "200", description = "Deployment found")
@ApiResponse(responseCode = "404", description = "Deployment not found")
public ResponseEntity<Deployment> getDeployment(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
try {
return ResponseEntity.ok(deploymentService.getById(deploymentId));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping
@Operation(summary = "Create and start a new deployment")
@ApiResponse(responseCode = "202", description = "Deployment accepted and starting")
public ResponseEntity<Deployment> deploy(@PathVariable String appSlug, @RequestBody DeployRequest request) {
try {
App app = appService.getBySlug(appSlug);
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), request.environmentId());
deploymentExecutor.executeAsync(deployment);
return ResponseEntity.accepted().body(deployment);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/{deploymentId}/stop")
@Operation(summary = "Stop a running deployment")
@ApiResponse(responseCode = "200", description = "Deployment stopped")
@ApiResponse(responseCode = "404", description = "Deployment not found")
public ResponseEntity<Deployment> stop(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
try {
Deployment deployment = deploymentService.getById(deploymentId);
deploymentExecutor.stopDeployment(deployment);
return ResponseEntity.ok(deploymentService.getById(deploymentId));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/{deploymentId}/promote")
@Operation(summary = "Promote deployment to a different environment")
@ApiResponse(responseCode = "202", description = "Promotion accepted and starting")
@ApiResponse(responseCode = "404", description = "Deployment not found")
public ResponseEntity<Deployment> promote(@PathVariable String appSlug, @PathVariable UUID deploymentId,
@RequestBody PromoteRequest request) {
try {
App app = appService.getBySlug(appSlug);
Deployment source = deploymentService.getById(deploymentId);
Deployment promoted = deploymentService.promote(app.id(), source.appVersionId(), request.targetEnvironmentId());
deploymentExecutor.executeAsync(promoted);
return ResponseEntity.accepted().body(promoted);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@GetMapping("/{deploymentId}/logs")
@Operation(summary = "Get container logs for a deployment")
@ApiResponse(responseCode = "200", description = "Logs returned")
@ApiResponse(responseCode = "404", description = "Deployment not found or no container")
public ResponseEntity<List<String>> getLogs(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
try {
Deployment deployment = deploymentService.getById(deploymentId);
if (deployment.containerId() == null) {
return ResponseEntity.notFound().build();
}
List<String> logs = orchestrator.getLogs(deployment.containerId(), 200).collect(Collectors.toList());
return ResponseEntity.ok(logs);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
public record DeployRequest(UUID appVersionId, UUID environmentId) {}
public record PromoteRequest(UUID targetEnvironmentId) {}
}

View File

@@ -0,0 +1,96 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.detail.DetailService;
import com.cameleer.server.core.detail.ExecutionDetail;
import com.cameleer.server.core.storage.ExecutionStore;
import com.cameleer.server.core.storage.ExecutionStore.ProcessorRecord;
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.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Endpoints for retrieving execution details and processor snapshots.
* <p>
* The detail endpoint returns a nested processor tree reconstructed from
* individual processor records stored in PostgreSQL. The snapshot endpoint
* returns per-processor exchange data (bodies and headers).
*/
@RestController
@RequestMapping("/api/v1/executions")
@Tag(name = "Detail", description = "Execution detail and processor snapshot endpoints")
public class DetailController {
private final DetailService detailService;
private final ExecutionStore executionStore;
public DetailController(DetailService detailService,
ExecutionStore executionStore) {
this.detailService = detailService;
this.executionStore = executionStore;
}
@GetMapping("/{executionId}")
@Operation(summary = "Get execution detail with nested processor tree")
@ApiResponse(responseCode = "200", description = "Execution detail found")
@ApiResponse(responseCode = "404", description = "Execution not found")
public ResponseEntity<ExecutionDetail> getDetail(@PathVariable String executionId) {
return detailService.getDetail(executionId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{executionId}/processors/{index}/snapshot")
@Operation(summary = "Get exchange snapshot for a specific processor by index")
@ApiResponse(responseCode = "200", description = "Snapshot data")
@ApiResponse(responseCode = "404", description = "Snapshot not found")
public ResponseEntity<Map<String, String>> getProcessorSnapshot(
@PathVariable String executionId,
@PathVariable int index) {
List<ProcessorRecord> processors = executionStore.findProcessors(executionId);
if (index < 0 || index >= processors.size()) {
return ResponseEntity.notFound().build();
}
ProcessorRecord p = processors.get(index);
Map<String, String> snapshot = new LinkedHashMap<>();
if (p.inputBody() != null) snapshot.put("inputBody", p.inputBody());
if (p.outputBody() != null) snapshot.put("outputBody", p.outputBody());
if (p.inputHeaders() != null) snapshot.put("inputHeaders", p.inputHeaders());
if (p.outputHeaders() != null) snapshot.put("outputHeaders", p.outputHeaders());
return ResponseEntity.ok(snapshot);
}
@GetMapping("/{executionId}/processors/by-id/{processorId}/snapshot")
@Operation(summary = "Get exchange snapshot for a specific processor by processorId")
@ApiResponse(responseCode = "200", description = "Snapshot data")
@ApiResponse(responseCode = "404", description = "Snapshot not found")
public ResponseEntity<Map<String, String>> processorSnapshotById(
@PathVariable String executionId,
@PathVariable String processorId) {
return detailService.getProcessorSnapshot(executionId, processorId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{executionId}/processors/by-seq/{seq}/snapshot")
@Operation(summary = "Get exchange snapshot for a processor by seq number")
@ApiResponse(responseCode = "200", description = "Snapshot data")
@ApiResponse(responseCode = "404", description = "Snapshot not found")
public ResponseEntity<Map<String, String>> processorSnapshotBySeq(
@PathVariable String executionId,
@PathVariable int seq) {
return detailService.getProcessorSnapshotBySeq(executionId, seq)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,82 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.graph.RouteGraph;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.ingestion.IngestionService;
import com.cameleer.server.core.ingestion.TaggedDiagram;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
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.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Ingestion endpoint for route diagrams.
* <p>
* Accepts both single {@link RouteGraph} and arrays. Data is written
* synchronously to PostgreSQL via {@link IngestionService}.
*/
@RestController
@RequestMapping("/api/v1/data")
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
public class DiagramController {
private final IngestionService ingestionService;
private final AgentRegistryService registryService;
private final ObjectMapper objectMapper;
public DiagramController(IngestionService ingestionService,
AgentRegistryService registryService,
ObjectMapper objectMapper) {
this.ingestionService = ingestionService;
this.registryService = registryService;
this.objectMapper = objectMapper;
}
@PostMapping("/diagrams")
@Operation(summary = "Ingest route diagram data",
description = "Accepts a single RouteGraph or an array of RouteGraphs")
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
public ResponseEntity<Void> ingestDiagrams(@RequestBody String body) throws JsonProcessingException {
String instanceId = extractAgentId();
String applicationId = resolveApplicationId(instanceId);
List<RouteGraph> graphs = parsePayload(body);
for (RouteGraph graph : graphs) {
ingestionService.ingestDiagram(new TaggedDiagram(instanceId, applicationId, graph));
}
return ResponseEntity.accepted().build();
}
private String extractAgentId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "";
}
private String resolveApplicationId(String instanceId) {
AgentInfo agent = registryService.findById(instanceId);
return agent != null ? agent.applicationId() : "";
}
private List<RouteGraph> parsePayload(String body) throws JsonProcessingException {
String trimmed = body.strip();
if (trimmed.startsWith("[")) {
return objectMapper.readValue(trimmed, new TypeReference<>() {});
} else {
RouteGraph single = objectMapper.readValue(trimmed, RouteGraph.class);
return List.of(single);
}
}
}

View File

@@ -0,0 +1,138 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.graph.RouteGraph;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.diagram.DiagramLayout;
import com.cameleer.server.core.diagram.DiagramRenderer;
import com.cameleer.server.core.storage.DiagramStore;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Optional;
/**
* REST endpoint for rendering route diagrams.
* <p>
* Supports content negotiation via Accept header:
* <ul>
* <li>{@code image/svg+xml} or default: returns SVG document</li>
* <li>{@code application/json}: returns JSON layout with node positions</li>
* </ul>
*/
@RestController
@RequestMapping("/api/v1/diagrams")
@Tag(name = "Diagrams", description = "Diagram rendering endpoints")
public class DiagramRenderController {
private static final MediaType SVG_MEDIA_TYPE = MediaType.valueOf("image/svg+xml");
private final DiagramStore diagramStore;
private final DiagramRenderer diagramRenderer;
private final AgentRegistryService registryService;
public DiagramRenderController(DiagramStore diagramStore,
DiagramRenderer diagramRenderer,
AgentRegistryService registryService) {
this.diagramStore = diagramStore;
this.diagramRenderer = diagramRenderer;
this.registryService = registryService;
}
@GetMapping("/{contentHash}/render")
@Operation(summary = "Render a route diagram",
description = "Returns SVG (default) or JSON layout based on Accept header")
@ApiResponse(responseCode = "200", description = "Diagram rendered successfully",
content = {
@Content(mediaType = "image/svg+xml", schema = @Schema(type = "string")),
@Content(mediaType = "application/json", schema = @Schema(implementation = DiagramLayout.class))
})
@ApiResponse(responseCode = "404", description = "Diagram not found")
public ResponseEntity<?> renderDiagram(
@PathVariable String contentHash,
@RequestParam(defaultValue = "LR") String direction,
HttpServletRequest request) {
Optional<RouteGraph> graphOpt = diagramStore.findByContentHash(contentHash);
if (graphOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
RouteGraph graph = graphOpt.get();
String accept = request.getHeader("Accept");
// Return JSON only when the client explicitly requests application/json
// without also accepting everything (*/*). This means "application/json"
// must appear and wildcards must not dominate the preference.
if (accept != null && isJsonPreferred(accept)) {
DiagramLayout layout = diagramRenderer.layoutJson(graph, direction);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(layout);
}
// Default to SVG for image/svg+xml, */* or no Accept header
String svg = diagramRenderer.renderSvg(graph);
return ResponseEntity.ok()
.contentType(SVG_MEDIA_TYPE)
.body(svg);
}
@GetMapping
@Operation(summary = "Find diagram by application and route ID",
description = "Resolves application to agent IDs and finds the latest diagram for the route")
@ApiResponse(responseCode = "200", description = "Diagram layout returned")
@ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
@RequestParam String application,
@RequestParam String routeId,
@RequestParam(defaultValue = "LR") String direction) {
List<String> agentIds = registryService.findByApplication(application).stream()
.map(AgentInfo::instanceId)
.toList();
if (agentIds.isEmpty()) {
return ResponseEntity.notFound().build();
}
Optional<String> contentHash = diagramStore.findContentHashForRouteByAgents(routeId, agentIds);
if (contentHash.isEmpty()) {
return ResponseEntity.notFound().build();
}
Optional<RouteGraph> graphOpt = diagramStore.findByContentHash(contentHash.get());
if (graphOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
DiagramLayout layout = diagramRenderer.layoutJson(graphOpt.get(), direction);
return ResponseEntity.ok(layout);
}
/**
* Determine if JSON is the explicitly preferred format.
* <p>
* Returns true only when the first media type in the Accept header is
* "application/json". Clients sending broad Accept lists like
* "text/plain, application/json, *&#47;*" are treated as unspecific
* and receive the SVG default.
*/
private boolean isJsonPreferred(String accept) {
String[] parts = accept.split(",");
if (parts.length == 0) return false;
String first = parts[0].trim().split(";")[0].trim();
return "application/json".equalsIgnoreCase(first);
}
}

View File

@@ -0,0 +1,148 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentService;
import com.cameleer.server.core.runtime.RuntimeType;
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.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/admin/environments")
@Tag(name = "Environment Admin", description = "Environment management (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class EnvironmentAdminController {
private final EnvironmentService environmentService;
public EnvironmentAdminController(EnvironmentService environmentService) {
this.environmentService = environmentService;
}
@GetMapping
@Operation(summary = "List all environments")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<List<Environment>> listEnvironments() {
return ResponseEntity.ok(environmentService.listAll());
}
@GetMapping("/{id}")
@Operation(summary = "Get environment by ID")
@ApiResponse(responseCode = "200", description = "Environment found")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<Environment> getEnvironment(@PathVariable UUID id) {
try {
return ResponseEntity.ok(environmentService.getById(id));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping
@Operation(summary = "Create a new environment")
@ApiResponse(responseCode = "201", description = "Environment created")
@ApiResponse(responseCode = "400", description = "Slug already exists")
public ResponseEntity<?> createEnvironment(@RequestBody CreateEnvironmentRequest request) {
try {
UUID id = environmentService.create(request.slug(), request.displayName(), request.production());
return ResponseEntity.status(201).body(environmentService.getById(id));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PutMapping("/{id}")
@Operation(summary = "Update an environment")
@ApiResponse(responseCode = "200", description = "Environment updated")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> updateEnvironment(@PathVariable UUID id, @RequestBody UpdateEnvironmentRequest request) {
try {
environmentService.update(id, request.displayName(), request.production(), request.enabled());
return ResponseEntity.ok(environmentService.getById(id));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete an environment")
@ApiResponse(responseCode = "204", description = "Environment deleted")
@ApiResponse(responseCode = "400", description = "Cannot delete default environment")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> deleteEnvironment(@PathVariable UUID id) {
try {
environmentService.delete(id);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
private static final java.util.regex.Pattern CUSTOM_ARGS_PATTERN =
java.util.regex.Pattern.compile("^[-a-zA-Z0-9_.=:/\\s+\"']*$");
private void validateContainerConfig(Map<String, Object> config) {
Object customArgs = config.get("customArgs");
if (customArgs instanceof String s && !s.isBlank() && !CUSTOM_ARGS_PATTERN.matcher(s).matches()) {
throw new IllegalArgumentException("customArgs contains invalid characters. Only JVM-style arguments are allowed.");
}
Object runtimeType = config.get("runtimeType");
if (runtimeType instanceof String s && !s.isBlank() && RuntimeType.fromString(s) == null) {
throw new IllegalArgumentException("Invalid runtimeType: " + s +
". Must be one of: auto, spring-boot, quarkus, plain-java, native");
}
}
@PutMapping("/{id}/default-container-config")
@Operation(summary = "Update default container config for an environment")
@ApiResponse(responseCode = "200", description = "Default container config updated")
@ApiResponse(responseCode = "400", description = "Invalid configuration")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> updateDefaultContainerConfig(@PathVariable UUID id,
@RequestBody Map<String, Object> defaultContainerConfig) {
try {
validateContainerConfig(defaultContainerConfig);
environmentService.updateDefaultContainerConfig(id, defaultContainerConfig);
return ResponseEntity.ok(environmentService.getById(id));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PutMapping("/{id}/jar-retention")
@Operation(summary = "Update JAR retention policy for an environment")
@ApiResponse(responseCode = "200", description = "Retention policy updated")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> updateJarRetention(@PathVariable UUID id,
@RequestBody JarRetentionRequest request) {
try {
environmentService.updateJarRetentionCount(id, request.jarRetentionCount());
return ResponseEntity.ok(environmentService.getById(id));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
public record CreateEnvironmentRequest(String slug, String displayName, boolean production) {}
public record UpdateEnvironmentRequest(String displayName, boolean production, boolean enabled) {}
public record JarRetentionRequest(Integer jarRetentionCount) {}
}

View File

@@ -0,0 +1,119 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.model.AgentEvent;
import com.cameleer.server.core.agent.AgentEventService;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.RouteStateRegistry;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* Ingestion endpoint for agent lifecycle events.
* <p>
* Agents emit events (AGENT_STARTED, AGENT_STOPPED, etc.) which are
* stored in the event log. AGENT_STOPPED triggers a graceful shutdown
* transition in the registry.
*/
@RestController
@RequestMapping("/api/v1/data")
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
public class EventIngestionController {
private static final Logger log = LoggerFactory.getLogger(EventIngestionController.class);
private final AgentEventService agentEventService;
private final AgentRegistryService registryService;
private final ObjectMapper objectMapper;
private final RouteStateRegistry routeStateRegistry;
public EventIngestionController(AgentEventService agentEventService,
AgentRegistryService registryService,
ObjectMapper objectMapper,
RouteStateRegistry routeStateRegistry) {
this.agentEventService = agentEventService;
this.registryService = registryService;
this.objectMapper = objectMapper;
this.routeStateRegistry = routeStateRegistry;
}
@PostMapping("/events")
@Operation(summary = "Ingest agent events")
public ResponseEntity<Void> ingestEvents(@RequestBody String body) {
String instanceId = extractInstanceId();
List<AgentEvent> events;
try {
String trimmed = body.strip();
if (trimmed.startsWith("[")) {
events = objectMapper.readValue(trimmed, new TypeReference<List<AgentEvent>>() {});
} else {
events = List.of(objectMapper.readValue(trimmed, AgentEvent.class));
}
} catch (Exception e) {
log.warn("Failed to parse event payload: {}", e.getMessage());
return ResponseEntity.badRequest().build();
}
AgentInfo agent = registryService.findById(instanceId);
String applicationId = agent != null ? agent.applicationId() : "";
for (AgentEvent event : events) {
agentEventService.recordEvent(instanceId, applicationId,
event.getEventType(),
event.getDetails() != null ? event.getDetails().toString() : null);
if ("AGENT_STOPPED".equals(event.getEventType())) {
log.info("Agent {} reported graceful shutdown", instanceId);
registryService.shutdown(instanceId);
}
if ("ROUTE_STATE_CHANGED".equals(event.getEventType())) {
Map<String, String> details = event.getDetails();
if (details != null) {
String routeId = details.get("routeId");
String newState = details.get("newState");
if (routeId != null && newState != null) {
RouteStateRegistry.RouteState state = parseRouteState(newState);
if (state != null) {
routeStateRegistry.setState(applicationId, routeId, state);
log.debug("Route state changed: {}/{} -> {} (reason: {})",
applicationId, routeId, newState, details.get("reason"));
}
}
}
}
}
return ResponseEntity.accepted().build();
}
private RouteStateRegistry.RouteState parseRouteState(String state) {
if (state == null) return null;
return switch (state) {
case "Started" -> RouteStateRegistry.RouteState.STARTED;
case "Stopped" -> RouteStateRegistry.RouteState.STOPPED;
case "Suspended" -> RouteStateRegistry.RouteState.SUSPENDED;
default -> null;
};
}
private String extractInstanceId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "";
}
}

View File

@@ -0,0 +1,87 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.model.RouteExecution;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.ingestion.ChunkAccumulator;
import com.cameleer.server.core.ingestion.IngestionService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
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.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Legacy ingestion endpoint for route execution data (PostgreSQL path).
* <p>
* Accepts both single {@link RouteExecution} and arrays. Data is written
* synchronously to PostgreSQL via {@link IngestionService}.
* <p>
* Only active when ClickHouse is disabled — when ClickHouse is enabled,
* {@link ChunkIngestionController} takes over the {@code /executions} mapping.
*/
@RestController
@RequestMapping("/api/v1/data")
@ConditionalOnMissingBean(ChunkAccumulator.class)
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
public class ExecutionController {
private final IngestionService ingestionService;
private final AgentRegistryService registryService;
private final ObjectMapper objectMapper;
public ExecutionController(IngestionService ingestionService,
AgentRegistryService registryService,
ObjectMapper objectMapper) {
this.ingestionService = ingestionService;
this.registryService = registryService;
this.objectMapper = objectMapper;
}
@PostMapping("/executions")
@Operation(summary = "Ingest route execution data",
description = "Accepts a single RouteExecution or an array of RouteExecutions")
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException {
String instanceId = extractAgentId();
String applicationId = resolveApplicationId(instanceId);
List<RouteExecution> executions = parsePayload(body);
for (RouteExecution execution : executions) {
ingestionService.ingestExecution(instanceId, applicationId, execution);
}
return ResponseEntity.accepted().build();
}
private String extractAgentId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "";
}
private String resolveApplicationId(String instanceId) {
AgentInfo agent = registryService.findById(instanceId);
return agent != null ? agent.applicationId() : "";
}
private List<RouteExecution> parsePayload(String body) throws JsonProcessingException {
String trimmed = body.strip();
if (trimmed.startsWith("[")) {
return objectMapper.readValue(trimmed, new TypeReference<>() {});
} else {
RouteExecution single = objectMapper.readValue(trimmed, RouteExecution.class);
return List.of(single);
}
}
}

View File

@@ -0,0 +1,174 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.rbac.GroupDetail;
import com.cameleer.server.core.rbac.GroupRepository;
import com.cameleer.server.core.rbac.GroupSummary;
import com.cameleer.server.core.rbac.RbacService;
import com.cameleer.server.core.rbac.SystemRole;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* Admin endpoints for group management.
* Protected by {@code ROLE_ADMIN}.
*/
@RestController
@RequestMapping("/api/v1/admin/groups")
@Tag(name = "Group Admin", description = "Group management (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class GroupAdminController {
private final GroupRepository groupRepository;
private final AuditService auditService;
private final RbacService rbacService;
public GroupAdminController(GroupRepository groupRepository, AuditService auditService,
RbacService rbacService) {
this.groupRepository = groupRepository;
this.auditService = auditService;
this.rbacService = rbacService;
}
@GetMapping
@Operation(summary = "List all groups with hierarchy and effective roles")
@ApiResponse(responseCode = "200", description = "Group list returned")
public ResponseEntity<List<GroupDetail>> listGroups() {
List<GroupSummary> summaries = groupRepository.findAll();
List<GroupDetail> details = new ArrayList<>();
for (GroupSummary summary : summaries) {
groupRepository.findById(summary.id()).ifPresent(details::add);
}
return ResponseEntity.ok(details);
}
@GetMapping("/{id}")
@Operation(summary = "Get group by ID with effective roles")
@ApiResponse(responseCode = "200", description = "Group found")
@ApiResponse(responseCode = "404", description = "Group not found")
public ResponseEntity<GroupDetail> getGroup(@PathVariable UUID id) {
return groupRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@Operation(summary = "Create a new group")
@ApiResponse(responseCode = "200", description = "Group created")
public ResponseEntity<Map<String, UUID>> createGroup(@RequestBody CreateGroupRequest request,
HttpServletRequest httpRequest) {
UUID id = groupRepository.create(request.name(), request.parentGroupId());
auditService.log("create_group", AuditCategory.RBAC, id.toString(),
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(Map.of("id", id));
}
@PutMapping("/{id}")
@Operation(summary = "Update group name or parent")
@ApiResponse(responseCode = "200", description = "Group updated")
@ApiResponse(responseCode = "404", description = "Group not found")
@ApiResponse(responseCode = "409", description = "Cycle detected in group hierarchy")
public ResponseEntity<Void> updateGroup(@PathVariable UUID id,
@RequestBody UpdateGroupRequest request,
HttpServletRequest httpRequest) {
Optional<GroupDetail> existing = groupRepository.findById(id);
if (existing.isEmpty()) {
return ResponseEntity.notFound().build();
}
// Cycle detection: walk ancestor chain of proposed parent and check if it includes 'id'
if (request.parentGroupId() != null) {
List<GroupSummary> ancestors = groupRepository.findAncestorChain(request.parentGroupId());
for (GroupSummary ancestor : ancestors) {
if (ancestor.id().equals(id)) {
return ResponseEntity.status(409).build();
}
}
// Also check that the proposed parent itself is not the group being updated
if (request.parentGroupId().equals(id)) {
return ResponseEntity.status(409).build();
}
}
groupRepository.update(id, request.name(), request.parentGroupId());
auditService.log("update_group", AuditCategory.RBAC, id.toString(),
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete group")
@ApiResponse(responseCode = "204", description = "Group deleted")
@ApiResponse(responseCode = "404", description = "Group not found")
public ResponseEntity<Void> deleteGroup(@PathVariable UUID id,
HttpServletRequest httpRequest) {
if (groupRepository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
groupRepository.delete(id);
auditService.log("delete_group", AuditCategory.RBAC, id.toString(),
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/roles/{roleId}")
@Operation(summary = "Assign a role to a group")
@ApiResponse(responseCode = "200", description = "Role assigned to group")
@ApiResponse(responseCode = "404", description = "Group not found")
public ResponseEntity<Void> assignRoleToGroup(@PathVariable UUID id,
@PathVariable UUID roleId,
HttpServletRequest httpRequest) {
if (groupRepository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
groupRepository.addRole(id, roleId);
auditService.log("assign_role_to_group", AuditCategory.RBAC, id.toString(),
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{id}/roles/{roleId}")
@Operation(summary = "Remove a role from a group")
@ApiResponse(responseCode = "204", description = "Role removed from group")
@ApiResponse(responseCode = "404", description = "Group not found")
public ResponseEntity<Void> removeRoleFromGroup(@PathVariable UUID id,
@PathVariable UUID roleId,
HttpServletRequest httpRequest) {
if (groupRepository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
if (SystemRole.ADMIN_ID.equals(roleId) && rbacService.getEffectivePrincipalsForRole(SystemRole.ADMIN_ID).size() <= 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Cannot remove the ADMIN role: at least one admin user must exist");
}
groupRepository.removeRole(id, roleId);
auditService.log("remove_role_from_group", AuditCategory.RBAC, id.toString(),
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
public record CreateGroupRequest(String name, UUID parentGroupId) {}
public record UpdateGroupRequest(String name, UUID parentGroupId) {}
}

View File

@@ -0,0 +1,53 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/admin/license")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "License Admin", description = "License management")
public class LicenseAdminController {
private final LicenseGate licenseGate;
private final String licensePublicKey;
public LicenseAdminController(LicenseGate licenseGate,
@Value("${cameleer.server.license.publickey:}") String licensePublicKey) {
this.licenseGate = licenseGate;
this.licensePublicKey = licensePublicKey;
}
@GetMapping
@Operation(summary = "Get current license info")
public ResponseEntity<LicenseInfo> getCurrent() {
return ResponseEntity.ok(licenseGate.getCurrent());
}
record UpdateLicenseRequest(String token) {}
@PostMapping
@Operation(summary = "Update license token at runtime")
public ResponseEntity<?> update(@RequestBody UpdateLicenseRequest request) {
if (licensePublicKey == null || licensePublicKey.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "No license public key configured"));
}
try {
LicenseValidator validator = new LicenseValidator(licensePublicKey);
LicenseInfo info = validator.validate(request.token());
licenseGate.load(info);
return ResponseEntity.ok(info);
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}

View File

@@ -0,0 +1,131 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.model.LogEntry;
import com.cameleer.server.app.metrics.ServerMetrics;
import com.cameleer.server.app.security.JwtAuthenticationFilter;
import com.cameleer.server.core.ingestion.BufferedLogEntry;
import java.util.List;
import com.cameleer.server.core.ingestion.WriteBuffer;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.JwtService.JwtValidationResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import com.cameleer.server.app.config.TenantProperties;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/data")
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
public class LogIngestionController {
private static final Logger log = LoggerFactory.getLogger(LogIngestionController.class);
private final WriteBuffer<BufferedLogEntry> logBuffer;
private final AgentRegistryService registryService;
private final TenantProperties tenantProperties;
private final ServerMetrics serverMetrics;
public LogIngestionController(WriteBuffer<BufferedLogEntry> logBuffer,
AgentRegistryService registryService,
TenantProperties tenantProperties,
ServerMetrics serverMetrics) {
this.logBuffer = logBuffer;
this.registryService = registryService;
this.tenantProperties = tenantProperties;
this.serverMetrics = serverMetrics;
}
@PostMapping("/logs")
@Operation(summary = "Ingest application log entries",
description = "Accepts a batch of log entries from an agent. Entries are buffered and flushed periodically.")
@ApiResponse(responseCode = "202", description = "Logs accepted for indexing")
public ResponseEntity<Void> ingestLogs(@RequestBody List<LogEntry> entries,
HttpServletRequest request) {
String instanceId = extractAgentId();
if (instanceId == null || instanceId.isBlank()) {
log.warn("Log ingestion rejected: no agent identity in request (unauthenticated or missing principal)");
serverMetrics.recordIngestionDrop("no_identity");
return ResponseEntity.accepted().build();
}
if (entries == null || entries.isEmpty()) {
log.warn("Log ingestion from instance={}: empty or null payload", instanceId);
return ResponseEntity.accepted().build();
}
String applicationId;
String environment;
AgentInfo agent = registryService.findById(instanceId);
if (agent != null) {
applicationId = agent.applicationId();
environment = agent.environmentId() != null ? agent.environmentId() : "default";
} else {
// Agent not yet in registry (e.g. server just restarted) — fall back to JWT claims
JwtValidationResult jwt = (JwtValidationResult) request.getAttribute(JwtAuthenticationFilter.JWT_RESULT_ATTR);
applicationId = jwt != null ? jwt.application() : null;
environment = jwt != null && jwt.environment() != null ? jwt.environment() : "default";
if (applicationId != null) {
log.debug("Log ingestion from instance={}: agent not in registry, using JWT claims (app={}, env={})",
instanceId, applicationId, environment);
}
}
if (applicationId == null || applicationId.isBlank()) {
log.warn("Log ingestion from instance={}: no applicationId from registry or JWT. {} entries dropped.",
instanceId, entries.size());
serverMetrics.recordIngestionDrops("no_agent", entries.size());
return ResponseEntity.accepted().build();
}
log.debug("Ingesting {} log entries from instance={}, app={}, env={}", entries.size(), instanceId, applicationId, environment);
int accepted = 0;
int dropped = 0;
for (var entry : entries) {
boolean offered = logBuffer.offer(new BufferedLogEntry(
tenantProperties.getId(), environment, instanceId, applicationId, entry));
if (offered) {
accepted++;
} else {
dropped++;
}
}
if (dropped > 0) {
log.warn("Log buffer full: accepted={}, dropped={} from instance={}, app={}",
accepted, dropped, instanceId, applicationId);
serverMetrics.recordIngestionDrops("buffer_full", dropped);
} else {
log.debug("Accepted {} log entries from instance={}, app={}", accepted, instanceId, applicationId);
}
return ResponseEntity.accepted().build();
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Void> handleDeserializationError(HttpMessageNotReadableException ex) {
String instanceId = extractAgentId();
log.warn("Log ingestion from instance={}: failed to deserialize request body: {}",
instanceId != null ? instanceId : "unknown", ex.getMostSpecificCause().getMessage());
return ResponseEntity.badRequest().build();
}
private String extractAgentId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : null;
}
}

View File

@@ -0,0 +1,83 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.LogEntryResponse;
import com.cameleer.server.app.dto.LogSearchPageResponse;
import com.cameleer.server.core.search.LogSearchRequest;
import com.cameleer.server.core.search.LogSearchResponse;
import com.cameleer.server.core.storage.LogIndex;
import io.swagger.v3.oas.annotations.Operation;
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.Arrays;
import java.util.List;
@RestController
@RequestMapping("/api/v1/logs")
@Tag(name = "Application Logs", description = "Query application logs")
public class LogQueryController {
private final LogIndex logIndex;
public LogQueryController(LogIndex logIndex) {
this.logIndex = logIndex;
}
@GetMapping
@Operation(summary = "Search application log entries",
description = "Returns log entries with cursor-based pagination and level count aggregation. " +
"Supports free-text search, multi-level filtering, and optional application scoping.")
public ResponseEntity<LogSearchPageResponse> searchLogs(
@RequestParam(required = false) String q,
@RequestParam(required = false) String query,
@RequestParam(required = false) String level,
@RequestParam(required = false) String application,
@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 source,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "100") int limit,
@RequestParam(defaultValue = "desc") String sort) {
// q takes precedence over deprecated query param
String searchText = q != null ? q : query;
// Parse CSV levels
List<String> levels = List.of();
if (level != null && !level.isEmpty()) {
levels = Arrays.stream(level.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
Instant fromInstant = from != null ? Instant.parse(from) : null;
Instant toInstant = to != null ? Instant.parse(to) : null;
LogSearchRequest request = new LogSearchRequest(
searchText, levels, application, instanceId, exchangeId,
logger, environment, source, fromInstant, toInstant, cursor, limit, sort);
LogSearchResponse result = logIndex.search(request);
List<LogEntryResponse> entries = result.data().stream()
.map(r -> new LogEntryResponse(
r.timestamp(), r.level(), r.loggerName(),
r.message(), r.threadName(), r.stackTrace(),
r.exchangeId(), r.instanceId(), r.application(),
r.mdc(), r.source()))
.toList();
return ResponseEntity.ok(new LogSearchPageResponse(
entries, result.nextCursor(), result.hasMore(), result.levelCounts()));
}
}

View File

@@ -0,0 +1,81 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.ingestion.IngestionService;
import com.cameleer.server.core.storage.model.MetricsSnapshot;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Ingestion endpoint for agent metrics.
* <p>
* Accepts an array of {@link MetricsSnapshot}. Data is buffered
* and flushed to PostgreSQL by the flush scheduler.
*/
@RestController
@RequestMapping("/api/v1/data")
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
public class MetricsController {
private static final Logger log = LoggerFactory.getLogger(MetricsController.class);
private final IngestionService ingestionService;
private final ObjectMapper objectMapper;
public MetricsController(IngestionService ingestionService, ObjectMapper objectMapper) {
this.ingestionService = ingestionService;
this.objectMapper = objectMapper;
}
@PostMapping("/metrics")
@Operation(summary = "Ingest agent metrics",
description = "Accepts an array of MetricsSnapshot objects")
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
@ApiResponse(responseCode = "400", description = "Invalid payload")
@ApiResponse(responseCode = "503", description = "Buffer full, retry later")
public ResponseEntity<Void> ingestMetrics(@RequestBody String body) {
List<MetricsSnapshot> metrics;
try {
metrics = parsePayload(body);
} catch (JsonProcessingException e) {
log.warn("Failed to parse metrics payload: {}", e.getMessage());
return ResponseEntity.badRequest().build();
}
log.debug("Received {} metric(s) from agent(s)", metrics.size());
boolean accepted = ingestionService.acceptMetrics(metrics);
if (!accepted) {
log.warn("Metrics buffer full ({} items), returning 503",
ingestionService.getMetricsBufferDepth());
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.header("Retry-After", "5")
.build();
}
return ResponseEntity.accepted().build();
}
private List<MetricsSnapshot> parsePayload(String body) throws JsonProcessingException {
String trimmed = body.strip();
if (trimmed.startsWith("[")) {
return objectMapper.readValue(trimmed, new TypeReference<>() {});
} else {
MetricsSnapshot single = objectMapper.readValue(trimmed, MetricsSnapshot.class);
return List.of(single);
}
}
}

View File

@@ -0,0 +1,152 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.ErrorResponse;
import com.cameleer.server.app.dto.OidcAdminConfigRequest;
import com.cameleer.server.app.dto.OidcAdminConfigResponse;
import com.cameleer.server.app.dto.OidcTestResult;
import com.cameleer.server.app.security.OidcTokenExchanger;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.security.OidcConfig;
import com.cameleer.server.core.security.OidcConfigRepository;
import jakarta.servlet.http.HttpServletRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Admin endpoints for managing OIDC provider configuration.
* Protected by {@code ROLE_ADMIN} via SecurityConfig URL patterns ({@code /api/v1/admin/**}).
*/
@RestController
@RequestMapping("/api/v1/admin/oidc")
@Tag(name = "OIDC Config Admin", description = "OIDC provider configuration (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class OidcConfigAdminController {
private static final Logger log = LoggerFactory.getLogger(OidcConfigAdminController.class);
private final OidcConfigRepository configRepository;
private final OidcTokenExchanger tokenExchanger;
private final AuditService auditService;
public OidcConfigAdminController(OidcConfigRepository configRepository,
OidcTokenExchanger tokenExchanger,
AuditService auditService) {
this.configRepository = configRepository;
this.tokenExchanger = tokenExchanger;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "Get OIDC configuration")
@ApiResponse(responseCode = "200", description = "Current OIDC configuration (client_secret masked)")
public ResponseEntity<OidcAdminConfigResponse> getConfig(HttpServletRequest httpRequest) {
auditService.log("view_oidc_config", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, httpRequest);
Optional<OidcConfig> config = configRepository.find();
if (config.isEmpty()) {
return ResponseEntity.ok(OidcAdminConfigResponse.unconfigured());
}
return ResponseEntity.ok(OidcAdminConfigResponse.from(config.get()));
}
@PutMapping
@Operation(summary = "Save OIDC configuration")
@ApiResponse(responseCode = "200", description = "Configuration saved")
@ApiResponse(responseCode = "400", description = "Invalid configuration",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<OidcAdminConfigResponse> saveConfig(@RequestBody OidcAdminConfigRequest request,
HttpServletRequest httpRequest) {
// Resolve client_secret: if masked or empty, preserve existing
String clientSecret = request.clientSecret();
if (clientSecret == null || clientSecret.isBlank() || clientSecret.equals("********")) {
Optional<OidcConfig> existing = configRepository.find();
clientSecret = existing.map(OidcConfig::clientSecret).orElse("");
}
if (request.enabled() && (request.issuerUri() == null || request.issuerUri().isBlank())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"issuerUri is required when OIDC is enabled");
}
if (request.enabled() && (request.clientId() == null || request.clientId().isBlank())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"clientId is required when OIDC is enabled");
}
OidcConfig config = new OidcConfig(
request.enabled(),
request.issuerUri() != null ? request.issuerUri() : "",
request.clientId() != null ? request.clientId() : "",
clientSecret,
request.rolesClaim() != null ? request.rolesClaim() : "roles",
request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER"),
request.autoSignup(),
request.displayNameClaim() != null ? request.displayNameClaim() : "name",
request.userIdClaim() != null ? request.userIdClaim() : "sub",
request.audience() != null ? request.audience() : "",
request.additionalScopes() != null ? request.additionalScopes() : List.of()
);
configRepository.save(config);
tokenExchanger.invalidateCache();
auditService.log("update_oidc", AuditCategory.CONFIG, "oidc", Map.of(), AuditResult.SUCCESS, httpRequest);
log.info("OIDC configuration updated: enabled={}, issuer={}", config.enabled(), config.issuerUri());
return ResponseEntity.ok(OidcAdminConfigResponse.from(config));
}
@PostMapping("/test")
@Operation(summary = "Test OIDC provider connectivity")
@ApiResponse(responseCode = "200", description = "Provider reachable")
@ApiResponse(responseCode = "400", description = "Provider unreachable or misconfigured",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<OidcTestResult> testConnection(HttpServletRequest httpRequest) {
Optional<OidcConfig> config = configRepository.find();
if (config.isEmpty() || !config.get().enabled()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"OIDC is not configured or disabled");
}
try {
tokenExchanger.invalidateCache();
String authEndpoint = tokenExchanger.getAuthorizationEndpoint();
auditService.log("test_oidc", AuditCategory.CONFIG, "oidc", null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new OidcTestResult("ok", authEndpoint));
} catch (Exception e) {
log.warn("OIDC connectivity test failed: {}", e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Failed to reach OIDC provider: " + e.getMessage());
}
}
@DeleteMapping
@Operation(summary = "Delete OIDC configuration")
@ApiResponse(responseCode = "204", description = "Configuration deleted")
public ResponseEntity<Void> deleteConfig(HttpServletRequest httpRequest) {
configRepository.delete();
tokenExchanger.invalidateCache();
auditService.log("delete_oidc", AuditCategory.CONFIG, "oidc", null, AuditResult.SUCCESS, httpRequest);
log.info("OIDC configuration deleted");
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,36 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.rbac.RbacService;
import com.cameleer.server.core.rbac.RbacStats;
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.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Admin endpoint for RBAC statistics.
* Protected by {@code ROLE_ADMIN}.
*/
@RestController
@RequestMapping("/api/v1/admin/rbac")
@Tag(name = "RBAC Stats", description = "RBAC statistics (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class RbacStatsController {
private final RbacService rbacService;
public RbacStatsController(RbacService rbacService) {
this.rbacService = rbacService;
}
@GetMapping("/stats")
@Operation(summary = "Get RBAC statistics for the dashboard")
@ApiResponse(responseCode = "200", description = "RBAC stats returned")
public ResponseEntity<RbacStats> getStats() {
return ResponseEntity.ok(rbacService.getStats());
}
}

View File

@@ -0,0 +1,121 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.rbac.RoleDetail;
import com.cameleer.server.core.rbac.RoleRepository;
import com.cameleer.server.core.rbac.SystemRole;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Admin endpoints for role management.
* Protected by {@code ROLE_ADMIN}.
*/
@RestController
@RequestMapping("/api/v1/admin/roles")
@Tag(name = "Role Admin", description = "Role management (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class RoleAdminController {
private final RoleRepository roleRepository;
private final AuditService auditService;
public RoleAdminController(RoleRepository roleRepository, AuditService auditService) {
this.roleRepository = roleRepository;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "List all roles (system and custom)")
@ApiResponse(responseCode = "200", description = "Role list returned")
public ResponseEntity<List<RoleDetail>> listRoles() {
return ResponseEntity.ok(roleRepository.findAll());
}
@GetMapping("/{id}")
@Operation(summary = "Get role by ID with effective principals")
@ApiResponse(responseCode = "200", description = "Role found")
@ApiResponse(responseCode = "404", description = "Role not found")
public ResponseEntity<RoleDetail> getRole(@PathVariable UUID id) {
return roleRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@Operation(summary = "Create a custom role")
@ApiResponse(responseCode = "200", description = "Role created")
public ResponseEntity<Map<String, UUID>> createRole(@RequestBody CreateRoleRequest request,
HttpServletRequest httpRequest) {
String desc = request.description() != null ? request.description() : "";
String sc = request.scope() != null ? request.scope() : "custom";
UUID id = roleRepository.create(request.name(), desc, sc);
auditService.log("create_role", AuditCategory.RBAC, id.toString(),
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(Map.of("id", id));
}
@PutMapping("/{id}")
@Operation(summary = "Update a custom role")
@ApiResponse(responseCode = "200", description = "Role updated")
@ApiResponse(responseCode = "403", description = "Cannot modify system role")
@ApiResponse(responseCode = "404", description = "Role not found")
public ResponseEntity<Void> updateRole(@PathVariable UUID id,
@RequestBody UpdateRoleRequest request,
HttpServletRequest httpRequest) {
if (SystemRole.isSystem(id)) {
auditService.log("update_role", AuditCategory.RBAC, id.toString(),
Map.of("reason", "system_role_protected"), AuditResult.FAILURE, httpRequest);
return ResponseEntity.status(403).build();
}
if (roleRepository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
roleRepository.update(id, request.name(), request.description(), request.scope());
auditService.log("update_role", AuditCategory.RBAC, id.toString(),
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete a custom role")
@ApiResponse(responseCode = "204", description = "Role deleted")
@ApiResponse(responseCode = "403", description = "Cannot delete system role")
@ApiResponse(responseCode = "404", description = "Role not found")
public ResponseEntity<Void> deleteRole(@PathVariable UUID id,
HttpServletRequest httpRequest) {
if (SystemRole.isSystem(id)) {
auditService.log("delete_role", AuditCategory.RBAC, id.toString(),
Map.of("reason", "system_role_protected"), AuditResult.FAILURE, httpRequest);
return ResponseEntity.status(403).build();
}
if (roleRepository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
roleRepository.delete(id);
auditService.log("delete_role", AuditCategory.RBAC, id.toString(),
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
public record CreateRoleRequest(String name, String description, String scope) {}
public record UpdateRoleRequest(String name, String description, String scope) {}
}

View File

@@ -0,0 +1,199 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.AgentSummary;
import com.cameleer.server.app.dto.AppCatalogEntry;
import com.cameleer.server.app.dto.RouteSummary;
import com.cameleer.common.graph.RouteGraph;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.AgentState;
import com.cameleer.server.core.agent.RouteStateRegistry;
import com.cameleer.server.core.storage.DiagramStore;
import com.cameleer.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.RequestParam;
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 static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RouteCatalogController.class);
private final AgentRegistryService registryService;
private final DiagramStore diagramStore;
private final JdbcTemplate jdbc;
private final RouteStateRegistry routeStateRegistry;
public RouteCatalogController(AgentRegistryService registryService,
DiagramStore diagramStore,
@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc,
RouteStateRegistry routeStateRegistry) {
this.registryService = registryService;
this.diagramStore = diagramStore;
this.jdbc = jdbc;
this.routeStateRegistry = routeStateRegistry;
}
@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(
@RequestParam(required = false) String from,
@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()));
// 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);
}
// Time range for exchange counts — use provided range or default to last 24h
Instant now = Instant.now();
Instant rangeFrom = from != null ? Instant.parse(from) : now.minus(24, ChronoUnit.HOURS);
Instant rangeTo = to != null ? Instant.parse(to) : now;
// Route exchange counts from AggregatingMergeTree (literal SQL — ClickHouse JDBC driver
// wraps prepared statements in sub-queries that strip AggregateFunction column types)
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, uniqMerge(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");
routeExchangeCounts.put(key, rs.getLong("cnt"));
Timestamp ts = rs.getTimestamp("last_seen");
if (ts != null) routeLastSeen.put(key, ts.toInstant());
});
} catch (Exception e) {
log.warn("Failed to query route exchange counts: {}", e.getMessage());
}
// Merge route IDs from ClickHouse stats into routesByApp.
// After server restart, auto-healed agents have empty routeIds, but
// ClickHouse still has execution data with the correct route IDs.
for (var countEntry : routeExchangeCounts.entrySet()) {
String[] parts = countEntry.getKey().split("/", 2);
if (parts.length == 2) {
routesByApp.computeIfAbsent(parts[0], k -> new LinkedHashSet<>()).add(parts[1]);
}
}
// Build catalog entries — merge apps from agent registry + ClickHouse data
Set<String> allAppIds = new LinkedHashSet<>(agentsByApp.keySet());
allAppIds.addAll(routesByApp.keySet());
List<AppCatalogEntry> catalog = new ArrayList<>();
for (String appId : allAppIds) {
List<AgentInfo> agents = agentsByApp.getOrDefault(appId, List.of());
// Routes
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
List<RouteSummary> routeSummaries = routeIds.stream()
.map(routeId -> {
String key = appId + "/" + routeId;
long count = routeExchangeCounts.getOrDefault(key, 0L);
Instant lastSeen = routeLastSeen.get(key);
String fromUri = resolveFromEndpointUri(routeId, agentIds);
String state = routeStateRegistry.getState(appId, routeId).name().toLowerCase();
// Only include non-default states (stopped/suspended); null means started
String routeState = "started".equals(state) ? null : state;
return new RouteSummary(routeId, count, lastSeen, fromUri, routeState);
})
.toList();
// Agent summaries
List<AgentSummary> agentSummaries = agents.stream()
.map(a -> new AgentSummary(a.instanceId(), a.displayName(), 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);
}
/** Resolve the from() endpoint URI for a route by looking up its diagram. */
private String resolveFromEndpointUri(String routeId, List<String> agentIds) {
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
.flatMap(diagramStore::findByContentHash)
.map(RouteGraph::getRoot)
.map(root -> root.getEndpointUri())
.orElse(null);
}
/** Format an Instant as a ClickHouse DateTime literal in UTC. */
private static String lit(Instant instant) {
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(java.time.ZoneOffset.UTC)
.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;
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";
}
}

View File

@@ -0,0 +1,210 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.ProcessorMetrics;
import com.cameleer.server.app.dto.RouteMetrics;
import com.cameleer.server.core.admin.AppSettings;
import com.cameleer.server.core.admin.AppSettingsRepository;
import com.cameleer.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.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;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/routes")
@Tag(name = "Route Metrics", description = "Route performance metrics")
public class RouteMetricsController {
private final JdbcTemplate jdbc;
private final StatsStore statsStore;
private final AppSettingsRepository appSettingsRepository;
public RouteMetricsController(@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc, StatsStore statsStore,
AppSettingsRepository appSettingsRepository) {
this.jdbc = jdbc;
this.statsStore = statsStore;
this.appSettingsRepository = appSettingsRepository;
}
@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,
@RequestParam(required = false) String environment) {
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();
// Literal SQL — ClickHouse JDBC driver wraps prepared statements in sub-queries
// that strip AggregateFunction column types, breaking -Merge combinators
var sql = new StringBuilder(
"SELECT application_id, route_id, " +
"uniqMerge(total_count) AS total, " +
"uniqIfMerge(failed_count) AS failed, " +
"CASE WHEN uniqMerge(total_count) > 0 THEN toFloat64(sumMerge(duration_sum)) / uniqMerge(total_count) ELSE 0 END AS avg_dur, " +
"COALESCE(quantileMerge(0.99)(p99_duration), 0) AS p99_dur " +
"FROM stats_1m_route WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant));
if (appId != null) {
sql.append(" AND application_id = " + lit(appId));
}
if (environment != null) {
sql.append(" AND environment = " + lit(environment));
}
sql.append(" GROUP BY application_id, route_id ORDER BY application_id, route_id");
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
String applicationId = rs.getString("application_id");
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;
return new RouteMetrics(routeId, applicationId, total, successRate,
avgDur, p99Dur, errorRate, tps, List.of(), -1.0);
});
// 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 {
var sparkWhere = new StringBuilder(
"FROM stats_1m_route WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) +
" AND application_id = " + lit(m.appId()) + " AND route_id = " + lit(m.routeId()));
if (environment != null) {
sparkWhere.append(" AND environment = " + lit(environment));
}
String sparkSql = "SELECT toStartOfInterval(bucket, toIntervalSecond(" + bucketSeconds + ")) AS period, " +
"COALESCE(uniqMerge(total_count), 0) AS cnt " +
sparkWhere + " GROUP BY period ORDER BY period";
List<Double> sparkline = jdbc.query(sparkSql,
(rs, rowNum) -> rs.getDouble("cnt"));
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
m.errorRate(), m.throughputPerSec(), sparkline, m.slaCompliance()));
} catch (Exception e) {
// Leave sparkline empty on error
}
}
}
// Enrich with SLA compliance per route
if (!metrics.isEmpty()) {
// Determine SLA threshold (per-app or default)
String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId());
int threshold = appSettingsRepository.findByApplicationId(effectiveAppId != null ? effectiveAppId : "")
.map(AppSettings::slaThresholdMs).orElse(300);
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
effectiveAppId, threshold, environment);
for (int i = 0; i < metrics.size(); i++) {
RouteMetrics m = metrics.get(i);
long[] counts = slaCounts.get(m.routeId());
double sla = (counts != null && counts[1] > 0)
? counts[0] * 100.0 / counts[1] : 100.0;
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
m.errorRate(), m.throughputPerSec(), m.sparkline(), sla));
}
}
return ResponseEntity.ok(metrics);
}
@GetMapping("/metrics/processors")
@Operation(summary = "Get processor metrics",
description = "Returns aggregated performance metrics per processor for the given route and time window")
@ApiResponse(responseCode = "200", description = "Metrics returned")
public ResponseEntity<List<ProcessorMetrics>> getProcessorMetrics(
@RequestParam String routeId,
@RequestParam(required = false) String appId,
@RequestParam(required = false) Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(required = false) String environment) {
Instant toInstant = to != null ? to : Instant.now();
Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS);
// Literal SQL for AggregatingMergeTree -Merge combinators.
// Aliases (tc, fc) must NOT shadow column names (total_count, failed_count) —
// ClickHouse 24.12 new analyzer resolves subsequent uniqMerge(total_count)
// to the alias (UInt64) instead of the AggregateFunction column.
// total_count/failed_count use uniq(execution_id) to deduplicate repeated inserts.
var sql = new StringBuilder(
"SELECT processor_id, processor_type, route_id, application_id, " +
"uniqMerge(total_count) AS tc, " +
"uniqIfMerge(failed_count) AS fc, " +
"CASE WHEN uniqMerge(total_count) > 0 THEN toFloat64(sumMerge(duration_sum)) / uniqMerge(total_count) ELSE 0 END AS avg_duration_ms, " +
"quantileMerge(0.99)(p99_duration) AS p99_duration_ms " +
"FROM stats_1m_processor_detail " +
"WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) +
" AND route_id = " + lit(routeId));
if (appId != null) {
sql.append(" AND application_id = " + lit(appId));
}
if (environment != null) {
sql.append(" AND environment = " + lit(environment));
}
sql.append(" GROUP BY processor_id, processor_type, route_id, application_id");
sql.append(" ORDER BY tc DESC");
List<ProcessorMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
long totalCount = rs.getLong("tc");
long failedCount = rs.getLong("fc");
double errorRate = failedCount > 0 ? (double) failedCount / totalCount : 0.0;
return new ProcessorMetrics(
rs.getString("processor_id"),
rs.getString("processor_type"),
rs.getString("route_id"),
rs.getString("application_id"),
totalCount,
failedCount,
rs.getDouble("avg_duration_ms"),
rs.getDouble("p99_duration_ms"),
errorRate);
});
return ResponseEntity.ok(metrics);
}
/** Format an Instant as a ClickHouse DateTime literal. */
private static String lit(Instant instant) {
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(java.time.ZoneOffset.UTC)
.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("'", "\\'") + "'";
}
}

View File

@@ -0,0 +1,213 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.admin.AppSettings;
import com.cameleer.server.core.admin.AppSettingsRepository;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.search.ExecutionStats;
import com.cameleer.server.core.search.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest;
import com.cameleer.server.core.search.SearchResult;
import com.cameleer.server.core.search.SearchService;
import com.cameleer.server.core.search.StatsTimeseries;
import com.cameleer.server.core.search.TopError;
import com.cameleer.server.core.storage.StatsStore;
import io.swagger.v3.oas.annotations.Operation;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
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;
import java.util.Map;
/**
* Search endpoints for querying route executions.
* <p>
* GET supports basic filters via query parameters. POST accepts a full
* {@link SearchRequest} JSON body for advanced search with all filter types.
*/
@RestController
@RequestMapping("/api/v1/search")
@Tag(name = "Search", description = "Transaction search endpoints")
public class SearchController {
private final SearchService searchService;
private final AgentRegistryService registryService;
private final AppSettingsRepository appSettingsRepository;
public SearchController(SearchService searchService, AgentRegistryService registryService,
AppSettingsRepository appSettingsRepository) {
this.searchService = searchService;
this.registryService = registryService;
this.appSettingsRepository = appSettingsRepository;
}
@GetMapping("/executions")
@Operation(summary = "Search executions with basic filters")
public ResponseEntity<SearchResult<ExecutionSummary>> searchGet(
@RequestParam(required = false) String status,
@RequestParam(required = false) Instant timeFrom,
@RequestParam(required = false) Instant timeTo,
@RequestParam(required = false) String correlationId,
@RequestParam(required = false) String text,
@RequestParam(required = false) String routeId,
@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,
@RequestParam(required = false) String sortDir) {
List<String> agentIds = resolveApplicationToAgentIds(application);
SearchRequest request = new SearchRequest(
status, timeFrom, timeTo,
null, null,
correlationId,
text, null, null, null,
routeId, instanceId, processorType,
application, agentIds,
offset, limit,
sortField, sortDir,
environment
);
return ResponseEntity.ok(searchService.search(request));
}
@PostMapping("/executions")
@Operation(summary = "Advanced search with all filters")
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
@RequestBody SearchRequest request) {
// Resolve application to agentIds if application is specified but agentIds is not
SearchRequest resolved = request;
if (request.applicationId() != null && !request.applicationId().isBlank()
&& (request.instanceIds() == null || request.instanceIds().isEmpty())) {
resolved = request.withInstanceIds(resolveApplicationToAgentIds(request.applicationId()));
}
return ResponseEntity.ok(searchService.search(resolved));
}
@GetMapping("/stats")
@Operation(summary = "Aggregate execution stats (P99 latency, active count, SLA compliance)")
public ResponseEntity<ExecutionStats> stats(
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(required = false) String routeId,
@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, environment);
} else if (routeId == null) {
stats = searchService.statsForApp(from, end, application, environment);
} else {
List<String> agentIds = resolveApplicationToAgentIds(application);
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, environment);
return ResponseEntity.ok(stats.withSlaCompliance(sla));
}
@GetMapping("/stats/timeseries")
@Operation(summary = "Bucketed time-series stats over a time window")
public ResponseEntity<StatsTimeseries> timeseries(
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "24") int buckets,
@RequestParam(required = false) String routeId,
@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, environment));
}
if (routeId == null) {
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, environment));
}
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, routeId, agentIds, environment));
}
@GetMapping("/stats/timeseries/by-app")
@Operation(summary = "Timeseries grouped by application")
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByApp(
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@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, environment));
}
@GetMapping("/stats/timeseries/by-route")
@Operation(summary = "Timeseries grouped by route for an application")
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByRoute(
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "24") int buckets,
@RequestParam String application,
@RequestParam(required = false) String environment) {
Instant end = to != null ? to : Instant.now();
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 environment) {
Instant to = Instant.now();
Instant from = to.minus(java.time.Duration.ofDays(7));
return ResponseEntity.ok(searchService.punchcard(from, to, application, environment));
}
@GetMapping("/attributes/keys")
@Operation(summary = "Distinct attribute key names across all executions")
public ResponseEntity<List<String>> attributeKeys() {
return ResponseEntity.ok(searchService.distinctAttributeKeys());
}
@GetMapping("/errors/top")
@Operation(summary = "Top N errors with velocity trend")
public ResponseEntity<List<TopError>> topErrors(
@RequestParam Instant from,
@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, environment));
}
/**
* Resolve an application name to agent IDs.
* Returns empty list if application is null/blank (no filtering).
*/
private List<String> resolveApplicationToAgentIds(String application) {
if (application == null || application.isBlank()) {
return List.of();
}
return registryService.findByApplication(application).stream()
.map(AgentInfo::instanceId)
.toList();
}
}

View File

@@ -0,0 +1,216 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.dto.CommandGroupResponse;
import com.cameleer.server.app.dto.SensitiveKeysRequest;
import com.cameleer.server.app.dto.SensitiveKeysResponse;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.admin.SensitiveKeysConfig;
import com.cameleer.server.core.admin.SensitiveKeysMerger;
import com.cameleer.server.core.admin.SensitiveKeysRepository;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.CommandReply;
import com.cameleer.server.core.agent.CommandType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
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.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@RestController
@RequestMapping("/api/v1/admin/sensitive-keys")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Sensitive Keys Admin", description = "Global sensitive key masking configuration (ADMIN only)")
public class SensitiveKeysAdminController {
private static final Logger log = LoggerFactory.getLogger(SensitiveKeysAdminController.class);
private final SensitiveKeysRepository sensitiveKeysRepository;
private final PostgresApplicationConfigRepository configRepository;
private final AgentRegistryService registryService;
private final ObjectMapper objectMapper;
private final AuditService auditService;
public SensitiveKeysAdminController(SensitiveKeysRepository sensitiveKeysRepository,
PostgresApplicationConfigRepository configRepository,
AgentRegistryService registryService,
ObjectMapper objectMapper,
AuditService auditService) {
this.sensitiveKeysRepository = sensitiveKeysRepository;
this.configRepository = configRepository;
this.registryService = registryService;
this.objectMapper = objectMapper;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "Get global sensitive keys configuration")
public ResponseEntity<SensitiveKeysConfig> getSensitiveKeys(HttpServletRequest httpRequest) {
auditService.log("view_sensitive_keys", AuditCategory.CONFIG, "sensitive_keys",
null, AuditResult.SUCCESS, httpRequest);
return sensitiveKeysRepository.find()
.map(ResponseEntity::ok)
.orElse(ResponseEntity.noContent().build());
}
@PutMapping
@Operation(summary = "Update global sensitive keys configuration",
description = "Saves the global sensitive keys. Optionally fans out merged keys to all live agents.")
public ResponseEntity<SensitiveKeysResponse> updateSensitiveKeys(
@Valid @RequestBody SensitiveKeysRequest request,
@RequestParam(required = false, defaultValue = "false") boolean pushToAgents,
Authentication auth,
HttpServletRequest httpRequest) {
String updatedBy = auth != null ? auth.getName() : "system";
SensitiveKeysConfig config = new SensitiveKeysConfig(request.keys());
sensitiveKeysRepository.save(config, updatedBy);
CommandGroupResponse pushResult = null;
if (pushToAgents) {
pushResult = fanOutToAllAgents(config.keys());
log.info("Sensitive keys saved and pushed to all applications, {} agent(s) responded",
pushResult.responded());
} else {
log.info("Sensitive keys saved ({} keys), push skipped", config.keys().size());
}
auditService.log("update_sensitive_keys", AuditCategory.CONFIG, "sensitive_keys",
Map.of("keyCount", config.keys().size(), "pushToAgents", pushToAgents),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new SensitiveKeysResponse(config.keys(), pushResult));
}
/**
* Fan out the merged (global + per-app) sensitive keys to all known applications.
* Collects distinct application IDs from both stored configs and live agents.
* Builds a minimal JSON payload carrying only the sensitiveKeys field so that
* agents apply it as a partial config update.
* <p>
* Per-app keys are read via JsonNode rather than ApplicationConfig.getSensitiveKeys()
* to remain compatible with older published versions of cameleer-common that may
* not yet include that field accessor.
*/
private CommandGroupResponse fanOutToAllAgents(List<String> globalKeys) {
// Collect all distinct application IDs
Set<String> applications = new LinkedHashSet<>();
configRepository.findAll().stream()
.map(ApplicationConfig::getApplication)
.filter(a -> a != null && !a.isBlank())
.forEach(applications::add);
registryService.findAll().stream()
.map(a -> a.applicationId())
.filter(a -> a != null && !a.isBlank())
.forEach(applications::add);
if (applications.isEmpty()) {
return new CommandGroupResponse(true, 0, 0, List.of(), List.of());
}
// Shared 10-second deadline across all applications
long deadline = System.currentTimeMillis() + 10_000;
List<CommandGroupResponse.AgentResponse> allResponses = new ArrayList<>();
List<String> allTimedOut = new ArrayList<>();
int totalAgents = 0;
for (String application : applications) {
// Load per-app sensitive keys via JsonNode to avoid dependency on
// ApplicationConfig.getSensitiveKeys() which may not be in the published jar yet.
List<String> perAppKeys = configRepository.findByApplication(application)
.map(cfg -> extractSensitiveKeys(cfg))
.orElse(null);
// Merge global + per-app keys
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
// Build a minimal payload map — only sensitiveKeys + application fields.
Map<String, Object> payloadMap = new LinkedHashMap<>();
payloadMap.put("application", application);
payloadMap.put("sensitiveKeys", mergedKeys);
String payloadJson;
try {
payloadJson = objectMapper.writeValueAsString(payloadMap);
} catch (JsonProcessingException e) {
log.error("Failed to serialize sensitive keys push payload for application '{}'", application, e);
continue;
}
Map<String, CompletableFuture<CommandReply>> futures =
registryService.addGroupCommandWithReplies(application, null, CommandType.CONFIG_UPDATE, payloadJson);
totalAgents += futures.size();
for (var entry : futures.entrySet()) {
long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) {
allTimedOut.add(entry.getKey());
entry.getValue().cancel(false);
continue;
}
try {
CommandReply reply = entry.getValue().get(remaining, TimeUnit.MILLISECONDS);
allResponses.add(new CommandGroupResponse.AgentResponse(
entry.getKey(), reply.status(), reply.message()));
} catch (TimeoutException e) {
allTimedOut.add(entry.getKey());
entry.getValue().cancel(false);
} catch (Exception e) {
allResponses.add(new CommandGroupResponse.AgentResponse(
entry.getKey(), "ERROR", e.getMessage()));
}
}
}
boolean allSuccess = allTimedOut.isEmpty() &&
allResponses.stream().allMatch(r -> "SUCCESS".equals(r.status()));
return new CommandGroupResponse(allSuccess, totalAgents, allResponses.size(), allResponses, allTimedOut);
}
/**
* Extracts the sensitiveKeys list from an ApplicationConfig by round-tripping through
* JsonNode. This avoids a compile-time dependency on ApplicationConfig.getSensitiveKeys()
* which may not be present in older published versions of cameleer-common.
*/
private List<String> extractSensitiveKeys(ApplicationConfig config) {
try {
JsonNode node = objectMapper.valueToTree(config);
JsonNode keysNode = node.get("sensitiveKeys");
if (keysNode == null || keysNode.isNull() || !keysNode.isArray()) {
return null;
}
return objectMapper.convertValue(keysNode, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("Failed to extract sensitiveKeys from ApplicationConfig", e);
return null;
}
}
}

View File

@@ -0,0 +1,62 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.ThresholdConfigRequest;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.admin.ThresholdConfig;
import com.cameleer.server.core.admin.ThresholdRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/admin/thresholds")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Threshold Admin", description = "Monitoring threshold configuration (ADMIN only)")
public class ThresholdAdminController {
private final ThresholdRepository thresholdRepository;
private final AuditService auditService;
public ThresholdAdminController(ThresholdRepository thresholdRepository, AuditService auditService) {
this.thresholdRepository = thresholdRepository;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "Get current threshold configuration")
public ResponseEntity<ThresholdConfig> getThresholds() {
ThresholdConfig config = thresholdRepository.find().orElse(ThresholdConfig.defaults());
return ResponseEntity.ok(config);
}
@PutMapping
@Operation(summary = "Update threshold configuration")
public ResponseEntity<ThresholdConfig> updateThresholds(@Valid @RequestBody ThresholdConfigRequest request,
HttpServletRequest httpRequest) {
List<String> errors = request.validate();
if (!errors.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
}
ThresholdConfig config = request.toConfig();
thresholdRepository.save(config, null);
auditService.log("update_thresholds", AuditCategory.CONFIG, "thresholds",
Map.of("config", config), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(config);
}
}

View File

@@ -0,0 +1,50 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.storage.ClickHouseUsageTracker;
import com.cameleer.server.core.analytics.UsageStats;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
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.time.temporal.ChronoUnit;
import java.util.List;
@RestController
@RequestMapping("/api/v1/admin/usage")
@ConditionalOnBean(ClickHouseUsageTracker.class)
@Tag(name = "Usage Analytics", description = "UI usage pattern analytics")
public class UsageAnalyticsController {
private final ClickHouseUsageTracker tracker;
public UsageAnalyticsController(ClickHouseUsageTracker tracker) {
this.tracker = tracker;
}
@GetMapping
@Operation(summary = "Query usage statistics",
description = "Returns aggregated API usage stats grouped by endpoint, user, or hour")
public ResponseEntity<List<UsageStats>> getUsage(
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@RequestParam(required = false) String username,
@RequestParam(defaultValue = "endpoint") String groupBy) {
Instant fromInstant = from != null ? Instant.parse(from) : Instant.now().minus(7, ChronoUnit.DAYS);
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
List<UsageStats> stats = switch (groupBy) {
case "user" -> tracker.queryByUser(fromInstant, toInstant);
case "hour" -> tracker.queryByHour(fromInstant, toInstant, username);
default -> tracker.queryByEndpoint(fromInstant, toInstant, username);
};
return ResponseEntity.ok(stats);
}
}

View File

@@ -0,0 +1,234 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.SetPasswordRequest;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.rbac.RbacService;
import com.cameleer.server.core.rbac.SystemRole;
import com.cameleer.server.core.rbac.UserDetail;
import com.cameleer.server.core.security.PasswordPolicyValidator;
import com.cameleer.server.core.security.UserInfo;
import com.cameleer.server.core.security.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import com.cameleer.server.app.security.SecurityProperties;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Admin endpoints for user management.
* Protected by {@code ROLE_ADMIN}.
*/
@RestController
@RequestMapping("/api/v1/admin/users")
@Tag(name = "User Admin", description = "User management (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class UserAdminController {
private static final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
private final RbacService rbacService;
private final UserRepository userRepository;
private final AuditService auditService;
private final boolean oidcEnabled;
public UserAdminController(RbacService rbacService, UserRepository userRepository,
AuditService auditService, SecurityProperties securityProperties) {
this.rbacService = rbacService;
this.userRepository = userRepository;
this.auditService = auditService;
String issuer = securityProperties.getOidc().getIssuerUri();
this.oidcEnabled = issuer != null && !issuer.isBlank();
}
@GetMapping
@Operation(summary = "List all users with RBAC detail")
@ApiResponse(responseCode = "200", description = "User list returned")
public ResponseEntity<List<UserDetail>> listUsers(HttpServletRequest httpRequest) {
auditService.log("view_users", AuditCategory.USER_MGMT, null, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(rbacService.listUsers());
}
@GetMapping("/{userId}")
@Operation(summary = "Get user by ID with RBAC detail")
@ApiResponse(responseCode = "200", description = "User found")
@ApiResponse(responseCode = "404", description = "User not found")
public ResponseEntity<UserDetail> getUser(@PathVariable String userId) {
UserDetail detail = rbacService.getUser(userId);
if (detail == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(detail);
}
@PostMapping
@Operation(summary = "Create a local user")
@ApiResponse(responseCode = "200", description = "User created")
@ApiResponse(responseCode = "400", description = "Disabled in OIDC mode")
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request,
HttpServletRequest httpRequest) {
if (oidcEnabled) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO."));
}
String userId = "user:" + request.username();
UserInfo user = new UserInfo(userId, "local",
request.email() != null ? request.email() : "",
request.displayName() != null ? request.displayName() : request.username(),
Instant.now());
userRepository.upsert(user);
if (request.password() != null && !request.password().isBlank()) {
List<String> violations = PasswordPolicyValidator.validate(request.password(), request.username());
if (!violations.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Password policy violation: " + String.join("; ", violations));
}
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
}
rbacService.assignRoleToUser(userId, SystemRole.VIEWER_ID);
auditService.log("create_user", AuditCategory.USER_MGMT, userId,
Map.of("username", request.username()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(rbacService.getUser(userId));
}
@PutMapping("/{userId}")
@Operation(summary = "Update user display name or email")
@ApiResponse(responseCode = "200", description = "User updated")
@ApiResponse(responseCode = "404", description = "User not found")
public ResponseEntity<Void> updateUser(@PathVariable String userId,
@RequestBody UpdateUserRequest request,
HttpServletRequest httpRequest) {
var existing = userRepository.findById(userId);
if (existing.isEmpty()) return ResponseEntity.notFound().build();
var user = existing.get();
var updated = new UserInfo(user.userId(), user.provider(),
request.email() != null ? request.email() : user.email(),
request.displayName() != null ? request.displayName() : user.displayName(),
user.createdAt());
userRepository.upsert(updated);
auditService.log("update_user", AuditCategory.USER_MGMT, userId,
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@PostMapping("/{userId}/roles/{roleId}")
@Operation(summary = "Assign a role to a user")
@ApiResponse(responseCode = "200", description = "Role assigned")
@ApiResponse(responseCode = "404", description = "User or role not found")
public ResponseEntity<Void> assignRoleToUser(@PathVariable String userId,
@PathVariable UUID roleId,
HttpServletRequest httpRequest) {
rbacService.assignRoleToUser(userId, roleId);
auditService.log("assign_role_to_user", AuditCategory.USER_MGMT, userId,
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{userId}/roles/{roleId}")
@Operation(summary = "Remove a role from a user")
@ApiResponse(responseCode = "204", description = "Role removed")
public ResponseEntity<Void> removeRoleFromUser(@PathVariable String userId,
@PathVariable UUID roleId,
HttpServletRequest httpRequest) {
rbacService.removeRoleFromUser(userId, roleId);
auditService.log("remove_role_from_user", AuditCategory.USER_MGMT, userId,
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
@PostMapping("/{userId}/groups/{groupId}")
@Operation(summary = "Add a user to a group")
@ApiResponse(responseCode = "200", description = "User added to group")
public ResponseEntity<Void> addUserToGroup(@PathVariable String userId,
@PathVariable UUID groupId,
HttpServletRequest httpRequest) {
rbacService.addUserToGroup(userId, groupId);
auditService.log("add_user_to_group", AuditCategory.USER_MGMT, userId,
Map.of("groupId", groupId), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{userId}/groups/{groupId}")
@Operation(summary = "Remove a user from a group")
@ApiResponse(responseCode = "204", description = "User removed from group")
public ResponseEntity<Void> removeUserFromGroup(@PathVariable String userId,
@PathVariable UUID groupId,
HttpServletRequest httpRequest) {
rbacService.removeUserFromGroup(userId, groupId);
auditService.log("remove_user_from_group", AuditCategory.USER_MGMT, userId,
Map.of("groupId", groupId), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{userId}")
@Operation(summary = "Delete user")
@ApiResponse(responseCode = "204", description = "User deleted")
@ApiResponse(responseCode = "409", description = "Cannot delete the last admin user")
public ResponseEntity<Void> deleteUser(@PathVariable String userId,
HttpServletRequest httpRequest) {
boolean isAdmin = rbacService.getEffectiveRolesForUser(userId).stream()
.anyMatch(r -> r.id().equals(SystemRole.ADMIN_ID));
if (isAdmin && rbacService.getEffectivePrincipalsForRole(SystemRole.ADMIN_ID).size() <= 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Cannot delete the last admin user");
}
userRepository.delete(userId);
auditService.log("delete_user", AuditCategory.USER_MGMT, userId,
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
@PostMapping("/{userId}/password")
@Operation(summary = "Reset user password")
@ApiResponse(responseCode = "204", description = "Password reset")
@ApiResponse(responseCode = "400", description = "Disabled in OIDC mode or policy violation")
public ResponseEntity<Void> resetPassword(
@PathVariable String userId,
@Valid @RequestBody SetPasswordRequest request,
HttpServletRequest httpRequest) {
// Block local UI users from resetting passwords when OIDC is enabled,
// but allow M2M callers (SaaS platform) identified by "oidc:" principal prefix
if (oidcEnabled) {
String caller = httpRequest.getUserPrincipal() != null ? httpRequest.getUserPrincipal().getName() : "";
if (!caller.startsWith("oidc:")) {
return ResponseEntity.badRequest().build();
}
}
// Extract bare username from "user:username" format for policy check
String username = userId.startsWith("user:") ? userId.substring(5) : userId;
List<String> violations = PasswordPolicyValidator.validate(request.password(), username);
if (!violations.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Password policy violation: " + String.join("; ", violations));
}
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
// Revoke all existing tokens so the user must re-authenticate with the new password
userRepository.revokeTokensBefore(userId, Instant.now());
auditService.log("reset_password", AuditCategory.USER_MGMT, userId, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
public record CreateUserRequest(String username, String displayName, String email, String password) {}
public record UpdateUserRequest(String displayName, String email) {}
}

View File

@@ -0,0 +1,11 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "Currently running database query")
public record ActiveQueryResponse(
@Schema(description = "Backend process ID") int pid,
@Schema(description = "Query duration in seconds") double durationSeconds,
@Schema(description = "Backend state (active, idle, etc.)") String state,
@Schema(description = "SQL query text") String query
) {}

View File

@@ -0,0 +1,24 @@
package com.cameleer.server.app.dto;
import com.cameleer.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 instanceId,
@NotNull String applicationId,
@NotNull String eventType,
String detail,
@NotNull Instant timestamp
) {
public static AgentEventResponse from(AgentEventRecord event) {
return new AgentEventResponse(
event.id(), event.instanceId(), event.applicationId(),
event.eventType(), event.detail(), event.timestamp()
);
}
}

View File

@@ -0,0 +1,52 @@
package com.cameleer.server.app.dto;
import com.cameleer.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;
import java.util.Map;
@Schema(description = "Agent instance summary with runtime metrics")
public record AgentInstanceResponse(
@NotNull String instanceId,
@NotNull String displayName,
@NotNull String applicationId,
String environmentId,
@NotNull String status,
@NotNull List<String> routeIds,
@NotNull Instant registeredAt,
@NotNull Instant lastHeartbeat,
String version,
Map<String, Object> capabilities,
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.instanceId(), info.displayName(), info.applicationId(),
info.environmentId(),
info.state().name(), info.routeIds(),
info.registeredAt(), info.lastHeartbeat(),
info.version(), info.capabilities(),
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(
instanceId, displayName, applicationId, environmentId,
status, routeIds, registeredAt, lastHeartbeat,
version, capabilities,
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
);
}
}

View File

@@ -0,0 +1,9 @@
package com.cameleer.server.app.dto;
import java.util.List;
import java.util.Map;
import jakarta.validation.constraints.NotNull;
public record AgentMetricsResponse(
@NotNull Map<String, List<MetricBucket>> metrics
) {}

View File

@@ -0,0 +1,7 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "Agent token refresh request")
public record AgentRefreshRequest(@NotNull String refreshToken) {}

View File

@@ -0,0 +1,7 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "Refreshed access and refresh tokens")
public record AgentRefreshResponse(@NotNull String accessToken, @NotNull String refreshToken) {}

View File

@@ -0,0 +1,17 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Map;
@Schema(description = "Agent registration payload")
public record AgentRegistrationRequest(
@NotNull String instanceId,
@Schema(defaultValue = "default") String applicationId,
@Schema(defaultValue = "default") String environmentId,
String version,
List<String> routeIds,
Map<String, Object> capabilities
) {}

View File

@@ -0,0 +1,14 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "Agent registration result with JWT tokens and SSE endpoint")
public record AgentRegistrationResponse(
@NotNull String instanceId,
@NotNull String sseEndpoint,
long heartbeatIntervalMs,
@NotNull String serverPublicKey,
@NotNull String accessToken,
@NotNull String refreshToken
) {}

View File

@@ -0,0 +1,12 @@
package com.cameleer.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
) {}

View File

@@ -0,0 +1,16 @@
package com.cameleer.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
) {}

View File

@@ -0,0 +1,14 @@
package com.cameleer.server.app.dto;
import com.cameleer.common.model.ApplicationConfig;
import java.util.List;
/**
* Wraps ApplicationConfig with additional server-computed fields for the UI.
*/
public record AppConfigResponse(
ApplicationConfig config,
List<String> globalSensitiveKeys,
List<String> mergedSensitiveKeys
) {}

View File

@@ -0,0 +1,54 @@
package com.cameleer.server.app.dto;
import com.cameleer.server.core.admin.AppSettings;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Schema(description = "Per-application dashboard settings")
public record AppSettingsRequest(
@NotNull @Min(1)
@Schema(description = "SLA duration threshold in milliseconds")
Integer slaThresholdMs,
@NotNull @Min(0) @Max(100)
@Schema(description = "Error rate % threshold for warning (yellow) health dot")
Double healthErrorWarn,
@NotNull @Min(0) @Max(100)
@Schema(description = "Error rate % threshold for critical (red) health dot")
Double healthErrorCrit,
@NotNull @Min(0) @Max(100)
@Schema(description = "SLA compliance % threshold for warning (yellow) health dot")
Double healthSlaWarn,
@NotNull @Min(0) @Max(100)
@Schema(description = "SLA compliance % threshold for critical (red) health dot")
Double healthSlaCrit
) {
public AppSettings toSettings(String appId) {
Instant now = Instant.now();
return new AppSettings(appId, slaThresholdMs, healthErrorWarn, healthErrorCrit,
healthSlaWarn, healthSlaCrit, now, now);
}
public List<String> validate() {
List<String> errors = new ArrayList<>();
if (healthErrorWarn != null && healthErrorCrit != null
&& healthErrorWarn > healthErrorCrit) {
errors.add("healthErrorWarn must be <= healthErrorCrit");
}
if (healthSlaWarn != null && healthSlaCrit != null
&& healthSlaWarn < healthSlaCrit) {
errors.add("healthSlaWarn must be >= healthSlaCrit (higher SLA = healthier)");
}
return errors;
}
}

View File

@@ -0,0 +1,15 @@
package com.cameleer.server.app.dto;
import com.cameleer.server.core.admin.AuditRecord;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
@Schema(description = "Paginated audit log entries")
public record AuditLogPageResponse(
@Schema(description = "Audit log entries") List<AuditRecord> items,
@Schema(description = "Total number of matching entries") long totalCount,
@Schema(description = "Current page number (0-based)") int page,
@Schema(description = "Page size") int pageSize,
@Schema(description = "Total number of pages") int totalPages
) {}

View File

@@ -0,0 +1,13 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "JWT token pair")
public record AuthTokenResponse(
@NotNull String accessToken,
@NotNull String refreshToken,
@NotNull String displayName,
@Schema(description = "OIDC id_token for end-session logout (only present after OIDC login)")
String idToken
) {}

View File

@@ -0,0 +1,26 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
@Schema(description = "Unified catalog entry combining app records with live agent data")
public record CatalogApp(
@Schema(description = "Application slug (universal identifier)") String slug,
@Schema(description = "Display name") String displayName,
@Schema(description = "True if a managed App record exists in the database") boolean managed,
@Schema(description = "Environment slug") String environmentSlug,
@Schema(description = "Composite health: deployment status + agent health") String health,
@Schema(description = "Human-readable tooltip explaining the health state") String healthTooltip,
@Schema(description = "Number of connected agents") int agentCount,
@Schema(description = "Live routes from agents") List<RouteSummary> routes,
@Schema(description = "Connected agent summaries") List<AgentSummary> agents,
@Schema(description = "Total exchange count from ClickHouse") long exchangeCount,
@Schema(description = "Active deployment info, null if no deployment") DeploymentSummary deployment
) {
public record DeploymentSummary(
String status,
String replicas,
int version
) {}
}

View File

@@ -0,0 +1,14 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "ClickHouse storage and performance metrics")
public record ClickHousePerformanceResponse(
String diskSize,
String uncompressedSize,
double compressionRatio,
long totalRows,
int partCount,
String memoryUsage,
int currentQueries
) {}

View File

@@ -0,0 +1,12 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "Active ClickHouse query information")
public record ClickHouseQueryInfo(
String queryId,
double elapsedSeconds,
String memory,
long readRows,
String query
) {}

View File

@@ -0,0 +1,11 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "ClickHouse cluster status")
public record ClickHouseStatusResponse(
boolean reachable,
String version,
String uptime,
String host
) {}

View File

@@ -0,0 +1,13 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "ClickHouse table information")
public record ClickHouseTableInfo(
String name,
String engine,
long rowCount,
String dataSize,
long dataSizeBytes,
int partitionCount
) {}

View File

@@ -0,0 +1,11 @@
package com.cameleer.server.app.dto;
/**
* Request body for command acknowledgment from agents.
* Contains the result status and message of the command execution.
*
* @param status "SUCCESS" or "FAILURE"
* @param message human-readable description of the result
* @param data optional structured JSON data returned by the agent (e.g. expression evaluation results)
*/
public record CommandAckRequest(String status, String message, String data) {}

View File

@@ -0,0 +1,12 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.util.List;
@Schema(description = "Result of broadcasting a command to multiple agents")
public record CommandBroadcastResponse(
@NotNull List<String> commandIds,
int targetCount
) {}

View File

@@ -0,0 +1,13 @@
package com.cameleer.server.app.dto;
import java.util.List;
public record CommandGroupResponse(
boolean success,
int total,
int responded,
List<AgentResponse> responses,
List<String> timedOut
) {
public record AgentResponse(String agentId, String status, String message) {}
}

View File

@@ -0,0 +1,12 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "Command to send to agent(s)")
public record CommandRequest(
@NotNull @Schema(description = "Command type: config-update, deep-trace, or replay")
String type,
@Schema(description = "Command payload JSON")
Object payload
) {}

View File

@@ -0,0 +1,10 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "Result of sending a command to a single agent")
public record CommandSingleResponse(
@NotNull String commandId,
@NotNull String status
) {}

View File

@@ -0,0 +1,8 @@
package com.cameleer.server.app.dto;
import com.cameleer.common.model.ApplicationConfig;
public record ConfigUpdateResponse(
ApplicationConfig config,
CommandGroupResponse pushResult
) {}

View File

@@ -0,0 +1,12 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "HikariCP connection pool statistics")
public record ConnectionPoolResponse(
@Schema(description = "Number of currently active connections") int activeConnections,
@Schema(description = "Number of idle connections") int idleConnections,
@Schema(description = "Number of threads waiting for a connection") int pendingThreads,
@Schema(description = "Maximum wait time in milliseconds") long maxWaitMs,
@Schema(description = "Maximum pool size") int maxPoolSize
) {}

View File

@@ -0,0 +1,11 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "Database connection and version status")
public record DatabaseStatusResponse(
@Schema(description = "Whether the database is reachable") boolean connected,
@Schema(description = "PostgreSQL version string") String version,
@Schema(description = "Database host") String host,
@Schema(description = "Current schema") String schema
) {}

View File

@@ -0,0 +1,7 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "Error response")
public record ErrorResponse(@NotNull String message) {}

View File

@@ -0,0 +1,16 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
@Schema(description = "Search indexer pipeline statistics")
public record IndexerPipelineResponse(
int queueDepth,
int maxQueueSize,
long failedCount,
long indexedCount,
long debounceMs,
double indexingRate,
Instant lastIndexedAt
) {}

View File

@@ -0,0 +1,20 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Map;
@Schema(description = "Application log entry")
public record LogEntryResponse(
@Schema(description = "Log timestamp (ISO-8601)") String timestamp,
@Schema(description = "Log level (INFO, WARN, ERROR, DEBUG, TRACE)") String level,
@Schema(description = "Logger name") String loggerName,
@Schema(description = "Log message") String message,
@Schema(description = "Thread name") String threadName,
@Schema(description = "Stack trace (if present)") String stackTrace,
@Schema(description = "Camel exchange ID (if present)") String exchangeId,
@Schema(description = "Agent instance ID") String instanceId,
@Schema(description = "Application ID") String application,
@Schema(description = "MDC context map") Map<String, String> mdc,
@Schema(description = "Log source: app or agent") String source
) {}

View File

@@ -0,0 +1,14 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import java.util.Map;
@Schema(description = "Log search response with cursor pagination and level counts")
public record LogSearchPageResponse(
@Schema(description = "Log entries for the current page") List<LogEntryResponse> data,
@Schema(description = "Cursor for next page (null if no more results)") String nextCursor,
@Schema(description = "Whether more results exist beyond this page") boolean hasMore,
@Schema(description = "Count of logs per level (unaffected by level filter)") Map<String, Long> levelCounts
) {}

View File

@@ -0,0 +1,9 @@
package com.cameleer.server.app.dto;
import java.time.Instant;
import jakarta.validation.constraints.NotNull;
public record MetricBucket(
@NotNull Instant time,
double value
) {}

View File

@@ -0,0 +1,20 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
@Schema(description = "OIDC configuration update request")
public record OidcAdminConfigRequest(
boolean enabled,
String issuerUri,
String clientId,
String clientSecret,
String rolesClaim,
List<String> defaultRoles,
boolean autoSignup,
String displayNameClaim,
String userIdClaim,
String audience,
List<String> additionalScopes
) {}

View File

@@ -0,0 +1,36 @@
package com.cameleer.server.app.dto;
import com.cameleer.server.core.security.OidcConfig;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.util.List;
@Schema(description = "OIDC configuration for admin management")
public record OidcAdminConfigResponse(
boolean configured,
boolean enabled,
String issuerUri,
String clientId,
boolean clientSecretSet,
String rolesClaim,
List<String> defaultRoles,
boolean autoSignup,
String displayNameClaim,
String userIdClaim,
String audience,
List<String> additionalScopes
) {
public static OidcAdminConfigResponse unconfigured() {
return new OidcAdminConfigResponse(false, false, null, null, false, null, null, false, null, null, null, null);
}
public static OidcAdminConfigResponse from(OidcConfig config) {
return new OidcAdminConfigResponse(
true, config.enabled(), config.issuerUri(), config.clientId(),
!config.clientSecret().isBlank(), config.rolesClaim(),
config.defaultRoles(), config.autoSignup(), config.displayNameClaim(),
config.userIdClaim(), config.audience(), config.additionalScopes()
);
}
}

View File

@@ -0,0 +1,17 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "OIDC configuration for SPA login flow")
public record OidcPublicConfigResponse(
@NotNull String issuer,
@NotNull String clientId,
@NotNull String authorizationEndpoint,
@Schema(description = "Present if the provider supports RP-initiated logout")
String endSessionEndpoint,
@Schema(description = "RFC 8707 resource indicator for the authorization request")
String resource,
@Schema(description = "Additional scopes to request beyond openid email profile")
java.util.List<String> additionalScopes
) {}

View File

@@ -0,0 +1,10 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "OIDC provider connectivity test result")
public record OidcTestResult(
@NotNull String status,
@NotNull String authorizationEndpoint
) {}

View File

@@ -0,0 +1,15 @@
package com.cameleer.server.app.dto;
import jakarta.validation.constraints.NotNull;
public record ProcessorMetrics(
@NotNull String processorId,
@NotNull String processorType,
@NotNull String routeId,
@NotNull String appId,
long totalCount,
long failedCount,
double avgDurationMs,
double p99DurationMs,
double errorRate
) {}

Some files were not shown because too many files have changed in this diff Show More