Contract-first API with DTOs, validation, and server-side OpenAPI post-processing
All checks were successful
CI / build (push) Successful in 1m27s
CI / docker (push) Successful in 2m6s
CI / deploy (push) Successful in 30s

Add dedicated request/response DTOs for all controllers, replacing raw
JsonNode parameters with validated types. Move OpenAPI path-prefix stripping
and ProcessorNode children injection into OpenApiCustomizer beans so the
spec served at /api/v1/api-docs is already clean — eliminating the need for
the ui/scripts/process-openapi.mjs post-processing script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-14 15:33:37 +01:00
parent 50bb22d6f6
commit 465f210aee
43 changed files with 1561 additions and 509 deletions

View File

@@ -66,6 +66,10 @@
<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>

View File

@@ -0,0 +1,90 @@
package com.cameleer3.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 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"
);
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info().title("Cameleer3 Server API").version("1.0"))
.addSecurityItem(new SecurityRequirement().addList("bearer"))
.servers(List.of());
}
@Bean
public OpenApiCustomizer pathPrefixStripper() {
return openApi -> {
var original = openApi.getPaths();
if (original == null) return;
String prefix = "/api/v1";
var stripped = new Paths();
for (var entry : original.entrySet()) {
String path = entry.getKey();
stripped.addPathItem(
path.startsWith(prefix) ? path.substring(prefix.length()) : path,
entry.getValue());
}
openApi.setPaths(stripped);
};
}
@Bean
@SuppressWarnings("unchecked")
public OpenApiCustomizer schemaCustomizer() {
return openApi -> {
var schemas = openApi.getComponents().getSchemas();
if (schemas == null) return;
// Add children to ProcessorNode if missing (recursive self-reference)
if (schemas.containsKey("ProcessorNode")) {
Schema<Object> processorNode = schemas.get("ProcessorNode");
if (processorNode.getProperties() != null
&& !processorNode.getProperties().containsKey("children")) {
Schema<?> selfRef = new Schema<>().$ref("#/components/schemas/ProcessorNode");
ArraySchema childrenArray = new ArraySchema().items(selfRef);
processorNode.addProperty("children", childrenArray);
}
}
// Mark all fields as required for core domain models
for (String schemaName : ALL_FIELDS_REQUIRED) {
if (schemas.containsKey(schemaName)) {
Schema<Object> schema = schemas.get(schemaName);
if (schema.getProperties() != null) {
schema.setRequired(new ArrayList<>(schema.getProperties().keySet()));
}
}
}
};
}
}

View File

@@ -1,13 +1,15 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.agent.SseConnectionManager;
import com.cameleer3.server.app.dto.CommandBroadcastResponse;
import com.cameleer3.server.app.dto.CommandRequest;
import com.cameleer3.server.app.dto.CommandSingleResponse;
import com.cameleer3.server.core.agent.AgentCommand;
import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.agent.AgentState;
import com.cameleer3.server.core.agent.CommandType;
import com.fasterxml.jackson.core.JsonProcessingException;
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.responses.ApiResponse;
@@ -24,9 +26,7 @@ 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;
/**
* Command push endpoints for sending commands to agents via SSE.
@@ -63,24 +63,21 @@ public class AgentCommandController {
@ApiResponse(responseCode = "202", description = "Command accepted")
@ApiResponse(responseCode = "400", description = "Invalid command payload")
@ApiResponse(responseCode = "404", description = "Agent not registered")
public ResponseEntity<String> sendCommand(@PathVariable String id,
@RequestBody String body) throws JsonProcessingException {
public ResponseEntity<CommandSingleResponse> sendCommand(@PathVariable String id,
@RequestBody CommandRequest request) throws JsonProcessingException {
AgentInfo agent = registryService.findById(id);
if (agent == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
}
CommandRequest request = parseCommandRequest(body);
AgentCommand command = registryService.addCommand(id, request.type, request.payloadJson);
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";
Map<String, Object> response = new LinkedHashMap<>();
response.put("commandId", command.id());
response.put("status", status);
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(objectMapper.writeValueAsString(response));
.body(new CommandSingleResponse(command.id(), status));
}
@PostMapping("/groups/{group}/commands")
@@ -88,9 +85,10 @@ public class AgentCommandController {
description = "Sends a command to all LIVE agents in the specified group")
@ApiResponse(responseCode = "202", description = "Commands accepted")
@ApiResponse(responseCode = "400", description = "Invalid command payload")
public ResponseEntity<String> sendGroupCommand(@PathVariable String group,
@RequestBody String body) throws JsonProcessingException {
CommandRequest request = parseCommandRequest(body);
public ResponseEntity<CommandBroadcastResponse> sendGroupCommand(@PathVariable String group,
@RequestBody CommandRequest request) throws JsonProcessingException {
CommandType type = mapCommandType(request.type());
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
List<AgentInfo> agents = registryService.findAll().stream()
.filter(a -> a.state() == AgentState.LIVE)
@@ -99,16 +97,12 @@ public class AgentCommandController {
List<String> commandIds = new ArrayList<>();
for (AgentInfo agent : agents) {
AgentCommand command = registryService.addCommand(agent.id(), request.type, request.payloadJson);
AgentCommand command = registryService.addCommand(agent.id(), type, payloadJson);
commandIds.add(command.id());
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("commandIds", commandIds);
response.put("targetCount", agents.size());
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(objectMapper.writeValueAsString(response));
.body(new CommandBroadcastResponse(commandIds, agents.size()));
}
@PostMapping("/commands")
@@ -116,23 +110,20 @@ public class AgentCommandController {
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<String> broadcastCommand(@RequestBody String body) throws JsonProcessingException {
CommandRequest request = parseCommandRequest(body);
public ResponseEntity<CommandBroadcastResponse> broadcastCommand(@RequestBody CommandRequest request) throws JsonProcessingException {
CommandType type = mapCommandType(request.type());
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
List<AgentInfo> liveAgents = registryService.findByState(AgentState.LIVE);
List<String> commandIds = new ArrayList<>();
for (AgentInfo agent : liveAgents) {
AgentCommand command = registryService.addCommand(agent.id(), request.type, request.payloadJson);
AgentCommand command = registryService.addCommand(agent.id(), type, payloadJson);
commandIds.add(command.id());
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("commandIds", commandIds);
response.put("targetCount", liveAgents.size());
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(objectMapper.writeValueAsString(response));
.body(new CommandBroadcastResponse(commandIds, liveAgents.size()));
}
@PostMapping("/{id}/commands/{commandId}/ack")
@@ -149,24 +140,6 @@ public class AgentCommandController {
return ResponseEntity.ok().build();
}
private CommandRequest parseCommandRequest(String body) throws JsonProcessingException {
JsonNode node = objectMapper.readTree(body);
if (!node.has("type")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing 'type' field");
}
String typeStr = node.get("type").asText();
CommandType type = mapCommandType(typeStr);
String payloadJson = "{}";
if (node.has("payload")) {
payloadJson = node.get("payload").toString();
}
return new CommandRequest(type, payloadJson);
}
private CommandType mapCommandType(String typeStr) {
return switch (typeStr) {
case "config-update" -> CommandType.CONFIG_UPDATE;
@@ -176,6 +149,4 @@ public class AgentCommandController {
"Invalid command type: " + typeStr + ". Valid: config-update, deep-trace, replay");
};
}
private record CommandRequest(CommandType type, String payloadJson) {}
}

View File

@@ -1,6 +1,12 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.config.AgentRegistryConfig;
import com.cameleer3.server.app.dto.AgentInstanceResponse;
import com.cameleer3.server.app.dto.AgentRefreshRequest;
import com.cameleer3.server.app.dto.AgentRefreshResponse;
import com.cameleer3.server.app.dto.AgentRegistrationRequest;
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
import com.cameleer3.server.app.dto.ErrorResponse;
import com.cameleer3.server.app.security.BootstrapTokenValidator;
import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService;
@@ -8,10 +14,9 @@ import com.cameleer3.server.core.agent.AgentState;
import com.cameleer3.server.core.security.Ed25519SigningService;
import com.cameleer3.server.core.security.InvalidTokenException;
import com.cameleer3.server.core.security.JwtService;
import com.fasterxml.jackson.core.JsonProcessingException;
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.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;
@@ -26,12 +31,8 @@ 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.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Agent registration, heartbeat, listing, and token refresh endpoints.
@@ -46,20 +47,17 @@ public class AgentRegistrationController {
private final AgentRegistryService registryService;
private final AgentRegistryConfig config;
private final ObjectMapper objectMapper;
private final BootstrapTokenValidator bootstrapTokenValidator;
private final JwtService jwtService;
private final Ed25519SigningService ed25519SigningService;
public AgentRegistrationController(AgentRegistryService registryService,
AgentRegistryConfig config,
ObjectMapper objectMapper,
BootstrapTokenValidator bootstrapTokenValidator,
JwtService jwtService,
Ed25519SigningService ed25519SigningService) {
this.registryService = registryService;
this.config = config;
this.objectMapper = objectMapper;
this.bootstrapTokenValidator = bootstrapTokenValidator;
this.jwtService = jwtService;
this.ed25519SigningService = ed25519SigningService;
@@ -70,12 +68,14 @@ public class AgentRegistrationController {
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")
@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<String> register(@RequestBody String body,
HttpServletRequest request) throws JsonProcessingException {
public ResponseEntity<AgentRegistrationResponse> register(
@RequestBody AgentRegistrationRequest request,
HttpServletRequest httpRequest) {
// Validate bootstrap token
String authHeader = request.getHeader("Authorization");
String authHeader = httpRequest.getHeader("Authorization");
String bootstrapToken = null;
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
bootstrapToken = authHeader.substring(BEARER_PREFIX.length());
@@ -84,52 +84,32 @@ public class AgentRegistrationController {
return ResponseEntity.status(401).build();
}
JsonNode node = objectMapper.readTree(body);
String agentId = getRequiredField(node, "agentId");
String name = getRequiredField(node, "name");
if (agentId == null || name == null) {
return ResponseEntity.badRequest()
.body("{\"error\":\"agentId and name are required\"}");
if (request.agentId() == null || request.agentId().isBlank()
|| request.name() == null || request.name().isBlank()) {
return ResponseEntity.badRequest().build();
}
String group = node.has("group") ? node.get("group").asText() : "default";
String version = node.has("version") ? node.get("version").asText() : null;
String group = request.group() != null ? request.group() : "default";
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
List<String> routeIds = new ArrayList<>();
if (node.has("routeIds") && node.get("routeIds").isArray()) {
for (JsonNode rid : node.get("routeIds")) {
routeIds.add(rid.asText());
}
}
Map<String, Object> capabilities = Collections.emptyMap();
if (node.has("capabilities") && node.get("capabilities").isObject()) {
capabilities = new LinkedHashMap<>();
Iterator<Map.Entry<String, JsonNode>> fields = node.get("capabilities").fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
capabilities.put(field.getKey(), parseJsonValue(field.getValue()));
}
}
AgentInfo agent = registryService.register(agentId, name, group, version, routeIds, capabilities);
log.info("Agent registered: {} (name={}, group={})", agentId, name, group);
AgentInfo agent = registryService.register(
request.agentId(), request.name(), group, request.version(), routeIds, capabilities);
log.info("Agent registered: {} (name={}, group={})", request.agentId(), request.name(), group);
// Issue JWT tokens with AGENT role
java.util.List<String> roles = java.util.List.of("AGENT");
String accessToken = jwtService.createAccessToken(agentId, group, roles);
String refreshToken = jwtService.createRefreshToken(agentId, group, roles);
List<String> roles = List.of("AGENT");
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles);
String refreshToken = jwtService.createRefreshToken(request.agentId(), group, roles);
Map<String, Object> response = new LinkedHashMap<>();
response.put("agentId", agent.id());
response.put("sseEndpoint", "/api/v1/agents/" + agentId + "/events");
response.put("heartbeatIntervalMs", config.getHeartbeatIntervalMs());
response.put("serverPublicKey", ed25519SigningService.getPublicKeyBase64());
response.put("accessToken", accessToken);
response.put("refreshToken", refreshToken);
return ResponseEntity.ok(objectMapper.writeValueAsString(response));
return ResponseEntity.ok(new AgentRegistrationResponse(
agent.id(),
"/api/v1/agents/" + agent.id() + "/events",
config.getHeartbeatIntervalMs(),
ed25519SigningService.getPublicKeyBase64(),
accessToken,
refreshToken
));
}
@PostMapping("/{id}/refresh")
@@ -138,19 +118,16 @@ public class AgentRegistrationController {
@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<String> refresh(@PathVariable String id,
@RequestBody String body) throws JsonProcessingException {
JsonNode node = objectMapper.readTree(body);
String refreshToken = node.has("refreshToken") ? node.get("refreshToken").asText() : null;
if (refreshToken == null || refreshToken.isBlank()) {
public ResponseEntity<AgentRefreshResponse> refresh(@PathVariable String id,
@RequestBody AgentRefreshRequest request) {
if (request.refreshToken() == null || request.refreshToken().isBlank()) {
return ResponseEntity.status(401).build();
}
// Validate refresh token
com.cameleer3.server.core.security.JwtService.JwtValidationResult result;
JwtService.JwtValidationResult result;
try {
result = jwtService.validateRefreshToken(refreshToken);
result = jwtService.validateRefreshToken(request.refreshToken());
} catch (InvalidTokenException e) {
log.debug("Refresh token validation failed: {}", e.getMessage());
return ResponseEntity.status(401).build();
@@ -171,14 +148,11 @@ public class AgentRegistrationController {
}
// Preserve roles from refresh token
java.util.List<String> roles = result.roles().isEmpty()
? java.util.List.of("AGENT") : result.roles();
List<String> roles = result.roles().isEmpty()
? List.of("AGENT") : result.roles();
String newAccessToken = jwtService.createAccessToken(agentId, agent.group(), roles);
Map<String, Object> response = new LinkedHashMap<>();
response.put("accessToken", newAccessToken);
return ResponseEntity.ok(objectMapper.writeValueAsString(response));
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken));
}
@PostMapping("/{id}/heartbeat")
@@ -198,9 +172,10 @@ public class AgentRegistrationController {
@Operation(summary = "List all agents",
description = "Returns all registered agents, optionally filtered by status")
@ApiResponse(responseCode = "200", description = "Agent list returned")
@ApiResponse(responseCode = "400", description = "Invalid status filter")
public ResponseEntity<String> listAgents(
@RequestParam(required = false) String status) throws JsonProcessingException {
@ApiResponse(responseCode = "400", description = "Invalid status filter",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
@RequestParam(required = false) String status) {
List<AgentInfo> agents;
if (status != null) {
@@ -208,29 +183,15 @@ public class AgentRegistrationController {
AgentState stateFilter = AgentState.valueOf(status.toUpperCase());
agents = registryService.findByState(stateFilter);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest()
.body("{\"error\":\"Invalid status: " + status + ". Valid values: LIVE, STALE, DEAD\"}");
return ResponseEntity.badRequest().build();
}
} else {
agents = registryService.findAll();
}
return ResponseEntity.ok(objectMapper.writeValueAsString(agents));
}
private String getRequiredField(JsonNode node, String fieldName) {
if (!node.has(fieldName) || node.get(fieldName).isNull() || node.get(fieldName).asText().isBlank()) {
return null;
}
return node.get(fieldName).asText();
}
private Object parseJsonValue(JsonNode node) {
if (node.isBoolean()) return node.asBoolean();
if (node.isInt()) return node.asInt();
if (node.isLong()) return node.asLong();
if (node.isDouble()) return node.asDouble();
if (node.isTextual()) return node.asText();
return node.toString();
List<AgentInstanceResponse> response = agents.stream()
.map(AgentInstanceResponse::from)
.toList();
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,20 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.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) {
return ResponseEntity.status(ex.getStatusCode())
.body(new ErrorResponse(ex.getReason() != null ? ex.getReason() : "Unknown error"));
}
}

View File

@@ -4,6 +4,7 @@ import com.cameleer3.server.app.storage.ClickHouseExecutionRepository;
import com.cameleer3.server.core.detail.DetailService;
import com.cameleer3.server.core.detail.ExecutionDetail;
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;
@@ -36,6 +37,8 @@ public class DetailController {
@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)
@@ -44,6 +47,8 @@ public class DetailController {
@GetMapping("/{executionId}/processors/{index}/snapshot")
@Operation(summary = "Get exchange snapshot for a specific processor")
@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) {

View File

@@ -5,6 +5,8 @@ import com.cameleer3.server.core.diagram.DiagramLayout;
import com.cameleer3.server.core.diagram.DiagramRenderer;
import com.cameleer3.server.core.storage.DiagramRepository;
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;
@@ -45,7 +47,11 @@ public class DiagramRenderController {
@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")
@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,

View File

@@ -1,13 +1,20 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.ErrorResponse;
import com.cameleer3.server.app.dto.OidcAdminConfigRequest;
import com.cameleer3.server.app.dto.OidcAdminConfigResponse;
import com.cameleer3.server.app.dto.OidcTestResult;
import com.cameleer3.server.app.security.OidcTokenExchanger;
import com.cameleer3.server.core.security.OidcConfig;
import com.cameleer3.server.core.security.OidcConfigRepository;
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.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -16,10 +23,9 @@ 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.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
@@ -45,19 +51,20 @@ public class OidcConfigAdminController {
@GetMapping
@Operation(summary = "Get OIDC configuration")
@ApiResponse(responseCode = "200", description = "Current OIDC configuration (client_secret masked)")
public ResponseEntity<?> getConfig() {
public ResponseEntity<OidcAdminConfigResponse> getConfig() {
Optional<OidcConfig> config = configRepository.find();
if (config.isEmpty()) {
return ResponseEntity.ok(Map.of("configured", false));
return ResponseEntity.ok(OidcAdminConfigResponse.unconfigured());
}
return ResponseEntity.ok(toResponse(config.get()));
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")
public ResponseEntity<?> saveConfig(@RequestBody OidcConfigRequest request) {
@ApiResponse(responseCode = "400", description = "Invalid configuration",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<OidcAdminConfigResponse> saveConfig(@RequestBody OidcAdminConfigRequest request) {
// Resolve client_secret: if masked or empty, preserve existing
String clientSecret = request.clientSecret();
if (clientSecret == null || clientSecret.isBlank() || clientSecret.equals("********")) {
@@ -66,12 +73,12 @@ public class OidcConfigAdminController {
}
if (request.enabled() && (request.issuerUri() == null || request.issuerUri().isBlank())) {
return ResponseEntity.badRequest()
.body(Map.of("message", "issuerUri is required when OIDC is enabled"));
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"issuerUri is required when OIDC is enabled");
}
if (request.enabled() && (request.clientId() == null || request.clientId().isBlank())) {
return ResponseEntity.badRequest()
.body(Map.of("message", "clientId is required when OIDC is enabled"));
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"clientId is required when OIDC is enabled");
}
OidcConfig config = new OidcConfig(
@@ -88,31 +95,29 @@ public class OidcConfigAdminController {
tokenExchanger.invalidateCache();
log.info("OIDC configuration updated: enabled={}, issuer={}", config.enabled(), config.issuerUri());
return ResponseEntity.ok(toResponse(config));
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")
public ResponseEntity<?> testConnection() {
@ApiResponse(responseCode = "400", description = "Provider unreachable or misconfigured",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<OidcTestResult> testConnection() {
Optional<OidcConfig> config = configRepository.find();
if (config.isEmpty() || !config.get().enabled()) {
return ResponseEntity.badRequest()
.body(Map.of("message", "OIDC is not configured or disabled"));
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"OIDC is not configured or disabled");
}
try {
tokenExchanger.invalidateCache();
String authEndpoint = tokenExchanger.getAuthorizationEndpoint();
return ResponseEntity.ok(Map.of(
"status", "ok",
"authorizationEndpoint", authEndpoint
));
return ResponseEntity.ok(new OidcTestResult("ok", authEndpoint));
} catch (Exception e) {
log.warn("OIDC connectivity test failed: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(Map.of("message", "Failed to reach OIDC provider: " + e.getMessage()));
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Failed to reach OIDC provider: " + e.getMessage());
}
}
@@ -125,27 +130,4 @@ public class OidcConfigAdminController {
log.info("OIDC configuration deleted");
return ResponseEntity.noContent().build();
}
private Map<String, Object> toResponse(OidcConfig config) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("configured", true);
map.put("enabled", config.enabled());
map.put("issuerUri", config.issuerUri());
map.put("clientId", config.clientId());
map.put("clientSecretSet", !config.clientSecret().isBlank());
map.put("rolesClaim", config.rolesClaim());
map.put("defaultRoles", config.defaultRoles());
map.put("autoSignup", config.autoSignup());
return map;
}
public record OidcConfigRequest(
boolean enabled,
String issuerUri,
String clientId,
String clientSecret,
String rolesClaim,
List<String> defaultRoles,
boolean autoSignup
) {}
}

View File

@@ -0,0 +1,27 @@
package com.cameleer3.server.app.dto;
import com.cameleer3.server.core.agent.AgentInfo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
import java.util.List;
@Schema(description = "Agent instance summary")
public record AgentInstanceResponse(
@NotNull String id,
@NotNull String name,
@NotNull String group,
@NotNull String status,
@NotNull List<String> routeIds,
@NotNull Instant registeredAt,
@NotNull Instant lastHeartbeat
) {
public static AgentInstanceResponse from(AgentInfo info) {
return new AgentInstanceResponse(
info.id(), info.name(), info.group(),
info.state().name(), info.routeIds(),
info.registeredAt(), info.lastHeartbeat()
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
package com.cameleer3.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 agentId,
@NotNull String name,
@Schema(defaultValue = "default") String group,
String version,
List<String> routeIds,
Map<String, Object> capabilities
) {}

View File

@@ -0,0 +1,14 @@
package com.cameleer3.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 agentId,
@NotNull String sseEndpoint,
long heartbeatIntervalMs,
@NotNull String serverPublicKey,
@NotNull String accessToken,
@NotNull String refreshToken
) {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
package com.cameleer3.server.app.dto;
import com.cameleer3.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
) {
public static OidcAdminConfigResponse unconfigured() {
return new OidcAdminConfigResponse(false, false, null, null, false, null, null, false);
}
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()
);
}
}

View File

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

View File

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

View File

@@ -1,24 +1,32 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.app.dto.AuthTokenResponse;
import com.cameleer3.server.app.dto.ErrorResponse;
import com.cameleer3.server.app.dto.OidcPublicConfigResponse;
import com.cameleer3.server.core.security.JwtService;
import com.cameleer3.server.core.security.OidcConfig;
import com.cameleer3.server.core.security.OidcConfigRepository;
import com.cameleer3.server.core.security.UserInfo;
import com.cameleer3.server.core.security.UserRepository;
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.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.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.net.URI;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
@@ -29,6 +37,7 @@ import java.util.Optional;
*/
@RestController
@RequestMapping("/api/v1/auth/oidc")
@Tag(name = "Authentication", description = "Login and token refresh endpoints")
public class OidcAuthController {
private static final Logger log = LoggerFactory.getLogger(OidcAuthController.class);
@@ -53,7 +62,12 @@ public class OidcAuthController {
* Returns 404 if OIDC is not configured or disabled.
*/
@GetMapping("/config")
public ResponseEntity<?> getConfig() {
@Operation(summary = "Get OIDC config for SPA login flow")
@ApiResponse(responseCode = "200", description = "OIDC configuration")
@ApiResponse(responseCode = "404", description = "OIDC not configured or disabled")
@ApiResponse(responseCode = "500", description = "Failed to retrieve OIDC provider metadata",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<OidcPublicConfigResponse> getConfig() {
Optional<OidcConfig> config = configRepository.find();
if (config.isEmpty() || !config.get().enabled()) {
return ResponseEntity.notFound().build();
@@ -61,19 +75,17 @@ public class OidcAuthController {
try {
OidcConfig oidc = config.get();
Map<String, Object> response = new LinkedHashMap<>();
response.put("issuer", oidc.issuerUri());
response.put("clientId", oidc.clientId());
response.put("authorizationEndpoint", tokenExchanger.getAuthorizationEndpoint());
String endSessionEndpoint = tokenExchanger.getEndSessionEndpoint();
if (endSessionEndpoint != null) {
response.put("endSessionEndpoint", endSessionEndpoint);
}
return ResponseEntity.ok(response);
return ResponseEntity.ok(new OidcPublicConfigResponse(
oidc.issuerUri(),
oidc.clientId(),
tokenExchanger.getAuthorizationEndpoint(),
endSessionEndpoint
));
} catch (Exception e) {
log.error("Failed to retrieve OIDC provider metadata: {}", e.getMessage());
return ResponseEntity.internalServerError()
.body(Map.of("message", "Failed to retrieve OIDC provider metadata"));
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
"Failed to retrieve OIDC provider metadata");
}
}
@@ -81,7 +93,14 @@ public class OidcAuthController {
* Exchanges an OIDC authorization code for internal Cameleer JWTs.
*/
@PostMapping("/callback")
public ResponseEntity<?> callback(@RequestBody CallbackRequest request) {
@Operation(summary = "Exchange OIDC authorization code for JWTs")
@ApiResponse(responseCode = "200", description = "Authentication successful")
@ApiResponse(responseCode = "401", description = "OIDC authentication failed",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@ApiResponse(responseCode = "403", description = "Account not provisioned",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@ApiResponse(responseCode = "404", description = "OIDC not configured or disabled")
public ResponseEntity<AuthTokenResponse> callback(@RequestBody CallbackRequest request) {
Optional<OidcConfig> config = configRepository.find();
if (config.isEmpty() || !config.get().enabled()) {
return ResponseEntity.notFound().build();
@@ -98,8 +117,8 @@ public class OidcAuthController {
// Check auto-signup gate: if disabled, user must already exist
Optional<UserInfo> existingUser = userRepository.findById(userId);
if (!config.get().autoSignup() && existingUser.isEmpty()) {
return ResponseEntity.status(403)
.body(Map.of("message", "Account not provisioned. Contact your administrator."));
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Account not provisioned. Contact your administrator.");
}
// Resolve roles: DB override > OIDC claim > default
@@ -111,14 +130,13 @@ public class OidcAuthController {
String accessToken = jwtService.createAccessToken(userId, "ui", roles);
String refreshToken = jwtService.createRefreshToken(userId, "ui", roles);
return ResponseEntity.ok(Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken
));
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken));
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
log.error("OIDC callback failed: {}", e.getMessage(), e);
return ResponseEntity.status(401)
.body(Map.of("message", "OIDC authentication failed: " + e.getMessage()));
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
"OIDC authentication failed: " + e.getMessage());
}
}

View File

@@ -1,20 +1,28 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.app.dto.AuthTokenResponse;
import com.cameleer3.server.app.dto.ErrorResponse;
import com.cameleer3.server.core.security.JwtService;
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
import com.cameleer3.server.core.security.UserInfo;
import com.cameleer3.server.core.security.UserRepository;
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.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 org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* Authentication endpoints for the UI (local credentials).
@@ -25,6 +33,7 @@ import java.util.Map;
*/
@RestController
@RequestMapping("/api/v1/auth")
@Tag(name = "Authentication", description = "Login and token refresh endpoints")
public class UiAuthController {
private static final Logger log = LoggerFactory.getLogger(UiAuthController.class);
@@ -41,20 +50,24 @@ public class UiAuthController {
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
@Operation(summary = "Login with local credentials")
@ApiResponse(responseCode = "200", description = "Login successful")
@ApiResponse(responseCode = "401", description = "Invalid credentials",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<AuthTokenResponse> login(@RequestBody LoginRequest request) {
String configuredUser = properties.getUiUser();
String configuredPassword = properties.getUiPassword();
if (configuredUser == null || configuredUser.isBlank()
|| configuredPassword == null || configuredPassword.isBlank()) {
log.warn("UI authentication attempted but CAMELEER_UI_USER / CAMELEER_UI_PASSWORD not configured");
return ResponseEntity.status(401).body(Map.of("message", "UI authentication not configured"));
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "UI authentication not configured");
}
if (!configuredUser.equals(request.username())
|| !configuredPassword.equals(request.password())) {
log.debug("UI login failed for user: {}", request.username());
return ResponseEntity.status(401).body(Map.of("message", "Invalid credentials"));
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");
}
String subject = "ui:" + request.username();
@@ -72,18 +85,19 @@ public class UiAuthController {
String refreshToken = jwtService.createRefreshToken(subject, "ui", roles);
log.info("UI user logged in: {}", request.username());
return ResponseEntity.ok(Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken
));
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken));
}
@PostMapping("/refresh")
public ResponseEntity<?> refresh(@RequestBody RefreshRequest request) {
@Operation(summary = "Refresh access token")
@ApiResponse(responseCode = "200", description = "Token refreshed")
@ApiResponse(responseCode = "401", description = "Invalid refresh token",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<AuthTokenResponse> refresh(@RequestBody RefreshRequest request) {
try {
JwtValidationResult result = jwtService.validateRefreshToken(request.refreshToken());
if (!result.subject().startsWith("ui:")) {
return ResponseEntity.status(401).body(Map.of("message", "Not a UI token"));
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not a UI token");
}
// Preserve roles from the refresh token
@@ -91,13 +105,12 @@ public class UiAuthController {
String accessToken = jwtService.createAccessToken(result.subject(), "ui", roles);
String refreshToken = jwtService.createRefreshToken(result.subject(), "ui", roles);
return ResponseEntity.ok(Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken
));
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken));
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
log.debug("UI token refresh failed: {}", e.getMessage());
return ResponseEntity.status(401).body(Map.of("message", "Invalid refresh token"));
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid refresh token");
}
}

View File

@@ -139,7 +139,7 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
for (JsonNode agent : body) {
assertThat(agent.get("state").asText()).isEqualTo("LIVE");
assertThat(agent.get("status").asText()).isEqualTo("LIVE");
}
}

73
ui/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -8,7 +8,8 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"generate-api": "openapi-typescript http://localhost:8081/api/v1/api-docs -o src/api/schema.d.ts"
"generate-api": "openapi-typescript src/api/openapi.json -o src/api/schema.d.ts",
"generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",

View File

@@ -1,10 +1,15 @@
{
"openapi": "3.1.0",
"info": {
"title": "OpenAPI definition",
"version": "v0"
"title": "Cameleer3 Server API",
"version": "1.0"
},
"servers": [],
"security": [
{
"bearer": []
}
],
"tags": [
{
"name": "Agent SSE",
@@ -26,6 +31,10 @@
"name": "Ingestion",
"description": "Data ingestion endpoints"
},
{
"name": "Authentication",
"description": "Login and token refresh endpoints"
},
{
"name": "OIDC Config Admin",
"description": "OIDC provider configuration (ADMIN only)"
@@ -94,7 +103,7 @@
"content": {
"*/*": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/OidcAdminConfigResponse"
}
}
}
@@ -111,7 +120,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OidcConfigRequest"
"$ref": "#/components/schemas/OidcAdminConfigRequest"
}
}
},
@@ -123,7 +132,7 @@
"content": {
"*/*": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/OidcAdminConfigResponse"
}
}
}
@@ -133,7 +142,7 @@
"content": {
"*/*": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -378,8 +387,9 @@
"/auth/refresh": {
"post": {
"tags": [
"ui-auth-controller"
"Authentication"
],
"summary": "Refresh access token",
"operationId": "refresh",
"requestBody": {
"content": {
@@ -393,11 +403,21 @@
},
"responses": {
"200": {
"description": "OK",
"description": "Token refreshed",
"content": {
"*/*": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/AuthTokenResponse"
}
}
}
},
"401": {
"description": "Invalid refresh token",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -408,8 +428,9 @@
"/auth/oidc/callback": {
"post": {
"tags": [
"oidc-auth-controller"
"Authentication"
],
"summary": "Exchange OIDC authorization code for JWTs",
"operationId": "callback",
"requestBody": {
"content": {
@@ -423,11 +444,41 @@
},
"responses": {
"200": {
"description": "OK",
"description": "Authentication successful",
"content": {
"*/*": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/AuthTokenResponse"
}
}
}
},
"401": {
"description": "OIDC authentication failed",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Account not provisioned",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "OIDC not configured or disabled",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/AuthTokenResponse"
}
}
}
@@ -438,8 +489,9 @@
"/auth/login": {
"post": {
"tags": [
"ui-auth-controller"
"Authentication"
],
"summary": "Login with local credentials",
"operationId": "login",
"requestBody": {
"content": {
@@ -453,11 +505,21 @@
},
"responses": {
"200": {
"description": "OK",
"description": "Login successful",
"content": {
"*/*": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/AuthTokenResponse"
}
}
}
},
"401": {
"description": "Invalid credentials",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -487,7 +549,7 @@
"content": {
"application/json": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/AgentRefreshRequest"
}
}
},
@@ -499,7 +561,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/AgentRefreshResponse"
}
}
}
@@ -509,7 +571,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/AgentRefreshResponse"
}
}
}
@@ -519,7 +581,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/AgentRefreshResponse"
}
}
}
@@ -577,7 +639,7 @@
"content": {
"application/json": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/CommandRequest"
}
}
},
@@ -589,7 +651,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/CommandSingleResponse"
}
}
}
@@ -599,7 +661,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/CommandSingleResponse"
}
}
}
@@ -609,7 +671,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/CommandSingleResponse"
}
}
}
@@ -665,7 +727,7 @@
"content": {
"application/json": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/AgentRegistrationRequest"
}
}
},
@@ -677,7 +739,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/AgentRegistrationResponse"
}
}
}
@@ -687,7 +749,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -697,7 +759,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/AgentRegistrationResponse"
}
}
}
@@ -727,7 +789,7 @@
"content": {
"application/json": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/CommandRequest"
}
}
},
@@ -739,7 +801,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/CommandBroadcastResponse"
}
}
}
@@ -749,7 +811,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/CommandBroadcastResponse"
}
}
}
@@ -769,7 +831,7 @@
"content": {
"application/json": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/CommandRequest"
}
}
},
@@ -781,7 +843,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/CommandBroadcastResponse"
}
}
}
@@ -791,7 +853,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/CommandBroadcastResponse"
}
}
}
@@ -812,7 +874,7 @@
"content": {
"*/*": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/OidcTestResult"
}
}
}
@@ -822,7 +884,7 @@
"content": {
"*/*": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -941,7 +1003,17 @@
],
"responses": {
"200": {
"description": "OK",
"description": "Execution detail found",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ExecutionDetail"
}
}
}
},
"404": {
"description": "Execution not found",
"content": {
"*/*": {
"schema": {
@@ -981,7 +1053,20 @@
],
"responses": {
"200": {
"description": "OK",
"description": "Snapshot data",
"content": {
"*/*": {
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"404": {
"description": "Snapshot not found",
"content": {
"*/*": {
"schema": {
@@ -1018,9 +1103,14 @@
"200": {
"description": "Diagram rendered successfully",
"content": {
"*/*": {
"image/svg+xml": {
"schema": {
"type": "object"
"type": "string"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/DiagramLayout"
}
}
}
@@ -1041,16 +1131,37 @@
"/auth/oidc/config": {
"get": {
"tags": [
"oidc-auth-controller"
"Authentication"
],
"summary": "Get OIDC config for SPA login flow",
"operationId": "getConfig_1",
"responses": {
"200": {
"description": "OK",
"description": "OIDC configuration",
"content": {
"*/*": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/OidcPublicConfigResponse"
}
}
}
},
"404": {
"description": "OIDC not configured or disabled",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/OidcPublicConfigResponse"
}
}
}
},
"500": {
"description": "Failed to retrieve OIDC provider metadata",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -1082,7 +1193,10 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"type": "array",
"items": {
"$ref": "#/components/schemas/AgentInstanceResponse"
}
}
}
}
@@ -1092,7 +1206,7 @@
"content": {
"*/*": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -1252,8 +1366,9 @@
}
}
},
"OidcConfigRequest": {
"OidcAdminConfigRequest": {
"type": "object",
"description": "OIDC configuration update request",
"properties": {
"enabled": {
"type": "boolean"
@@ -1281,6 +1396,51 @@
}
}
},
"ErrorResponse": {
"type": "object",
"description": "Error response",
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
]
},
"OidcAdminConfigResponse": {
"type": "object",
"description": "OIDC configuration for admin management",
"properties": {
"configured": {
"type": "boolean"
},
"enabled": {
"type": "boolean"
},
"issuerUri": {
"type": "string"
},
"clientId": {
"type": "string"
},
"clientSecretSet": {
"type": "boolean"
},
"rolesClaim": {
"type": "string"
},
"defaultRoles": {
"type": "array",
"items": {
"type": "string"
}
},
"autoSignup": {
"type": "boolean"
}
}
},
"SearchRequest": {
"type": "object",
"properties": {
@@ -1373,7 +1533,19 @@
"diagramContentHash": {
"type": "string"
}
}
},
"required": [
"agentId",
"correlationId",
"diagramContentHash",
"durationMs",
"endTime",
"errorMessage",
"executionId",
"routeId",
"startTime",
"status"
]
},
"SearchResultExecutionSummary": {
"type": "object",
@@ -1396,7 +1568,13 @@
"type": "integer",
"format": "int32"
}
}
},
"required": [
"data",
"limit",
"offset",
"total"
]
},
"RefreshRequest": {
"type": "object",
@@ -1406,6 +1584,22 @@
}
}
},
"AuthTokenResponse": {
"type": "object",
"description": "JWT token pair",
"properties": {
"accessToken": {
"type": "string"
},
"refreshToken": {
"type": "string"
}
},
"required": [
"accessToken",
"refreshToken"
]
},
"CallbackRequest": {
"type": "object",
"properties": {
@@ -1428,6 +1622,165 @@
}
}
},
"AgentRefreshRequest": {
"type": "object",
"description": "Agent token refresh request",
"properties": {
"refreshToken": {
"type": "string"
}
},
"required": [
"refreshToken"
]
},
"AgentRefreshResponse": {
"type": "object",
"description": "Refreshed access token",
"properties": {
"accessToken": {
"type": "string"
}
},
"required": [
"accessToken"
]
},
"CommandRequest": {
"type": "object",
"description": "Command to send to agent(s)",
"properties": {
"type": {
"type": "string",
"description": "Command type: config-update, deep-trace, or replay"
},
"payload": {
"type": "object",
"description": "Command payload JSON"
}
},
"required": [
"type"
]
},
"CommandSingleResponse": {
"type": "object",
"description": "Result of sending a command to a single agent",
"properties": {
"commandId": {
"type": "string"
},
"status": {
"type": "string"
}
},
"required": [
"commandId",
"status"
]
},
"AgentRegistrationRequest": {
"type": "object",
"description": "Agent registration payload",
"properties": {
"agentId": {
"type": "string"
},
"name": {
"type": "string"
},
"group": {
"type": "string",
"default": "default"
},
"version": {
"type": "string"
},
"routeIds": {
"type": "array",
"items": {
"type": "string"
}
},
"capabilities": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
},
"required": [
"agentId",
"name"
]
},
"AgentRegistrationResponse": {
"type": "object",
"description": "Agent registration result with JWT tokens and SSE endpoint",
"properties": {
"agentId": {
"type": "string"
},
"sseEndpoint": {
"type": "string"
},
"heartbeatIntervalMs": {
"type": "integer",
"format": "int64"
},
"serverPublicKey": {
"type": "string"
},
"accessToken": {
"type": "string"
},
"refreshToken": {
"type": "string"
}
},
"required": [
"accessToken",
"agentId",
"refreshToken",
"serverPublicKey",
"sseEndpoint"
]
},
"CommandBroadcastResponse": {
"type": "object",
"description": "Result of broadcasting a command to multiple agents",
"properties": {
"commandIds": {
"type": "array",
"items": {
"type": "string"
}
},
"targetCount": {
"type": "integer",
"format": "int32"
}
},
"required": [
"commandIds"
]
},
"OidcTestResult": {
"type": "object",
"description": "OIDC provider connectivity test result",
"properties": {
"status": {
"type": "string"
},
"authorizationEndpoint": {
"type": "string"
}
},
"required": [
"authorizationEndpoint",
"status"
]
},
"ExecutionStats": {
"type": "object",
"properties": {
@@ -1471,7 +1824,19 @@
"type": "integer",
"format": "int64"
}
}
},
"required": [
"activeCount",
"avgDurationMs",
"failedCount",
"p99LatencyMs",
"prevAvgDurationMs",
"prevFailedCount",
"prevP99LatencyMs",
"prevTotalCount",
"totalCount",
"totalToday"
]
},
"StatsTimeseries": {
"type": "object",
@@ -1482,7 +1847,10 @@
"$ref": "#/components/schemas/TimeseriesBucket"
}
}
}
},
"required": [
"buckets"
]
},
"TimeseriesBucket": {
"type": "object",
@@ -1511,7 +1879,15 @@
"type": "integer",
"format": "int64"
}
}
},
"required": [
"activeCount",
"avgDurationMs",
"failedCount",
"p99DurationMs",
"time",
"totalCount"
]
},
"ExecutionDetail": {
"type": "object",
@@ -1561,7 +1937,22 @@
"$ref": "#/components/schemas/ProcessorNode"
}
}
}
},
"required": [
"agentId",
"correlationId",
"diagramContentHash",
"durationMs",
"endTime",
"errorMessage",
"errorStackTrace",
"exchangeId",
"executionId",
"processors",
"routeId",
"startTime",
"status"
]
},
"ProcessorNode": {
"type": "object",
@@ -1595,9 +1986,171 @@
},
"errorStackTrace": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ProcessorNode"
}
}
},
"required": [
"children",
"diagramNodeId",
"durationMs",
"endTime",
"errorMessage",
"errorStackTrace",
"processorId",
"processorType",
"startTime",
"status"
]
},
"DiagramLayout": {
"type": "object",
"properties": {
"width": {
"type": "number",
"format": "double"
},
"height": {
"type": "number",
"format": "double"
},
"nodes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PositionedNode"
}
},
"edges": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PositionedEdge"
}
}
}
},
"PositionedEdge": {
"type": "object",
"properties": {
"sourceId": {
"type": "string"
},
"targetId": {
"type": "string"
},
"label": {
"type": "string"
},
"points": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "number",
"format": "double"
}
}
}
}
},
"PositionedNode": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"type": {
"type": "string"
},
"x": {
"type": "number",
"format": "double"
},
"y": {
"type": "number",
"format": "double"
},
"width": {
"type": "number",
"format": "double"
},
"height": {
"type": "number",
"format": "double"
}
}
},
"OidcPublicConfigResponse": {
"type": "object",
"description": "OIDC configuration for SPA login flow",
"properties": {
"issuer": {
"type": "string"
},
"clientId": {
"type": "string"
},
"authorizationEndpoint": {
"type": "string"
},
"endSessionEndpoint": {
"type": "string",
"description": "Present if the provider supports RP-initiated logout"
}
},
"required": [
"authorizationEndpoint",
"clientId",
"issuer"
]
},
"AgentInstanceResponse": {
"type": "object",
"description": "Agent instance summary",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"group": {
"type": "string"
},
"status": {
"type": "string"
},
"routeIds": {
"type": "array",
"items": {
"type": "string"
}
},
"registeredAt": {
"type": "string",
"format": "date-time"
},
"lastHeartbeat": {
"type": "string",
"format": "date-time"
}
},
"required": [
"group",
"id",
"lastHeartbeat",
"name",
"registeredAt",
"routeIds",
"status"
]
},
"SseEmitter": {
"type": "object",
"properties": {
@@ -1632,7 +2185,22 @@
"type": "string",
"format": "date-time"
}
}
},
"required": [
"createdAt",
"displayName",
"email",
"provider",
"roles",
"userId"
]
}
},
"securitySchemes": {
"bearer": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
}

View File

@@ -1,12 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../client';
import type {
SearchRequest,
ExecutionStats,
ExecutionSummary,
StatsTimeseries,
ExecutionDetail,
} from '../schema-types';
import type { SearchRequest } from '../types';
export function useExecutionStats(timeFrom: string | undefined, timeTo: string | undefined) {
return useQuery({
@@ -21,7 +15,7 @@ export function useExecutionStats(timeFrom: string | undefined, timeTo: string |
},
});
if (error) throw new Error('Failed to load stats');
return data as unknown as ExecutionStats;
return data!;
},
enabled: !!timeFrom,
placeholderData: (prev) => prev,
@@ -37,7 +31,7 @@ export function useSearchExecutions(filters: SearchRequest, live = false) {
body: filters,
});
if (error) throw new Error('Search failed');
return data as unknown as { data: ExecutionSummary[]; total: number; offset: number; limit: number };
return data!;
},
placeholderData: (prev) => prev,
refetchInterval: live ? 5_000 : false,
@@ -58,7 +52,7 @@ export function useStatsTimeseries(timeFrom: string | undefined, timeTo: string
},
});
if (error) throw new Error('Failed to load timeseries');
return data as unknown as StatsTimeseries;
return data!;
},
enabled: !!timeFrom,
placeholderData: (prev) => prev,
@@ -74,7 +68,7 @@ export function useExecutionDetail(executionId: string | null) {
params: { path: { executionId: executionId! } },
});
if (error) throw new Error('Failed to load execution detail');
return data as unknown as ExecutionDetail;
return data!;
},
enabled: !!executionId,
});
@@ -96,7 +90,7 @@ export function useProcessorSnapshot(
},
);
if (error) throw new Error('Failed to load snapshot');
return data as unknown as Record<string, string>;
return data!;
},
enabled: !!executionId && index !== null,
});

View File

@@ -1,80 +1,47 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
export interface OidcConfigResponse {
configured: boolean;
enabled?: boolean;
issuerUri?: string;
clientId?: string;
clientSecretSet?: boolean;
rolesClaim?: string;
defaultRoles?: string[];
autoSignup?: boolean;
}
export interface OidcConfigRequest {
enabled: boolean;
issuerUri: string;
clientId: string;
clientSecret: string;
rolesClaim: string;
defaultRoles: string[];
autoSignup: boolean;
}
interface TestResult {
status: string;
authorizationEndpoint: string;
}
async function adminFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = useAuthStore.getState().accessToken;
const res = await fetch(`${config.apiBaseUrl}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (res.status === 204) return undefined as T;
const body = await res.json();
if (!res.ok) throw new Error(body.message || `Request failed (${res.status})`);
return body as T;
}
import { api } from '../client';
import type { OidcAdminConfigRequest } from '../types';
export function useOidcConfig() {
return useQuery<OidcConfigResponse>({
return useQuery({
queryKey: ['admin', 'oidc'],
queryFn: () => adminFetch('/admin/oidc'),
queryFn: async () => {
const { data, error } = await api.GET('/admin/oidc');
if (error) throw new Error('Failed to load OIDC config');
return data!;
},
});
}
export function useSaveOidcConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: OidcConfigRequest) =>
adminFetch<OidcConfigResponse>('/admin/oidc', {
method: 'PUT',
body: JSON.stringify(data),
}),
mutationFn: async (body: OidcAdminConfigRequest) => {
const { data, error } = await api.PUT('/admin/oidc', { body });
if (error) throw new Error('Failed to save OIDC config');
return data!;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }),
});
}
export function useTestOidcConnection() {
return useMutation({
mutationFn: () =>
adminFetch<TestResult>('/admin/oidc/test', { method: 'POST' }),
mutationFn: async () => {
const { data, error } = await api.POST('/admin/oidc/test');
if (error) throw new Error('OIDC test failed');
return data!;
},
});
}
export function useDeleteOidcConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
adminFetch<void>('/admin/oidc', { method: 'DELETE' }),
mutationFn: async () => {
const { error } = await api.DELETE('/admin/oidc');
if (error) throw new Error('Failed to delete OIDC config');
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }),
});
}

View File

@@ -1,31 +0,0 @@
import type { components } from './schema';
type Require<T> = {
[K in keyof T]-?: T[K] extends (infer U)[]
? Require<U>[]
: T[K] extends object | undefined
? Require<NonNullable<T[K]>>
: NonNullable<T[K]>;
};
export type ExecutionSummary = Require<components['schemas']['ExecutionSummary']>;
export type SearchRequest = components['schemas']['SearchRequest'];
export type ExecutionDetail = Require<components['schemas']['ExecutionDetail']>;
export type ExecutionStats = Require<components['schemas']['ExecutionStats']>;
export type StatsTimeseries = Require<components['schemas']['StatsTimeseries']>;
export type TimeseriesBucket = Require<components['schemas']['TimeseriesBucket']>;
export type UserInfo = Require<components['schemas']['UserInfo']>;
export type ProcessorNode = Require<components['schemas']['ProcessorNode']> & {
children?: ProcessorNode[];
};
export interface AgentInstance {
id: string;
applicationName: string;
group: string;
status: string;
routeIds: string[];
registeredAt: string;
lastHeartbeat: string;
}

404
ui/src/api/schema.d.ts vendored
View File

@@ -127,6 +127,7 @@ export interface paths {
};
get?: never;
put?: never;
/** Refresh access token */
post: operations["refresh"];
delete?: never;
options?: never;
@@ -143,6 +144,7 @@ export interface paths {
};
get?: never;
put?: never;
/** Exchange OIDC authorization code for JWTs */
post: operations["callback"];
delete?: never;
options?: never;
@@ -159,6 +161,7 @@ export interface paths {
};
get?: never;
put?: never;
/** Login with local credentials */
post: operations["login"];
delete?: never;
options?: never;
@@ -418,6 +421,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Get OIDC config for SPA login flow */
get: operations["getConfig_1"];
put?: never;
post?: never;
@@ -509,7 +513,8 @@ export interface components {
RolesRequest: {
roles?: string[];
};
OidcConfigRequest: {
/** @description OIDC configuration update request */
OidcAdminConfigRequest: {
enabled?: boolean;
issuerUri?: string;
clientId?: string;
@@ -518,6 +523,21 @@ export interface components {
defaultRoles?: string[];
autoSignup?: boolean;
};
/** @description Error response */
ErrorResponse: {
message: string;
};
/** @description OIDC configuration for admin management */
OidcAdminConfigResponse: {
configured?: boolean;
enabled?: boolean;
issuerUri?: string;
clientId?: string;
clientSecretSet?: boolean;
rolesClaim?: string;
defaultRoles?: string[];
autoSignup?: boolean;
};
SearchRequest: {
status?: string;
/** Format: date-time */
@@ -542,32 +562,37 @@ export interface components {
limit?: number;
};
ExecutionSummary: {
executionId?: string;
routeId?: string;
agentId?: string;
status?: string;
executionId: string;
routeId: string;
agentId: string;
status: string;
/** Format: date-time */
startTime?: string;
startTime: string;
/** Format: date-time */
endTime?: string;
endTime: string;
/** Format: int64 */
durationMs?: number;
correlationId?: string;
errorMessage?: string;
diagramContentHash?: string;
durationMs: number;
correlationId: string;
errorMessage: string;
diagramContentHash: string;
};
SearchResultExecutionSummary: {
data?: components["schemas"]["ExecutionSummary"][];
data: components["schemas"]["ExecutionSummary"][];
/** Format: int64 */
total?: number;
total: number;
/** Format: int32 */
offset?: number;
offset: number;
/** Format: int32 */
limit?: number;
limit: number;
};
RefreshRequest: {
refreshToken?: string;
};
/** @description JWT token pair */
AuthTokenResponse: {
accessToken: string;
refreshToken: string;
};
CallbackRequest: {
code?: string;
redirectUri?: string;
@@ -576,89 +601,190 @@ export interface components {
username?: string;
password?: string;
};
/** @description Agent token refresh request */
AgentRefreshRequest: {
refreshToken: string;
};
/** @description Refreshed access token */
AgentRefreshResponse: {
accessToken: string;
};
/** @description Command to send to agent(s) */
CommandRequest: {
/** @description Command type: config-update, deep-trace, or replay */
type: string;
/** @description Command payload JSON */
payload?: Record<string, never>;
};
/** @description Result of sending a command to a single agent */
CommandSingleResponse: {
commandId: string;
status: string;
};
/** @description Agent registration payload */
AgentRegistrationRequest: {
agentId: string;
name: string;
/** @default default */
group: string;
version?: string;
routeIds?: string[];
capabilities?: {
[key: string]: Record<string, never>;
};
};
/** @description Agent registration result with JWT tokens and SSE endpoint */
AgentRegistrationResponse: {
agentId: string;
sseEndpoint: string;
/** Format: int64 */
heartbeatIntervalMs?: number;
serverPublicKey: string;
accessToken: string;
refreshToken: string;
};
/** @description Result of broadcasting a command to multiple agents */
CommandBroadcastResponse: {
commandIds: string[];
/** Format: int32 */
targetCount?: number;
};
/** @description OIDC provider connectivity test result */
OidcTestResult: {
status: string;
authorizationEndpoint: string;
};
ExecutionStats: {
/** Format: int64 */
totalCount?: number;
totalCount: number;
/** Format: int64 */
failedCount?: number;
failedCount: number;
/** Format: int64 */
avgDurationMs?: number;
avgDurationMs: number;
/** Format: int64 */
p99LatencyMs?: number;
p99LatencyMs: number;
/** Format: int64 */
activeCount?: number;
activeCount: number;
/** Format: int64 */
totalToday?: number;
totalToday: number;
/** Format: int64 */
prevTotalCount?: number;
prevTotalCount: number;
/** Format: int64 */
prevFailedCount?: number;
prevFailedCount: number;
/** Format: int64 */
prevAvgDurationMs?: number;
prevAvgDurationMs: number;
/** Format: int64 */
prevP99LatencyMs?: number;
prevP99LatencyMs: number;
};
StatsTimeseries: {
buckets?: components["schemas"]["TimeseriesBucket"][];
buckets: components["schemas"]["TimeseriesBucket"][];
};
TimeseriesBucket: {
/** Format: date-time */
time?: string;
time: string;
/** Format: int64 */
totalCount?: number;
totalCount: number;
/** Format: int64 */
failedCount?: number;
failedCount: number;
/** Format: int64 */
avgDurationMs?: number;
avgDurationMs: number;
/** Format: int64 */
p99DurationMs?: number;
p99DurationMs: number;
/** Format: int64 */
activeCount?: number;
activeCount: number;
};
ExecutionDetail: {
executionId?: string;
routeId?: string;
agentId?: string;
status?: string;
executionId: string;
routeId: string;
agentId: string;
status: string;
/** Format: date-time */
startTime?: string;
startTime: string;
/** Format: date-time */
endTime?: string;
endTime: string;
/** Format: int64 */
durationMs?: number;
correlationId?: string;
exchangeId?: string;
errorMessage?: string;
errorStackTrace?: string;
diagramContentHash?: string;
processors?: components["schemas"]["ProcessorNode"][];
durationMs: number;
correlationId: string;
exchangeId: string;
errorMessage: string;
errorStackTrace: string;
diagramContentHash: string;
processors: components["schemas"]["ProcessorNode"][];
};
ProcessorNode: {
processorId?: string;
processorType?: string;
status?: string;
processorId: string;
processorType: string;
status: string;
/** Format: date-time */
startTime?: string;
startTime: string;
/** Format: date-time */
endTime?: string;
endTime: string;
/** Format: int64 */
durationMs?: number;
diagramNodeId?: string;
errorMessage?: string;
errorStackTrace?: string;
durationMs: number;
diagramNodeId: string;
errorMessage: string;
errorStackTrace: string;
children: components["schemas"]["ProcessorNode"][];
};
DiagramLayout: {
/** Format: double */
width?: number;
/** Format: double */
height?: number;
nodes?: components["schemas"]["PositionedNode"][];
edges?: components["schemas"]["PositionedEdge"][];
};
PositionedEdge: {
sourceId?: string;
targetId?: string;
label?: string;
points?: number[][];
};
PositionedNode: {
id?: string;
label?: string;
type?: string;
/** Format: double */
x?: number;
/** Format: double */
y?: number;
/** Format: double */
width?: number;
/** Format: double */
height?: number;
};
/** @description OIDC configuration for SPA login flow */
OidcPublicConfigResponse: {
issuer: string;
clientId: string;
authorizationEndpoint: string;
/** @description Present if the provider supports RP-initiated logout */
endSessionEndpoint?: string;
};
/** @description Agent instance summary */
AgentInstanceResponse: {
id: string;
name: string;
group: string;
status: string;
routeIds: string[];
/** Format: date-time */
registeredAt: string;
/** Format: date-time */
lastHeartbeat: string;
};
SseEmitter: {
/** Format: int64 */
timeout?: number;
};
UserInfo: {
userId?: string;
provider?: string;
email?: string;
displayName?: string;
roles?: string[];
userId: string;
provider: string;
email: string;
displayName: string;
roles: string[];
/** Format: date-time */
createdAt?: string;
createdAt: string;
};
};
responses: never;
@@ -715,7 +841,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": Record<string, never>;
"*/*": components["schemas"]["OidcAdminConfigResponse"];
};
};
};
@@ -729,7 +855,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["OidcConfigRequest"];
"application/json": components["schemas"]["OidcAdminConfigRequest"];
};
};
responses: {
@@ -739,7 +865,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": Record<string, never>;
"*/*": components["schemas"]["OidcAdminConfigResponse"];
};
};
/** @description Invalid configuration */
@@ -748,7 +874,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": Record<string, never>;
"*/*": components["schemas"]["ErrorResponse"];
};
};
};
@@ -926,13 +1052,22 @@ export interface operations {
};
};
responses: {
/** @description OK */
/** @description Token refreshed */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": Record<string, never>;
"*/*": components["schemas"]["AuthTokenResponse"];
};
};
/** @description Invalid refresh token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ErrorResponse"];
};
};
};
@@ -950,13 +1085,40 @@ export interface operations {
};
};
responses: {
/** @description OK */
/** @description Authentication successful */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": Record<string, never>;
"*/*": components["schemas"]["AuthTokenResponse"];
};
};
/** @description OIDC authentication failed */
401: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ErrorResponse"];
};
};
/** @description Account not provisioned */
403: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ErrorResponse"];
};
};
/** @description OIDC not configured or disabled */
404: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AuthTokenResponse"];
};
};
};
@@ -974,13 +1136,22 @@ export interface operations {
};
};
responses: {
/** @description OK */
/** @description Login successful */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": Record<string, never>;
"*/*": components["schemas"]["AuthTokenResponse"];
};
};
/** @description Invalid credentials */
401: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ErrorResponse"];
};
};
};
@@ -996,7 +1167,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": string;
"application/json": components["schemas"]["AgentRefreshRequest"];
};
};
responses: {
@@ -1006,7 +1177,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["AgentRefreshResponse"];
};
};
/** @description Invalid or expired refresh token */
@@ -1015,7 +1186,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["AgentRefreshResponse"];
};
};
/** @description Agent not found */
@@ -1024,7 +1195,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["AgentRefreshResponse"];
};
};
};
@@ -1067,7 +1238,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": string;
"application/json": components["schemas"]["CommandRequest"];
};
};
responses: {
@@ -1077,7 +1248,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["CommandSingleResponse"];
};
};
/** @description Invalid command payload */
@@ -1086,7 +1257,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["CommandSingleResponse"];
};
};
/** @description Agent not registered */
@@ -1095,7 +1266,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["CommandSingleResponse"];
};
};
};
@@ -1137,7 +1308,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": string;
"application/json": components["schemas"]["AgentRegistrationRequest"];
};
};
responses: {
@@ -1147,7 +1318,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["AgentRegistrationResponse"];
};
};
/** @description Invalid registration payload */
@@ -1156,7 +1327,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["ErrorResponse"];
};
};
/** @description Missing or invalid bootstrap token */
@@ -1165,7 +1336,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["AgentRegistrationResponse"];
};
};
};
@@ -1181,7 +1352,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": string;
"application/json": components["schemas"]["CommandRequest"];
};
};
responses: {
@@ -1191,7 +1362,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["CommandBroadcastResponse"];
};
};
/** @description Invalid command payload */
@@ -1200,7 +1371,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["CommandBroadcastResponse"];
};
};
};
@@ -1214,7 +1385,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": string;
"application/json": components["schemas"]["CommandRequest"];
};
};
responses: {
@@ -1224,7 +1395,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["CommandBroadcastResponse"];
};
};
/** @description Invalid command payload */
@@ -1233,7 +1404,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["CommandBroadcastResponse"];
};
};
};
@@ -1253,7 +1424,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": Record<string, never>;
"*/*": components["schemas"]["OidcTestResult"];
};
};
/** @description Provider unreachable or misconfigured */
@@ -1262,7 +1433,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": Record<string, never>;
"*/*": components["schemas"]["ErrorResponse"];
};
};
};
@@ -1325,7 +1496,7 @@ export interface operations {
};
requestBody?: never;
responses: {
/** @description OK */
/** @description Execution detail found */
200: {
headers: {
[name: string]: unknown;
@@ -1334,6 +1505,15 @@ export interface operations {
"*/*": components["schemas"]["ExecutionDetail"];
};
};
/** @description Execution not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ExecutionDetail"];
};
};
};
};
getProcessorSnapshot: {
@@ -1348,7 +1528,7 @@ export interface operations {
};
requestBody?: never;
responses: {
/** @description OK */
/** @description Snapshot data */
200: {
headers: {
[name: string]: unknown;
@@ -1359,6 +1539,17 @@ export interface operations {
};
};
};
/** @description Snapshot not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": {
[key: string]: string;
};
};
};
};
};
renderDiagram: {
@@ -1378,7 +1569,8 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": Record<string, never>;
"image/svg+xml": string;
"application/json": components["schemas"]["DiagramLayout"];
};
};
/** @description Diagram not found */
@@ -1401,13 +1593,31 @@ export interface operations {
};
requestBody?: never;
responses: {
/** @description OK */
/** @description OIDC configuration */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": Record<string, never>;
"*/*": components["schemas"]["OidcPublicConfigResponse"];
};
};
/** @description OIDC not configured or disabled */
404: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["OidcPublicConfigResponse"];
};
};
/** @description Failed to retrieve OIDC provider metadata */
500: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ErrorResponse"];
};
};
};
@@ -1429,7 +1639,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["AgentInstanceResponse"][];
};
};
/** @description Invalid status filter */
@@ -1438,7 +1648,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": string;
"*/*": components["schemas"]["ErrorResponse"];
};
};
};

17
ui/src/api/types.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { components } from './schema';
export type ExecutionSummary = components['schemas']['ExecutionSummary'];
export type SearchRequest = components['schemas']['SearchRequest'];
export type ExecutionDetail = components['schemas']['ExecutionDetail'];
export type ExecutionStats = components['schemas']['ExecutionStats'];
export type StatsTimeseries = components['schemas']['StatsTimeseries'];
export type TimeseriesBucket = components['schemas']['TimeseriesBucket'];
export type UserInfo = components['schemas']['UserInfo'];
export type ProcessorNode = components['schemas']['ProcessorNode'];
export type AgentInstance = components['schemas']['AgentInstanceResponse'];
export type OidcAdminConfigResponse = components['schemas']['OidcAdminConfigResponse'];
export type OidcAdminConfigRequest = components['schemas']['OidcAdminConfigRequest'];
export type OidcTestResult = components['schemas']['OidcTestResult'];
export type OidcPublicConfigResponse = components['schemas']['OidcPublicConfigResponse'];
export type AuthTokenResponse = components['schemas']['AuthTokenResponse'];
export type ErrorResponse = components['schemas']['ErrorResponse'];

View File

@@ -1,7 +1,7 @@
import { type FormEvent, useEffect, useState } from 'react';
import { Navigate } from 'react-router';
import { useAuthStore } from './auth-store';
import { config } from '../config';
import { api } from '../api/client';
import styles from './LoginPage.module.css';
interface OidcInfo {
@@ -17,9 +17,8 @@ export function LoginPage() {
const [oidcLoading, setOidcLoading] = useState(false);
useEffect(() => {
fetch(`${config.apiBaseUrl}/auth/oidc/config`)
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
api.GET('/auth/oidc/config')
.then(({ data }) => {
if (data?.authorizationEndpoint && data?.clientId) {
setOidc({ clientId: data.clientId, authorizationEndpoint: data.authorizationEndpoint });
if (data.endSessionEndpoint) {

View File

@@ -1,5 +1,5 @@
import { create } from 'zustand';
import { config } from '../config';
import { api } from '../api/client';
interface AuthState {
accessToken: string | null;
@@ -58,16 +58,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
login: async (username, password) => {
set({ loading: true, error: null });
try {
const res = await fetch(`${config.apiBaseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
const { data, error } = await api.POST('/auth/login', {
body: { username, password },
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Invalid credentials');
if (error || !data) {
throw new Error('Invalid credentials');
}
const { accessToken, refreshToken } = await res.json();
const { accessToken, refreshToken } = data;
localStorage.removeItem('cameleer-oidc-end-session');
persistTokens(accessToken, refreshToken, username);
set({
@@ -89,16 +86,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
loginWithOidcCode: async (code, redirectUri) => {
set({ loading: true, error: null });
try {
const res = await fetch(`${config.apiBaseUrl}/auth/oidc/callback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, redirectUri }),
const { data, error } = await api.POST('/auth/oidc/callback', {
body: { code, redirectUri },
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'OIDC login failed');
if (error || !data) {
throw new Error('OIDC login failed');
}
const { accessToken, refreshToken } = await res.json();
const { accessToken, refreshToken } = data;
const payload = JSON.parse(atob(accessToken.split('.')[1]));
const username = payload.sub ?? 'oidc-user';
persistTokens(accessToken, refreshToken, username);
@@ -122,13 +116,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const { refreshToken } = get();
if (!refreshToken) return false;
try {
const res = await fetch(`${config.apiBaseUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
const { data, error } = await api.POST('/auth/refresh', {
body: { refreshToken },
});
if (!res.ok) return false;
const data = await res.json();
if (error || !data) return false;
const username = get().username ?? '';
persistTokens(data.accessToken, data.refreshToken, username);
set({

View File

@@ -1,4 +1,4 @@
import type { ExecutionSummary, AgentInstance } from '../../api/schema-types';
import type { ExecutionSummary, AgentInstance } from '../../api/types';
import type { PaletteResult, RouteInfo } from './use-palette-search';
import { highlightMatch, formatRelativeTime } from './utils';
import { AppBadge } from '../shared/AppBadge';

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../../api/client';
import type { ExecutionSummary, AgentInstance } from '../../api/schema-types';
import type { ExecutionSummary, AgentInstance } from '../../api/types';
import { useCommandPalette, type PaletteScope } from './use-command-palette';
import { useDebouncedValue } from './utils';
@@ -51,7 +51,7 @@ export function usePaletteSearch() {
},
});
if (error) throw new Error('Search failed');
return data as unknown as { data: ExecutionSummary[]; total: number };
return data!;
},
enabled: isOpen && isExecutionScope(scope),
placeholderData: (prev) => prev,
@@ -64,7 +64,7 @@ export function usePaletteSearch() {
params: { query: {} },
});
if (error) throw new Error('Failed to load agents');
return data as unknown as AgentInstance[];
return data!;
},
enabled: isOpen && (isApplicationScope(scope) || isRouteScope(scope)),
staleTime: 30_000,

View File

@@ -5,8 +5,8 @@ import {
useSaveOidcConfig,
useTestOidcConnection,
useDeleteOidcConfig,
type OidcConfigRequest,
} from '../../api/queries/oidc-admin';
import type { OidcAdminConfigRequest } from '../../api/types';
import styles from './OidcAdminPage.module.css';
interface FormData {
@@ -87,7 +87,7 @@ function OidcAdminForm() {
}
async function handleSave() {
const payload: OidcConfigRequest = {
const payload: OidcAdminConfigRequest = {
...form,
clientSecret: secretTouched ? form.clientSecret : '********',
};

View File

@@ -1,5 +1,5 @@
import { useProcessorSnapshot } from '../../api/queries/executions';
import type { ExecutionSummary } from '../../api/schema-types';
import type { ExecutionSummary } from '../../api/types';
import styles from './ExchangeDetail.module.css';
interface ExchangeDetailProps {

View File

@@ -1,5 +1,5 @@
import { useExecutionDetail } from '../../api/queries/executions';
import type { ProcessorNode as ProcessorNodeType } from '../../api/schema-types';
import type { ProcessorNode as ProcessorNodeType } from '../../api/types';
import styles from './ProcessorTree.module.css';
const ICON_MAP: Record<string, { label: string; className: string }> = {

View File

@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react';
import type { ExecutionSummary } from '../../api/schema-types';
import type { ExecutionSummary } from '../../api/types';
import { StatusPill } from '../../components/shared/StatusPill';
import { DurationBar } from '../../components/shared/DurationBar';
import { AppBadge } from '../../components/shared/AppBadge';

View File

@@ -7,7 +7,7 @@ import { ScopeTabs } from '../../components/command-palette/ScopeTabs';
import { ResultsList } from '../../components/command-palette/ResultsList';
import { PaletteFooter } from '../../components/command-palette/PaletteFooter';
import { FilterChip } from '../../components/shared/FilterChip';
import type { ExecutionSummary, AgentInstance } from '../../api/schema-types';
import type { ExecutionSummary, AgentInstance } from '../../api/types';
import styles from './SearchFilters.module.css';
export function SearchFilters() {

View File

@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { SearchRequest } from '../../api/schema-types';
import type { SearchRequest } from '../../api/types';
function todayMidnight(): string {
const d = new Date();