chore: rename cameleer3 to cameleer
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:
203
cameleer-server-app/pom.xml
Normal file
203
cameleer-server-app/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)) + "'";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, */*" 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);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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() : "";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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("'", "\\'") + "'";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
) {}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cameleer.server.app.dto;
|
||||
|
||||
import com.cameleer.common.model.ApplicationConfig;
|
||||
|
||||
public record ConfigUpdateResponse(
|
||||
ApplicationConfig config,
|
||||
CommandGroupResponse pushResult
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
Reference in New Issue
Block a user