Compare commits
54 Commits
f01487ccb4
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dafd7adb00 | ||
|
|
44eecfa5cd | ||
|
|
ff76751629 | ||
|
|
413839452c | ||
|
|
c33e899be7 | ||
|
|
180514a039 | ||
|
|
60fced56ed | ||
|
|
515c942623 | ||
|
|
3ccd4b6548 | ||
|
|
dad608e3a2 | ||
|
|
7479dd6daf | ||
|
|
e4dff0cad1 | ||
|
|
717367252c | ||
|
|
a06808a2a2 | ||
|
|
6b750df1c4 | ||
|
|
ea56bcf2d7 | ||
|
|
826466aa55 | ||
|
|
6a5dba4eba | ||
|
|
8ad0016a8e | ||
|
|
3c226de62f | ||
|
|
c8c62a98bb | ||
|
|
2ae2871822 | ||
|
|
a950feaef1 | ||
|
|
695969d759 | ||
|
|
a72b0954db | ||
|
|
4572230c9c | ||
|
|
752d7ec0e7 | ||
|
|
9ab38dfc59 | ||
|
|
907bcd5017 | ||
|
|
83caf4be5b | ||
|
|
1533bea2a6 | ||
|
|
94d1e81852 | ||
|
|
8e27f45a2b | ||
|
|
a86f56f588 | ||
|
|
651cf9de6e | ||
|
|
63d8078688 | ||
|
|
ee69dbedfc | ||
|
|
313d871948 | ||
|
|
f4d2693561 | ||
|
|
2051572ee2 | ||
|
|
cc433b4215 | ||
|
|
31b60c4e24 | ||
|
|
017a0c218e | ||
|
|
4ff01681d4 | ||
|
|
f2744e3094 | ||
|
|
ea5b5a685d | ||
|
|
045d9ea890 | ||
|
|
9613bddc60 | ||
|
|
2b111c603c | ||
|
|
82124c3145 | ||
|
|
17ef48e392 | ||
| 4085f42160 | |||
|
|
0fcbe83cc2 | ||
|
|
5a0a915cc6 |
@@ -120,6 +120,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
docker buildx build --platform linux/amd64 \
|
docker buildx build --platform linux/amd64 \
|
||||||
-f ui/Dockerfile \
|
-f ui/Dockerfile \
|
||||||
|
--build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
|
||||||
$TAGS \
|
$TAGS \
|
||||||
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache \
|
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache \
|
||||||
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache,mode=max \
|
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache,mode=max \
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
|||||||
- Maintains agent instance registry with states: LIVE → STALE → DEAD
|
- Maintains agent instance registry with states: LIVE → STALE → DEAD
|
||||||
- Storage: PostgreSQL (TimescaleDB) for structured data, OpenSearch for full-text search
|
- Storage: PostgreSQL (TimescaleDB) for structured data, OpenSearch for full-text search
|
||||||
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing, bootstrap token for registration
|
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing, bootstrap token for registration
|
||||||
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`oidc_config` table)
|
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`server_config` table)
|
||||||
- User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users`
|
- User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users`
|
||||||
|
|
||||||
## CI/CD & Deployment
|
## CI/CD & Deployment
|
||||||
@@ -56,3 +56,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
|||||||
- Secrets managed in CI deploy step (idempotent `--dry-run=client | kubectl apply`): `cameleer-auth`, `postgres-credentials`, `opensearch-credentials`
|
- Secrets managed in CI deploy step (idempotent `--dry-run=client | kubectl apply`): `cameleer-auth`, `postgres-credentials`, `opensearch-credentials`
|
||||||
- K8s probes: server uses `/api/v1/health`, PostgreSQL uses `pg_isready`, OpenSearch uses `/_cluster/health`
|
- K8s probes: server uses `/api/v1/health`, PostgreSQL uses `pg_isready`, OpenSearch uses `/_cluster/health`
|
||||||
- Docker build uses buildx registry cache + `--provenance=false` for Gitea compatibility
|
- Docker build uses buildx registry cache + `--provenance=false` for Gitea compatibility
|
||||||
|
|
||||||
|
## Disabled Skills
|
||||||
|
|
||||||
|
- Do NOT use any `gsd:*` skills in this project. This includes all `/gsd:` prefixed commands.
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
package com.cameleer3.server.app.agent;
|
package com.cameleer3.server.app.agent;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Periodic task that checks agent lifecycle and expires old commands.
|
* Periodic task that checks agent lifecycle and expires old commands.
|
||||||
* <p>
|
* <p>
|
||||||
* Runs on a configurable fixed delay (default 10 seconds). Transitions
|
* Runs on a configurable fixed delay (default 10 seconds). Transitions
|
||||||
* agents LIVE -> STALE -> DEAD based on heartbeat timing, and removes
|
* agents LIVE -> STALE -> DEAD based on heartbeat timing, and removes
|
||||||
* expired pending commands.
|
* expired pending commands. Records lifecycle events for state transitions.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class AgentLifecycleMonitor {
|
public class AgentLifecycleMonitor {
|
||||||
@@ -19,18 +25,46 @@ public class AgentLifecycleMonitor {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class);
|
private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class);
|
||||||
|
|
||||||
private final AgentRegistryService registryService;
|
private final AgentRegistryService registryService;
|
||||||
|
private final AgentEventService agentEventService;
|
||||||
|
|
||||||
public AgentLifecycleMonitor(AgentRegistryService registryService) {
|
public AgentLifecycleMonitor(AgentRegistryService registryService,
|
||||||
|
AgentEventService agentEventService) {
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
|
this.agentEventService = agentEventService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}")
|
@Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}")
|
||||||
public void checkLifecycle() {
|
public void checkLifecycle() {
|
||||||
try {
|
try {
|
||||||
|
// Snapshot states before lifecycle check
|
||||||
|
Map<String, AgentState> statesBefore = new HashMap<>();
|
||||||
|
for (AgentInfo agent : registryService.findAll()) {
|
||||||
|
statesBefore.put(agent.id(), agent.state());
|
||||||
|
}
|
||||||
|
|
||||||
registryService.checkLifecycle();
|
registryService.checkLifecycle();
|
||||||
registryService.expireOldCommands();
|
registryService.expireOldCommands();
|
||||||
|
|
||||||
|
// Detect transitions and record events
|
||||||
|
for (AgentInfo agent : registryService.findAll()) {
|
||||||
|
AgentState before = statesBefore.get(agent.id());
|
||||||
|
if (before != null && before != agent.state()) {
|
||||||
|
String eventType = mapTransitionEvent(before, agent.state());
|
||||||
|
if (eventType != null) {
|
||||||
|
agentEventService.recordEvent(agent.id(), agent.application(), eventType,
|
||||||
|
agent.name() + " " + before + " -> " + agent.state());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error during agent lifecycle check", e);
|
log.error("Error during agent lifecycle check", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String mapTransitionEvent(AgentState from, AgentState to) {
|
||||||
|
if (from == AgentState.LIVE && to == AgentState.STALE) return "WENT_STALE";
|
||||||
|
if (from == AgentState.STALE && to == AgentState.DEAD) return "WENT_DEAD";
|
||||||
|
if (from == AgentState.STALE && to == AgentState.LIVE) return "RECOVERED";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package com.cameleer3.server.app.config;
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRepository;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the {@link AgentRegistryService} bean.
|
* Creates the {@link AgentRegistryService} and {@link AgentEventService} beans.
|
||||||
* <p>
|
* <p>
|
||||||
* Follows the established pattern: core module plain class, app module bean config.
|
* Follows the established pattern: core module plain class, app module bean config.
|
||||||
*/
|
*/
|
||||||
@@ -20,4 +22,9 @@ public class AgentRegistryBeanConfig {
|
|||||||
config.getCommandExpiryMs()
|
config.getCommandExpiryMs()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AgentEventService agentEventService(AgentEventRepository repository) {
|
||||||
|
return new AgentEventService(repository);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ public class OpenApiConfig {
|
|||||||
"ExecutionSummary", "ExecutionDetail", "ExecutionStats",
|
"ExecutionSummary", "ExecutionDetail", "ExecutionStats",
|
||||||
"StatsTimeseries", "TimeseriesBucket",
|
"StatsTimeseries", "TimeseriesBucket",
|
||||||
"SearchResultExecutionSummary", "UserInfo",
|
"SearchResultExecutionSummary", "UserInfo",
|
||||||
"ProcessorNode"
|
"ProcessorNode",
|
||||||
|
"AppCatalogEntry", "RouteSummary", "AgentSummary",
|
||||||
|
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse",
|
||||||
|
"ProcessorMetrics", "AgentMetricsResponse", "MetricBucket"
|
||||||
);
|
);
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ public class AgentCommandController {
|
|||||||
|
|
||||||
List<AgentInfo> agents = registryService.findAll().stream()
|
List<AgentInfo> agents = registryService.findAll().stream()
|
||||||
.filter(a -> a.state() == AgentState.LIVE)
|
.filter(a -> a.state() == AgentState.LIVE)
|
||||||
.filter(a -> group.equals(a.group()))
|
.filter(a -> group.equals(a.application()))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
List<String> commandIds = new ArrayList<>();
|
List<String> commandIds = new ArrayList<>();
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.AgentEventResponse;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/agents/events-log")
|
||||||
|
@Tag(name = "Agent Events", description = "Agent lifecycle event log")
|
||||||
|
public class AgentEventsController {
|
||||||
|
|
||||||
|
private final AgentEventService agentEventService;
|
||||||
|
|
||||||
|
public AgentEventsController(AgentEventService agentEventService) {
|
||||||
|
this.agentEventService = agentEventService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Query agent events",
|
||||||
|
description = "Returns agent lifecycle events, optionally filtered by app and/or agent ID")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Events returned")
|
||||||
|
public ResponseEntity<List<AgentEventResponse>> getEvents(
|
||||||
|
@RequestParam(required = false) String appId,
|
||||||
|
@RequestParam(required = false) String agentId,
|
||||||
|
@RequestParam(required = false) String from,
|
||||||
|
@RequestParam(required = false) String to,
|
||||||
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
|
|
||||||
|
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||||
|
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||||
|
|
||||||
|
var events = agentEventService.queryEvents(appId, agentId, fromInstant, toInstant, limit)
|
||||||
|
.stream()
|
||||||
|
.map(AgentEventResponse::from)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(events);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.AgentMetricsResponse;
|
||||||
|
import com.cameleer3.server.app.dto.MetricBucket;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/agents/{agentId}/metrics")
|
||||||
|
public class AgentMetricsController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public AgentMetricsController(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public AgentMetricsResponse getMetrics(
|
||||||
|
@PathVariable String agentId,
|
||||||
|
@RequestParam String names,
|
||||||
|
@RequestParam(required = false) Instant from,
|
||||||
|
@RequestParam(required = false) Instant to,
|
||||||
|
@RequestParam(defaultValue = "60") int buckets) {
|
||||||
|
|
||||||
|
if (from == null) from = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||||
|
if (to == null) to = Instant.now();
|
||||||
|
|
||||||
|
List<String> metricNames = Arrays.asList(names.split(","));
|
||||||
|
long intervalMs = (to.toEpochMilli() - from.toEpochMilli()) / Math.max(buckets, 1);
|
||||||
|
String intervalStr = intervalMs + " milliseconds";
|
||||||
|
|
||||||
|
Map<String, List<MetricBucket>> result = new LinkedHashMap<>();
|
||||||
|
for (String name : metricNames) {
|
||||||
|
result.put(name.trim(), new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT time_bucket(CAST(? AS interval), collected_at) AS bucket,
|
||||||
|
metric_name,
|
||||||
|
AVG(metric_value) AS avg_value
|
||||||
|
FROM agent_metrics
|
||||||
|
WHERE agent_id = ?
|
||||||
|
AND collected_at >= ? AND collected_at < ?
|
||||||
|
AND metric_name = ANY(?)
|
||||||
|
GROUP BY bucket, metric_name
|
||||||
|
ORDER BY bucket
|
||||||
|
""";
|
||||||
|
|
||||||
|
String[] namesArray = metricNames.stream().map(String::trim).toArray(String[]::new);
|
||||||
|
jdbc.query(sql, rs -> {
|
||||||
|
String metricName = rs.getString("metric_name");
|
||||||
|
Instant bucket = rs.getTimestamp("bucket").toInstant();
|
||||||
|
double value = rs.getDouble("avg_value");
|
||||||
|
result.computeIfAbsent(metricName, k -> new ArrayList<>())
|
||||||
|
.add(new MetricBucket(bucket, value));
|
||||||
|
}, intervalStr, agentId, Timestamp.from(from), Timestamp.from(to), namesArray);
|
||||||
|
|
||||||
|
return new AgentMetricsResponse(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,10 @@ import com.cameleer3.server.app.dto.AgentRegistrationRequest;
|
|||||||
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
|
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
|
||||||
import com.cameleer3.server.app.dto.ErrorResponse;
|
import com.cameleer3.server.app.dto.ErrorResponse;
|
||||||
import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
import com.cameleer3.server.core.agent.AgentInfo;
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.agent.AgentState;
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
import com.cameleer3.server.core.rbac.RbacService;
|
|
||||||
import com.cameleer3.server.core.rbac.SystemRole;
|
|
||||||
import com.cameleer3.server.core.security.Ed25519SigningService;
|
import com.cameleer3.server.core.security.Ed25519SigningService;
|
||||||
import com.cameleer3.server.core.security.InvalidTokenException;
|
import com.cameleer3.server.core.security.InvalidTokenException;
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
import com.cameleer3.server.core.security.JwtService;
|
||||||
@@ -25,6 +24,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -33,8 +33,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent registration, heartbeat, listing, and token refresh endpoints.
|
* Agent registration, heartbeat, listing, and token refresh endpoints.
|
||||||
@@ -52,20 +57,23 @@ public class AgentRegistrationController {
|
|||||||
private final BootstrapTokenValidator bootstrapTokenValidator;
|
private final BootstrapTokenValidator bootstrapTokenValidator;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final Ed25519SigningService ed25519SigningService;
|
private final Ed25519SigningService ed25519SigningService;
|
||||||
private final RbacService rbacService;
|
private final AgentEventService agentEventService;
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
public AgentRegistrationController(AgentRegistryService registryService,
|
public AgentRegistrationController(AgentRegistryService registryService,
|
||||||
AgentRegistryConfig config,
|
AgentRegistryConfig config,
|
||||||
BootstrapTokenValidator bootstrapTokenValidator,
|
BootstrapTokenValidator bootstrapTokenValidator,
|
||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
Ed25519SigningService ed25519SigningService,
|
Ed25519SigningService ed25519SigningService,
|
||||||
RbacService rbacService) {
|
AgentEventService agentEventService,
|
||||||
|
JdbcTemplate jdbc) {
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.bootstrapTokenValidator = bootstrapTokenValidator;
|
this.bootstrapTokenValidator = bootstrapTokenValidator;
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.ed25519SigningService = ed25519SigningService;
|
this.ed25519SigningService = ed25519SigningService;
|
||||||
this.rbacService = rbacService;
|
this.agentEventService = agentEventService;
|
||||||
|
this.jdbc = jdbc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
@@ -94,21 +102,21 @@ public class AgentRegistrationController {
|
|||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
String group = request.group() != null ? request.group() : "default";
|
String application = request.application() != null ? request.application() : "default";
|
||||||
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
|
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
|
||||||
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
|
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
|
||||||
|
|
||||||
AgentInfo agent = registryService.register(
|
AgentInfo agent = registryService.register(
|
||||||
request.agentId(), request.name(), group, request.version(), routeIds, capabilities);
|
request.agentId(), request.name(), application, request.version(), routeIds, capabilities);
|
||||||
log.info("Agent registered: {} (name={}, group={})", request.agentId(), request.name(), group);
|
log.info("Agent registered: {} (name={}, application={})", request.agentId(), request.name(), application);
|
||||||
|
|
||||||
// Assign AGENT role via RBAC
|
agentEventService.recordEvent(request.agentId(), application, "REGISTERED",
|
||||||
rbacService.assignRoleToUser(request.agentId(), SystemRole.AGENT_ID);
|
"Agent registered: " + request.name());
|
||||||
|
|
||||||
// Issue JWT tokens with AGENT role
|
// Issue JWT tokens with AGENT role
|
||||||
List<String> roles = List.of("AGENT");
|
List<String> roles = List.of("AGENT");
|
||||||
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles);
|
String accessToken = jwtService.createAccessToken(request.agentId(), application, roles);
|
||||||
String refreshToken = jwtService.createRefreshToken(request.agentId(), group, roles);
|
String refreshToken = jwtService.createRefreshToken(request.agentId(), application, roles);
|
||||||
|
|
||||||
return ResponseEntity.ok(new AgentRegistrationResponse(
|
return ResponseEntity.ok(new AgentRegistrationResponse(
|
||||||
agent.id(),
|
agent.id(),
|
||||||
@@ -158,9 +166,10 @@ public class AgentRegistrationController {
|
|||||||
// Preserve roles from refresh token
|
// Preserve roles from refresh token
|
||||||
List<String> roles = result.roles().isEmpty()
|
List<String> roles = result.roles().isEmpty()
|
||||||
? List.of("AGENT") : result.roles();
|
? List.of("AGENT") : result.roles();
|
||||||
String newAccessToken = jwtService.createAccessToken(agentId, agent.group(), roles);
|
String newAccessToken = jwtService.createAccessToken(agentId, agent.application(), roles);
|
||||||
|
String newRefreshToken = jwtService.createRefreshToken(agentId, agent.application(), roles);
|
||||||
|
|
||||||
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken));
|
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/heartbeat")
|
@PostMapping("/{id}/heartbeat")
|
||||||
@@ -178,13 +187,13 @@ public class AgentRegistrationController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all agents",
|
@Operation(summary = "List all agents",
|
||||||
description = "Returns all registered agents, optionally filtered by status and/or group")
|
description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or application")
|
||||||
@ApiResponse(responseCode = "200", description = "Agent list returned")
|
@ApiResponse(responseCode = "200", description = "Agent list returned")
|
||||||
@ApiResponse(responseCode = "400", description = "Invalid status filter",
|
@ApiResponse(responseCode = "400", description = "Invalid status filter",
|
||||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
|
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
|
||||||
@RequestParam(required = false) String status,
|
@RequestParam(required = false) String status,
|
||||||
@RequestParam(required = false) String group) {
|
@RequestParam(required = false) String application) {
|
||||||
List<AgentInfo> agents;
|
List<AgentInfo> agents;
|
||||||
|
|
||||||
if (status != null) {
|
if (status != null) {
|
||||||
@@ -198,16 +207,59 @@ public class AgentRegistrationController {
|
|||||||
agents = registryService.findAll();
|
agents = registryService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply group filter if specified
|
// Apply application filter if specified
|
||||||
if (group != null && !group.isBlank()) {
|
if (application != null && !application.isBlank()) {
|
||||||
agents = agents.stream()
|
agents = agents.stream()
|
||||||
.filter(a -> group.equals(a.group()))
|
.filter(a -> application.equals(a.application()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AgentInstanceResponse> response = agents.stream()
|
// Enrich with runtime metrics from continuous aggregates
|
||||||
.map(AgentInstanceResponse::from)
|
Map<String, double[]> agentMetrics = queryAgentMetrics();
|
||||||
|
final List<AgentInfo> finalAgents = agents;
|
||||||
|
|
||||||
|
List<AgentInstanceResponse> response = finalAgents.stream()
|
||||||
|
.map(a -> {
|
||||||
|
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
|
||||||
|
double[] m = agentMetrics.get(a.application());
|
||||||
|
if (m != null) {
|
||||||
|
long appAgentCount = finalAgents.stream()
|
||||||
|
.filter(ag -> ag.application().equals(a.application())).count();
|
||||||
|
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
|
||||||
|
double errorRate = m[1];
|
||||||
|
int activeRoutes = (int) m[2];
|
||||||
|
return dto.withMetrics(agentTps, errorRate, activeRoutes);
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
|
})
|
||||||
.toList();
|
.toList();
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, double[]> queryAgentMetrics() {
|
||||||
|
Map<String, double[]> result = new HashMap<>();
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
||||||
|
try {
|
||||||
|
jdbc.query(
|
||||||
|
"SELECT application_name, " +
|
||||||
|
"SUM(total_count) AS total, " +
|
||||||
|
"SUM(failed_count) AS failed, " +
|
||||||
|
"COUNT(DISTINCT route_id) AS active_routes " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
|
"GROUP BY application_name",
|
||||||
|
rs -> {
|
||||||
|
long total = rs.getLong("total");
|
||||||
|
long failed = rs.getLong("failed");
|
||||||
|
double tps = total / 60.0;
|
||||||
|
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||||
|
int activeRoutes = rs.getInt("active_routes");
|
||||||
|
result.put(rs.getString("application_name"), new double[]{tps, errorRate, activeRoutes});
|
||||||
|
},
|
||||||
|
Timestamp.from(from1m), Timestamp.from(now));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Could not query agent metrics: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,13 +72,14 @@ public class DatabaseAdminController {
|
|||||||
@Operation(summary = "Get table sizes and row counts")
|
@Operation(summary = "Get table sizes and row counts")
|
||||||
public ResponseEntity<List<TableSizeResponse>> getTables() {
|
public ResponseEntity<List<TableSizeResponse>> getTables() {
|
||||||
var tables = jdbc.query("""
|
var tables = jdbc.query("""
|
||||||
SELECT schemaname || '.' || relname AS table_name,
|
SELECT relname AS table_name,
|
||||||
n_live_tup AS row_count,
|
n_live_tup AS row_count,
|
||||||
pg_size_pretty(pg_total_relation_size(relid)) AS data_size,
|
pg_size_pretty(pg_total_relation_size(relid)) AS data_size,
|
||||||
pg_total_relation_size(relid) AS data_size_bytes,
|
pg_total_relation_size(relid) AS data_size_bytes,
|
||||||
pg_size_pretty(pg_indexes_size(relid)) AS index_size,
|
pg_size_pretty(pg_indexes_size(relid)) AS index_size,
|
||||||
pg_indexes_size(relid) AS index_size_bytes
|
pg_indexes_size(relid) AS index_size_bytes
|
||||||
FROM pg_stat_user_tables
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname = current_schema()
|
||||||
ORDER BY pg_total_relation_size(relid) DESC
|
ORDER BY pg_total_relation_size(relid) DESC
|
||||||
""", (rs, row) -> new TableSizeResponse(
|
""", (rs, row) -> new TableSizeResponse(
|
||||||
rs.getString("table_name"), rs.getLong("row_count"),
|
rs.getString("table_name"), rs.getLong("row_count"),
|
||||||
@@ -94,7 +95,7 @@ public class DatabaseAdminController {
|
|||||||
SELECT pid, EXTRACT(EPOCH FROM (now() - query_start)) AS duration_seconds,
|
SELECT pid, EXTRACT(EPOCH FROM (now() - query_start)) AS duration_seconds,
|
||||||
state, query
|
state, query
|
||||||
FROM pg_stat_activity
|
FROM pg_stat_activity
|
||||||
WHERE state != 'idle' AND pid != pg_backend_pid()
|
WHERE state != 'idle' AND pid != pg_backend_pid() AND datname = current_database()
|
||||||
ORDER BY query_start ASC
|
ORDER BY query_start ASC
|
||||||
""", (rs, row) -> new ActiveQueryResponse(
|
""", (rs, row) -> new ActiveQueryResponse(
|
||||||
rs.getInt("pid"), rs.getDouble("duration_seconds"),
|
rs.getInt("pid"), rs.getDouble("duration_seconds"),
|
||||||
|
|||||||
@@ -90,14 +90,14 @@ public class DiagramRenderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "Find diagram by application group and route ID",
|
@Operation(summary = "Find diagram by application and route ID",
|
||||||
description = "Resolves group to agent IDs and finds the latest diagram for the route")
|
description = "Resolves application to agent IDs and finds the latest diagram for the route")
|
||||||
@ApiResponse(responseCode = "200", description = "Diagram layout returned")
|
@ApiResponse(responseCode = "200", description = "Diagram layout returned")
|
||||||
@ApiResponse(responseCode = "404", description = "No diagram found for the given group and route")
|
@ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
|
||||||
public ResponseEntity<DiagramLayout> findByGroupAndRoute(
|
public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
|
||||||
@RequestParam String group,
|
@RequestParam String application,
|
||||||
@RequestParam String routeId) {
|
@RequestParam String routeId) {
|
||||||
List<String> agentIds = registryService.findByGroup(group).stream()
|
List<String> agentIds = registryService.findByApplication(application).stream()
|
||||||
.map(AgentInfo::id)
|
.map(AgentInfo::id)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ public class ExecutionController {
|
|||||||
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
||||||
public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException {
|
public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException {
|
||||||
String agentId = extractAgentId();
|
String agentId = extractAgentId();
|
||||||
String groupName = resolveGroupName(agentId);
|
String applicationName = resolveApplicationName(agentId);
|
||||||
List<RouteExecution> executions = parsePayload(body);
|
List<RouteExecution> executions = parsePayload(body);
|
||||||
|
|
||||||
for (RouteExecution execution : executions) {
|
for (RouteExecution execution : executions) {
|
||||||
ingestionService.ingestExecution(agentId, groupName, execution);
|
ingestionService.ingestExecution(agentId, applicationName, execution);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.accepted().build();
|
return ResponseEntity.accepted().build();
|
||||||
@@ -68,9 +68,9 @@ public class ExecutionController {
|
|||||||
return auth != null ? auth.getName() : "";
|
return auth != null ? auth.getName() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveGroupName(String agentId) {
|
private String resolveApplicationName(String agentId) {
|
||||||
AgentInfo agent = registryService.findById(agentId);
|
AgentInfo agent = registryService.findById(agentId);
|
||||||
return agent != null ? agent.group() : "";
|
return agent != null ? agent.application() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<RouteExecution> parsePayload(String body) throws JsonProcessingException {
|
private List<RouteExecution> parsePayload(String body) throws JsonProcessingException {
|
||||||
|
|||||||
@@ -48,17 +48,20 @@ public class OpenSearchAdminController {
|
|||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final String opensearchUrl;
|
private final String opensearchUrl;
|
||||||
|
private final String indexPrefix;
|
||||||
|
|
||||||
public OpenSearchAdminController(OpenSearchClient client, RestClient restClient,
|
public OpenSearchAdminController(OpenSearchClient client, RestClient restClient,
|
||||||
SearchIndexerStats indexerStats, AuditService auditService,
|
SearchIndexerStats indexerStats, AuditService auditService,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
@Value("${opensearch.url:http://localhost:9200}") String opensearchUrl) {
|
@Value("${opensearch.url:http://localhost:9200}") String opensearchUrl,
|
||||||
|
@Value("${opensearch.index-prefix:executions-}") String indexPrefix) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.restClient = restClient;
|
this.restClient = restClient;
|
||||||
this.indexerStats = indexerStats;
|
this.indexerStats = indexerStats;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.opensearchUrl = opensearchUrl;
|
this.opensearchUrl = opensearchUrl;
|
||||||
|
this.indexPrefix = indexPrefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
@@ -109,6 +112,9 @@ public class OpenSearchAdminController {
|
|||||||
List<IndexInfoResponse> allIndices = new ArrayList<>();
|
List<IndexInfoResponse> allIndices = new ArrayList<>();
|
||||||
for (JsonNode idx : indices) {
|
for (JsonNode idx : indices) {
|
||||||
String name = idx.path("index").asText("");
|
String name = idx.path("index").asText("");
|
||||||
|
if (!name.startsWith(indexPrefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!search.isEmpty() && !name.contains(search)) {
|
if (!search.isEmpty() && !name.contains(search)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -146,6 +152,9 @@ public class OpenSearchAdminController {
|
|||||||
@Operation(summary = "Delete an OpenSearch index")
|
@Operation(summary = "Delete an OpenSearch index")
|
||||||
public ResponseEntity<Void> deleteIndex(@PathVariable String name, HttpServletRequest request) {
|
public ResponseEntity<Void> deleteIndex(@PathVariable String name, HttpServletRequest request) {
|
||||||
try {
|
try {
|
||||||
|
if (!name.startsWith(indexPrefix)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete index outside application scope");
|
||||||
|
}
|
||||||
boolean exists = client.indices().exists(r -> r.index(name)).value();
|
boolean exists = client.indices().exists(r -> r.index(name)).value();
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Index not found: " + name);
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Index not found: " + name);
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.AgentSummary;
|
||||||
|
import com.cameleer3.server.app.dto.AppCatalogEntry;
|
||||||
|
import com.cameleer3.server.app.dto.RouteSummary;
|
||||||
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
|
import com.cameleer3.server.core.storage.StatsStore;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/routes")
|
||||||
|
@Tag(name = "Route Catalog", description = "Route catalog and discovery")
|
||||||
|
public class RouteCatalogController {
|
||||||
|
|
||||||
|
private final AgentRegistryService registryService;
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public RouteCatalogController(AgentRegistryService registryService, JdbcTemplate jdbc) {
|
||||||
|
this.registryService = registryService;
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/catalog")
|
||||||
|
@Operation(summary = "Get route catalog",
|
||||||
|
description = "Returns all applications with their routes, agents, and health status")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Catalog returned")
|
||||||
|
public ResponseEntity<List<AppCatalogEntry>> getCatalog() {
|
||||||
|
List<AgentInfo> allAgents = registryService.findAll();
|
||||||
|
|
||||||
|
// Group agents by application name
|
||||||
|
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
||||||
|
.collect(Collectors.groupingBy(AgentInfo::application, LinkedHashMap::new, Collectors.toList()));
|
||||||
|
|
||||||
|
// Collect all distinct routes per app
|
||||||
|
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
|
||||||
|
for (var entry : agentsByApp.entrySet()) {
|
||||||
|
Set<String> routes = new LinkedHashSet<>();
|
||||||
|
for (AgentInfo agent : entry.getValue()) {
|
||||||
|
if (agent.routeIds() != null) {
|
||||||
|
routes.addAll(agent.routeIds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routesByApp.put(entry.getKey(), routes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query route-level stats for the last 24 hours
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant from24h = now.minus(24, ChronoUnit.HOURS);
|
||||||
|
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
||||||
|
|
||||||
|
// Route exchange counts from continuous aggregate
|
||||||
|
Map<String, Long> routeExchangeCounts = new LinkedHashMap<>();
|
||||||
|
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
||||||
|
try {
|
||||||
|
jdbc.query(
|
||||||
|
"SELECT application_name, route_id, SUM(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
|
"GROUP BY application_name, route_id",
|
||||||
|
rs -> {
|
||||||
|
String key = rs.getString("application_name") + "/" + rs.getString("route_id");
|
||||||
|
routeExchangeCounts.put(key, rs.getLong("cnt"));
|
||||||
|
Timestamp ts = rs.getTimestamp("last_seen");
|
||||||
|
if (ts != null) routeLastSeen.put(key, ts.toInstant());
|
||||||
|
},
|
||||||
|
Timestamp.from(from24h), Timestamp.from(now));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Continuous aggregate may not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-agent TPS from the last minute
|
||||||
|
Map<String, Double> agentTps = new LinkedHashMap<>();
|
||||||
|
try {
|
||||||
|
jdbc.query(
|
||||||
|
"SELECT application_name, SUM(total_count) AS cnt " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
|
"GROUP BY application_name",
|
||||||
|
rs -> {
|
||||||
|
// This gives per-app TPS; we'll distribute among agents below
|
||||||
|
},
|
||||||
|
Timestamp.from(from1m), Timestamp.from(now));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Continuous aggregate may not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build catalog entries
|
||||||
|
List<AppCatalogEntry> catalog = new ArrayList<>();
|
||||||
|
for (var entry : agentsByApp.entrySet()) {
|
||||||
|
String appId = entry.getKey();
|
||||||
|
List<AgentInfo> agents = entry.getValue();
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
|
||||||
|
List<RouteSummary> routeSummaries = routeIds.stream()
|
||||||
|
.map(routeId -> {
|
||||||
|
String key = appId + "/" + routeId;
|
||||||
|
long count = routeExchangeCounts.getOrDefault(key, 0L);
|
||||||
|
Instant lastSeen = routeLastSeen.get(key);
|
||||||
|
return new RouteSummary(routeId, count, lastSeen);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Agent summaries
|
||||||
|
List<AgentSummary> agentSummaries = agents.stream()
|
||||||
|
.map(a -> new AgentSummary(a.id(), a.name(), a.state().name().toLowerCase(), 0.0))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Health = worst state among agents
|
||||||
|
String health = computeWorstHealth(agents);
|
||||||
|
|
||||||
|
// Total exchange count for the app
|
||||||
|
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
|
||||||
|
|
||||||
|
catalog.add(new AppCatalogEntry(appId, routeSummaries, agentSummaries,
|
||||||
|
agents.size(), health, totalExchanges));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String computeWorstHealth(List<AgentInfo> agents) {
|
||||||
|
boolean hasDead = false;
|
||||||
|
boolean hasStale = false;
|
||||||
|
for (AgentInfo a : agents) {
|
||||||
|
if (a.state() == AgentState.DEAD) hasDead = true;
|
||||||
|
if (a.state() == AgentState.STALE) hasStale = true;
|
||||||
|
}
|
||||||
|
if (hasDead) return "dead";
|
||||||
|
if (hasStale) return "stale";
|
||||||
|
return "live";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.ProcessorMetrics;
|
||||||
|
import com.cameleer3.server.app.dto.RouteMetrics;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/routes")
|
||||||
|
@Tag(name = "Route Metrics", description = "Route performance metrics")
|
||||||
|
public class RouteMetricsController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public RouteMetricsController(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/metrics")
|
||||||
|
@Operation(summary = "Get route metrics",
|
||||||
|
description = "Returns aggregated performance metrics per route for the given time window")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Metrics returned")
|
||||||
|
public ResponseEntity<List<RouteMetrics>> getMetrics(
|
||||||
|
@RequestParam(required = false) String from,
|
||||||
|
@RequestParam(required = false) String to,
|
||||||
|
@RequestParam(required = false) String appId) {
|
||||||
|
|
||||||
|
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
|
||||||
|
Instant fromInstant = from != null ? Instant.parse(from) : toInstant.minus(24, ChronoUnit.HOURS);
|
||||||
|
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
|
||||||
|
|
||||||
|
var sql = new StringBuilder(
|
||||||
|
"SELECT application_name, route_id, " +
|
||||||
|
"SUM(total_count) AS total, " +
|
||||||
|
"SUM(failed_count) AS failed, " +
|
||||||
|
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_dur, " +
|
||||||
|
"COALESCE(MAX(p99_duration), 0) AS p99_dur " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ?");
|
||||||
|
var params = new ArrayList<Object>();
|
||||||
|
params.add(Timestamp.from(fromInstant));
|
||||||
|
params.add(Timestamp.from(toInstant));
|
||||||
|
|
||||||
|
if (appId != null) {
|
||||||
|
sql.append(" AND application_name = ?");
|
||||||
|
params.add(appId);
|
||||||
|
}
|
||||||
|
sql.append(" GROUP BY application_name, route_id ORDER BY application_name, route_id");
|
||||||
|
|
||||||
|
// Key struct for sparkline lookup
|
||||||
|
record RouteKey(String appId, String routeId) {}
|
||||||
|
List<RouteKey> routeKeys = new ArrayList<>();
|
||||||
|
|
||||||
|
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
||||||
|
String applicationName = rs.getString("application_name");
|
||||||
|
String routeId = rs.getString("route_id");
|
||||||
|
long total = rs.getLong("total");
|
||||||
|
long failed = rs.getLong("failed");
|
||||||
|
double avgDur = rs.getDouble("avg_dur");
|
||||||
|
double p99Dur = rs.getDouble("p99_dur");
|
||||||
|
|
||||||
|
double successRate = total > 0 ? (double) (total - failed) / total : 1.0;
|
||||||
|
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||||
|
double tps = windowSeconds > 0 ? (double) total / windowSeconds : 0.0;
|
||||||
|
|
||||||
|
routeKeys.add(new RouteKey(applicationName, routeId));
|
||||||
|
return new RouteMetrics(routeId, applicationName, total, successRate,
|
||||||
|
avgDur, p99Dur, errorRate, tps, List.of());
|
||||||
|
}, params.toArray());
|
||||||
|
|
||||||
|
// Fetch sparklines (12 buckets over the time window)
|
||||||
|
if (!metrics.isEmpty()) {
|
||||||
|
int sparkBuckets = 12;
|
||||||
|
long bucketSeconds = Math.max(windowSeconds / sparkBuckets, 60);
|
||||||
|
|
||||||
|
for (int i = 0; i < metrics.size(); i++) {
|
||||||
|
RouteMetrics m = metrics.get(i);
|
||||||
|
try {
|
||||||
|
List<Double> sparkline = jdbc.query(
|
||||||
|
"SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " +
|
||||||
|
"COALESCE(SUM(total_count), 0) AS cnt " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
|
"AND application_name = ? AND route_id = ? " +
|
||||||
|
"GROUP BY period ORDER BY period",
|
||||||
|
(rs, rowNum) -> rs.getDouble("cnt"),
|
||||||
|
bucketSeconds, Timestamp.from(fromInstant), Timestamp.from(toInstant),
|
||||||
|
m.appId(), m.routeId());
|
||||||
|
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
|
||||||
|
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
|
||||||
|
m.errorRate(), m.throughputPerSec(), sparkline));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Leave sparkline empty on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(metrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/metrics/processors")
|
||||||
|
@Operation(summary = "Get processor metrics",
|
||||||
|
description = "Returns aggregated performance metrics per processor for the given route and time window")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Metrics returned")
|
||||||
|
public ResponseEntity<List<ProcessorMetrics>> getProcessorMetrics(
|
||||||
|
@RequestParam String routeId,
|
||||||
|
@RequestParam(required = false) String appId,
|
||||||
|
@RequestParam(required = false) Instant from,
|
||||||
|
@RequestParam(required = false) Instant to) {
|
||||||
|
|
||||||
|
Instant toInstant = to != null ? to : Instant.now();
|
||||||
|
Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS);
|
||||||
|
|
||||||
|
var sql = new StringBuilder(
|
||||||
|
"SELECT processor_id, processor_type, route_id, application_name, " +
|
||||||
|
"SUM(total_count) AS total_count, " +
|
||||||
|
"SUM(failed_count) AS failed_count, " +
|
||||||
|
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum)::double precision / SUM(total_count) ELSE 0 END AS avg_duration_ms, " +
|
||||||
|
"MAX(p99_duration) AS p99_duration_ms " +
|
||||||
|
"FROM stats_1m_processor_detail " +
|
||||||
|
"WHERE bucket >= ? AND bucket < ? AND route_id = ?");
|
||||||
|
var params = new ArrayList<Object>();
|
||||||
|
params.add(Timestamp.from(fromInstant));
|
||||||
|
params.add(Timestamp.from(toInstant));
|
||||||
|
params.add(routeId);
|
||||||
|
|
||||||
|
if (appId != null) {
|
||||||
|
sql.append(" AND application_name = ?");
|
||||||
|
params.add(appId);
|
||||||
|
}
|
||||||
|
sql.append(" GROUP BY processor_id, processor_type, route_id, application_name");
|
||||||
|
sql.append(" ORDER BY SUM(total_count) DESC");
|
||||||
|
|
||||||
|
List<ProcessorMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
||||||
|
long totalCount = rs.getLong("total_count");
|
||||||
|
long failedCount = rs.getLong("failed_count");
|
||||||
|
double errorRate = failedCount > 0 ? (double) failedCount / totalCount : 0.0;
|
||||||
|
return new ProcessorMetrics(
|
||||||
|
rs.getString("processor_id"),
|
||||||
|
rs.getString("processor_type"),
|
||||||
|
rs.getString("route_id"),
|
||||||
|
rs.getString("application_name"),
|
||||||
|
totalCount,
|
||||||
|
failedCount,
|
||||||
|
rs.getDouble("avg_duration_ms"),
|
||||||
|
rs.getDouble("p99_duration_ms"),
|
||||||
|
errorRate);
|
||||||
|
}, params.toArray());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(metrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,13 +51,13 @@ public class SearchController {
|
|||||||
@RequestParam(required = false) String routeId,
|
@RequestParam(required = false) String routeId,
|
||||||
@RequestParam(required = false) String agentId,
|
@RequestParam(required = false) String agentId,
|
||||||
@RequestParam(required = false) String processorType,
|
@RequestParam(required = false) String processorType,
|
||||||
@RequestParam(required = false) String group,
|
@RequestParam(required = false) String application,
|
||||||
@RequestParam(defaultValue = "0") int offset,
|
@RequestParam(defaultValue = "0") int offset,
|
||||||
@RequestParam(defaultValue = "50") int limit,
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
@RequestParam(required = false) String sortField,
|
@RequestParam(required = false) String sortField,
|
||||||
@RequestParam(required = false) String sortDir) {
|
@RequestParam(required = false) String sortDir) {
|
||||||
|
|
||||||
List<String> agentIds = resolveGroupToAgentIds(group);
|
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||||
|
|
||||||
SearchRequest request = new SearchRequest(
|
SearchRequest request = new SearchRequest(
|
||||||
status, timeFrom, timeTo,
|
status, timeFrom, timeTo,
|
||||||
@@ -65,7 +65,7 @@ public class SearchController {
|
|||||||
correlationId,
|
correlationId,
|
||||||
text, null, null, null,
|
text, null, null, null,
|
||||||
routeId, agentId, processorType,
|
routeId, agentId, processorType,
|
||||||
group, agentIds,
|
application, agentIds,
|
||||||
offset, limit,
|
offset, limit,
|
||||||
sortField, sortDir
|
sortField, sortDir
|
||||||
);
|
);
|
||||||
@@ -77,11 +77,11 @@ public class SearchController {
|
|||||||
@Operation(summary = "Advanced search with all filters")
|
@Operation(summary = "Advanced search with all filters")
|
||||||
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
|
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
|
||||||
@RequestBody SearchRequest request) {
|
@RequestBody SearchRequest request) {
|
||||||
// Resolve group to agentIds if group is specified but agentIds is not
|
// Resolve application to agentIds if application is specified but agentIds is not
|
||||||
SearchRequest resolved = request;
|
SearchRequest resolved = request;
|
||||||
if (request.group() != null && !request.group().isBlank()
|
if (request.application() != null && !request.application().isBlank()
|
||||||
&& (request.agentIds() == null || request.agentIds().isEmpty())) {
|
&& (request.agentIds() == null || request.agentIds().isEmpty())) {
|
||||||
resolved = request.withAgentIds(resolveGroupToAgentIds(request.group()));
|
resolved = request.withAgentIds(resolveApplicationToAgentIds(request.application()));
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(searchService.search(resolved));
|
return ResponseEntity.ok(searchService.search(resolved));
|
||||||
}
|
}
|
||||||
@@ -92,12 +92,15 @@ public class SearchController {
|
|||||||
@RequestParam Instant from,
|
@RequestParam Instant from,
|
||||||
@RequestParam(required = false) Instant to,
|
@RequestParam(required = false) Instant to,
|
||||||
@RequestParam(required = false) String routeId,
|
@RequestParam(required = false) String routeId,
|
||||||
@RequestParam(required = false) String group) {
|
@RequestParam(required = false) String application) {
|
||||||
Instant end = to != null ? to : Instant.now();
|
Instant end = to != null ? to : Instant.now();
|
||||||
List<String> agentIds = resolveGroupToAgentIds(group);
|
if (routeId == null && application == null) {
|
||||||
if (routeId == null && agentIds == null) {
|
|
||||||
return ResponseEntity.ok(searchService.stats(from, end));
|
return ResponseEntity.ok(searchService.stats(from, end));
|
||||||
}
|
}
|
||||||
|
if (routeId == null) {
|
||||||
|
return ResponseEntity.ok(searchService.statsForApp(from, end, application));
|
||||||
|
}
|
||||||
|
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||||
return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds));
|
return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,9 +111,15 @@ public class SearchController {
|
|||||||
@RequestParam(required = false) Instant to,
|
@RequestParam(required = false) Instant to,
|
||||||
@RequestParam(defaultValue = "24") int buckets,
|
@RequestParam(defaultValue = "24") int buckets,
|
||||||
@RequestParam(required = false) String routeId,
|
@RequestParam(required = false) String routeId,
|
||||||
@RequestParam(required = false) String group) {
|
@RequestParam(required = false) String application) {
|
||||||
Instant end = to != null ? to : Instant.now();
|
Instant end = to != null ? to : Instant.now();
|
||||||
List<String> agentIds = resolveGroupToAgentIds(group);
|
if (routeId == null && application == null) {
|
||||||
|
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
||||||
|
}
|
||||||
|
if (routeId == null) {
|
||||||
|
return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application));
|
||||||
|
}
|
||||||
|
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||||
if (routeId == null && agentIds == null) {
|
if (routeId == null && agentIds == null) {
|
||||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
||||||
}
|
}
|
||||||
@@ -118,14 +127,14 @@ public class SearchController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve an application group name to agent IDs.
|
* Resolve an application name to agent IDs.
|
||||||
* Returns null if group is null/blank (no filtering).
|
* Returns null if application is null/blank (no filtering).
|
||||||
*/
|
*/
|
||||||
private List<String> resolveGroupToAgentIds(String group) {
|
private List<String> resolveApplicationToAgentIds(String application) {
|
||||||
if (group == null || group.isBlank()) {
|
if (application == null || application.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return registryService.findByGroup(group).stream()
|
return registryService.findByApplication(application).stream()
|
||||||
.map(AgentInfo::id)
|
.map(AgentInfo::id)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.cameleer3.server.app.controller;
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.SetPasswordRequest;
|
||||||
import com.cameleer3.server.core.admin.AuditCategory;
|
import com.cameleer3.server.core.admin.AuditCategory;
|
||||||
import com.cameleer3.server.core.admin.AuditResult;
|
import com.cameleer3.server.core.admin.AuditResult;
|
||||||
import com.cameleer3.server.core.admin.AuditService;
|
import com.cameleer3.server.core.admin.AuditService;
|
||||||
@@ -12,6 +13,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
|||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
@@ -172,6 +174,18 @@ public class UserAdminController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{userId}/password")
|
||||||
|
@Operation(summary = "Reset user password")
|
||||||
|
@ApiResponse(responseCode = "204", description = "Password reset")
|
||||||
|
public ResponseEntity<Void> resetPassword(
|
||||||
|
@PathVariable String userId,
|
||||||
|
@Valid @RequestBody SetPasswordRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
|
||||||
|
auditService.log("reset_password", AuditCategory.USER_MGMT, userId, null, AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
public record CreateUserRequest(String username, String displayName, String email, String password) {}
|
public record CreateUserRequest(String username, String displayName, String email, String password) {}
|
||||||
public record UpdateUserRequest(String displayName, String email) {}
|
public record UpdateUserRequest(String displayName, String email) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRecord;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Schema(description = "Agent lifecycle event")
|
||||||
|
public record AgentEventResponse(
|
||||||
|
@NotNull long id,
|
||||||
|
@NotNull String agentId,
|
||||||
|
@NotNull String appId,
|
||||||
|
@NotNull String eventType,
|
||||||
|
String detail,
|
||||||
|
@NotNull Instant timestamp
|
||||||
|
) {
|
||||||
|
public static AgentEventResponse from(AgentEventRecord record) {
|
||||||
|
return new AgentEventResponse(
|
||||||
|
record.id(), record.agentId(), record.appId(),
|
||||||
|
record.eventType(), record.detail(), record.timestamp()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,24 +4,46 @@ import com.cameleer3.server.core.agent.AgentInfo;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Schema(description = "Agent instance summary")
|
@Schema(description = "Agent instance summary with runtime metrics")
|
||||||
public record AgentInstanceResponse(
|
public record AgentInstanceResponse(
|
||||||
@NotNull String id,
|
@NotNull String id,
|
||||||
@NotNull String name,
|
@NotNull String name,
|
||||||
@NotNull String group,
|
@NotNull String application,
|
||||||
@NotNull String status,
|
@NotNull String status,
|
||||||
@NotNull List<String> routeIds,
|
@NotNull List<String> routeIds,
|
||||||
@NotNull Instant registeredAt,
|
@NotNull Instant registeredAt,
|
||||||
@NotNull Instant lastHeartbeat
|
@NotNull Instant lastHeartbeat,
|
||||||
|
String version,
|
||||||
|
Map<String, Object> capabilities,
|
||||||
|
double tps,
|
||||||
|
double errorRate,
|
||||||
|
int activeRoutes,
|
||||||
|
int totalRoutes,
|
||||||
|
long uptimeSeconds
|
||||||
) {
|
) {
|
||||||
public static AgentInstanceResponse from(AgentInfo info) {
|
public static AgentInstanceResponse from(AgentInfo info) {
|
||||||
|
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
|
||||||
return new AgentInstanceResponse(
|
return new AgentInstanceResponse(
|
||||||
info.id(), info.name(), info.group(),
|
info.id(), info.name(), info.application(),
|
||||||
info.state().name(), info.routeIds(),
|
info.state().name(), info.routeIds(),
|
||||||
info.registeredAt(), info.lastHeartbeat()
|
info.registeredAt(), info.lastHeartbeat(),
|
||||||
|
info.version(), info.capabilities(),
|
||||||
|
0.0, 0.0,
|
||||||
|
0, info.routeIds() != null ? info.routeIds().size() : 0,
|
||||||
|
uptime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
|
||||||
|
return new AgentInstanceResponse(
|
||||||
|
id, name, application, status, routeIds, registeredAt, lastHeartbeat,
|
||||||
|
version, capabilities,
|
||||||
|
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record AgentMetricsResponse(
|
||||||
|
@NotNull Map<String, List<MetricBucket>> metrics
|
||||||
|
) {}
|
||||||
@@ -3,5 +3,5 @@ package com.cameleer3.server.app.dto;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
@Schema(description = "Refreshed access token")
|
@Schema(description = "Refreshed access and refresh tokens")
|
||||||
public record AgentRefreshResponse(@NotNull String accessToken) {}
|
public record AgentRefreshResponse(@NotNull String accessToken, @NotNull String refreshToken) {}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import java.util.Map;
|
|||||||
public record AgentRegistrationRequest(
|
public record AgentRegistrationRequest(
|
||||||
@NotNull String agentId,
|
@NotNull String agentId,
|
||||||
@NotNull String name,
|
@NotNull String name,
|
||||||
@Schema(defaultValue = "default") String group,
|
@Schema(defaultValue = "default") String application,
|
||||||
String version,
|
String version,
|
||||||
List<String> routeIds,
|
List<String> routeIds,
|
||||||
Map<String, Object> capabilities
|
Map<String, Object> capabilities
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
@Schema(description = "Summary of an agent instance for sidebar display")
|
||||||
|
public record AgentSummary(
|
||||||
|
@NotNull String id,
|
||||||
|
@NotNull String name,
|
||||||
|
@NotNull String status,
|
||||||
|
@NotNull double tps
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Schema(description = "Application catalog entry with routes and agents")
|
||||||
|
public record AppCatalogEntry(
|
||||||
|
@NotNull String appId,
|
||||||
|
@NotNull List<RouteSummary> routes,
|
||||||
|
@NotNull List<AgentSummary> agents,
|
||||||
|
@NotNull int agentCount,
|
||||||
|
@NotNull String health,
|
||||||
|
@NotNull long exchangeCount
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record MetricBucket(
|
||||||
|
@NotNull Instant time,
|
||||||
|
double value
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record ProcessorMetrics(
|
||||||
|
@NotNull String processorId,
|
||||||
|
@NotNull String processorType,
|
||||||
|
@NotNull String routeId,
|
||||||
|
@NotNull String appId,
|
||||||
|
long totalCount,
|
||||||
|
long failedCount,
|
||||||
|
double avgDurationMs,
|
||||||
|
double p99DurationMs,
|
||||||
|
double errorRate
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Schema(description = "Aggregated route performance metrics")
|
||||||
|
public record RouteMetrics(
|
||||||
|
@NotNull String routeId,
|
||||||
|
@NotNull String appId,
|
||||||
|
@NotNull long exchangeCount,
|
||||||
|
@NotNull double successRate,
|
||||||
|
@NotNull double avgDurationMs,
|
||||||
|
@NotNull double p99DurationMs,
|
||||||
|
@NotNull double errorRate,
|
||||||
|
@NotNull double throughputPerSec,
|
||||||
|
@NotNull List<Double> sparkline
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Schema(description = "Summary of a route within an application")
|
||||||
|
public record RouteSummary(
|
||||||
|
@NotNull String routeId,
|
||||||
|
@NotNull long exchangeCount,
|
||||||
|
Instant lastSeen
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record SetPasswordRequest(
|
||||||
|
@NotBlank String password
|
||||||
|
) {}
|
||||||
@@ -288,7 +288,7 @@ public class OpenSearchIndex implements SearchIndex {
|
|||||||
map.put("execution_id", doc.executionId());
|
map.put("execution_id", doc.executionId());
|
||||||
map.put("route_id", doc.routeId());
|
map.put("route_id", doc.routeId());
|
||||||
map.put("agent_id", doc.agentId());
|
map.put("agent_id", doc.agentId());
|
||||||
map.put("group_name", doc.groupName());
|
map.put("application_name", doc.applicationName());
|
||||||
map.put("status", doc.status());
|
map.put("status", doc.status());
|
||||||
map.put("correlation_id", doc.correlationId());
|
map.put("correlation_id", doc.correlationId());
|
||||||
map.put("exchange_id", doc.exchangeId());
|
map.put("exchange_id", doc.exchangeId());
|
||||||
@@ -323,6 +323,7 @@ public class OpenSearchIndex implements SearchIndex {
|
|||||||
(String) src.get("execution_id"),
|
(String) src.get("execution_id"),
|
||||||
(String) src.get("route_id"),
|
(String) src.get("route_id"),
|
||||||
(String) src.get("agent_id"),
|
(String) src.get("agent_id"),
|
||||||
|
(String) src.get("application_name"),
|
||||||
(String) src.get("status"),
|
(String) src.get("status"),
|
||||||
src.get("start_time") != null ? Instant.parse((String) src.get("start_time")) : null,
|
src.get("start_time") != null ? Instant.parse((String) src.get("start_time")) : null,
|
||||||
src.get("end_time") != null ? Instant.parse((String) src.get("end_time")) : null,
|
src.get("end_time") != null ? Instant.parse((String) src.get("end_time")) : null,
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String createAccessToken(String subject, String group, List<String> roles) {
|
public String createAccessToken(String subject, String application, List<String> roles) {
|
||||||
return createToken(subject, group, roles, "access", properties.getAccessTokenExpiryMs());
|
return createToken(subject, application, roles, "access", properties.getAccessTokenExpiryMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String createRefreshToken(String subject, String group, List<String> roles) {
|
public String createRefreshToken(String subject, String application, List<String> roles) {
|
||||||
return createToken(subject, group, roles, "refresh", properties.getRefreshTokenExpiryMs());
|
return createToken(subject, application, roles, "refresh", properties.getRefreshTokenExpiryMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -84,12 +84,12 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
return validateAccessToken(token).subject();
|
return validateAccessToken(token).subject();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String createToken(String subject, String group, List<String> roles,
|
private String createToken(String subject, String application, List<String> roles,
|
||||||
String type, long expiryMs) {
|
String type, long expiryMs) {
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
||||||
.subject(subject)
|
.subject(subject)
|
||||||
.claim("group", group)
|
.claim("group", application)
|
||||||
.claim("type", type)
|
.claim("type", type)
|
||||||
.claim("roles", roles)
|
.claim("roles", roles)
|
||||||
.issueTime(Date.from(now))
|
.issueTime(Date.from(now))
|
||||||
@@ -132,7 +132,7 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
throw new InvalidTokenException("Token has no subject");
|
throw new InvalidTokenException("Token has no subject");
|
||||||
}
|
}
|
||||||
|
|
||||||
String group = claims.getStringClaim("group");
|
String application = claims.getStringClaim("group");
|
||||||
|
|
||||||
// Extract roles — may be absent in legacy tokens
|
// Extract roles — may be absent in legacy tokens
|
||||||
List<String> roles;
|
List<String> roles;
|
||||||
@@ -145,7 +145,7 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
roles = List.of();
|
roles = List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new JwtValidationResult(subject, group, roles);
|
return new JwtValidationResult(subject, application, roles);
|
||||||
} catch (ParseException e) {
|
} catch (ParseException e) {
|
||||||
throw new InvalidTokenException("Failed to parse JWT", e);
|
throw new InvalidTokenException("Failed to parse JWT", e);
|
||||||
} catch (JOSEException e) {
|
} catch (JOSEException e) {
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ public class SecurityConfig {
|
|||||||
// Read-only data endpoints — viewer+
|
// Read-only data endpoints — viewer+
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/agents/*/metrics").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/agents/events-log").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
|
||||||
// Admin endpoints
|
// Admin endpoints
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.cameleer3.server.app.storage;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRecord;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRepository;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class PostgresAgentEventRepository implements AgentEventRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public PostgresAgentEventRepository(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void insert(String agentId, String appId, String eventType, String detail) {
|
||||||
|
jdbc.update(
|
||||||
|
"INSERT INTO agent_events (agent_id, app_id, event_type, detail) VALUES (?, ?, ?, ?)",
|
||||||
|
agentId, appId, eventType, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AgentEventRecord> query(String appId, String agentId, Instant from, Instant to, int limit) {
|
||||||
|
var sql = new StringBuilder("SELECT id, agent_id, app_id, event_type, detail, timestamp FROM agent_events WHERE 1=1");
|
||||||
|
var params = new ArrayList<Object>();
|
||||||
|
|
||||||
|
if (appId != null) {
|
||||||
|
sql.append(" AND app_id = ?");
|
||||||
|
params.add(appId);
|
||||||
|
}
|
||||||
|
if (agentId != null) {
|
||||||
|
sql.append(" AND agent_id = ?");
|
||||||
|
params.add(agentId);
|
||||||
|
}
|
||||||
|
if (from != null) {
|
||||||
|
sql.append(" AND timestamp >= ?");
|
||||||
|
params.add(Timestamp.from(from));
|
||||||
|
}
|
||||||
|
if (to != null) {
|
||||||
|
sql.append(" AND timestamp < ?");
|
||||||
|
params.add(Timestamp.from(to));
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY timestamp DESC LIMIT ?");
|
||||||
|
params.add(limit);
|
||||||
|
|
||||||
|
return jdbc.query(sql.toString(), (rs, rowNum) -> new AgentEventRecord(
|
||||||
|
rs.getLong("id"),
|
||||||
|
rs.getString("agent_id"),
|
||||||
|
rs.getString("app_id"),
|
||||||
|
rs.getString("event_type"),
|
||||||
|
rs.getString("detail"),
|
||||||
|
rs.getTimestamp("timestamp").toInstant()
|
||||||
|
), params.toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
|||||||
@Override
|
@Override
|
||||||
public void upsert(ExecutionRecord execution) {
|
public void upsert(ExecutionRecord execution) {
|
||||||
jdbc.update("""
|
jdbc.update("""
|
||||||
INSERT INTO executions (execution_id, route_id, agent_id, group_name,
|
INSERT INTO executions (execution_id, route_id, agent_id, application_name,
|
||||||
status, correlation_id, exchange_id, start_time, end_time,
|
status, correlation_id, exchange_id, start_time, end_time,
|
||||||
duration_ms, error_message, error_stacktrace, diagram_content_hash,
|
duration_ms, error_message, error_stacktrace, diagram_content_hash,
|
||||||
created_at, updated_at)
|
created_at, updated_at)
|
||||||
@@ -45,7 +45,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
""",
|
""",
|
||||||
execution.executionId(), execution.routeId(), execution.agentId(),
|
execution.executionId(), execution.routeId(), execution.agentId(),
|
||||||
execution.groupName(), execution.status(), execution.correlationId(),
|
execution.applicationName(), execution.status(), execution.correlationId(),
|
||||||
execution.exchangeId(),
|
execution.exchangeId(),
|
||||||
Timestamp.from(execution.startTime()),
|
Timestamp.from(execution.startTime()),
|
||||||
execution.endTime() != null ? Timestamp.from(execution.endTime()) : null,
|
execution.endTime() != null ? Timestamp.from(execution.endTime()) : null,
|
||||||
@@ -55,11 +55,11 @@ public class PostgresExecutionStore implements ExecutionStore {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void upsertProcessors(String executionId, Instant startTime,
|
public void upsertProcessors(String executionId, Instant startTime,
|
||||||
String groupName, String routeId,
|
String applicationName, String routeId,
|
||||||
List<ProcessorRecord> processors) {
|
List<ProcessorRecord> processors) {
|
||||||
jdbc.batchUpdate("""
|
jdbc.batchUpdate("""
|
||||||
INSERT INTO processor_executions (execution_id, processor_id, processor_type,
|
INSERT INTO processor_executions (execution_id, processor_id, processor_type,
|
||||||
diagram_node_id, group_name, route_id, depth, parent_processor_id,
|
diagram_node_id, application_name, route_id, depth, parent_processor_id,
|
||||||
status, start_time, end_time, duration_ms, error_message, error_stacktrace,
|
status, start_time, end_time, duration_ms, error_message, error_stacktrace,
|
||||||
input_body, output_body, input_headers, output_headers)
|
input_body, output_body, input_headers, output_headers)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb)
|
||||||
@@ -76,7 +76,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
|||||||
""",
|
""",
|
||||||
processors.stream().map(p -> new Object[]{
|
processors.stream().map(p -> new Object[]{
|
||||||
p.executionId(), p.processorId(), p.processorType(),
|
p.executionId(), p.processorId(), p.processorType(),
|
||||||
p.diagramNodeId(), p.groupName(), p.routeId(),
|
p.diagramNodeId(), p.applicationName(), p.routeId(),
|
||||||
p.depth(), p.parentProcessorId(), p.status(),
|
p.depth(), p.parentProcessorId(), p.status(),
|
||||||
Timestamp.from(p.startTime()),
|
Timestamp.from(p.startTime()),
|
||||||
p.endTime() != null ? Timestamp.from(p.endTime()) : null,
|
p.endTime() != null ? Timestamp.from(p.endTime()) : null,
|
||||||
@@ -103,7 +103,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
|||||||
private static final RowMapper<ExecutionRecord> EXECUTION_MAPPER = (rs, rowNum) ->
|
private static final RowMapper<ExecutionRecord> EXECUTION_MAPPER = (rs, rowNum) ->
|
||||||
new ExecutionRecord(
|
new ExecutionRecord(
|
||||||
rs.getString("execution_id"), rs.getString("route_id"),
|
rs.getString("execution_id"), rs.getString("route_id"),
|
||||||
rs.getString("agent_id"), rs.getString("group_name"),
|
rs.getString("agent_id"), rs.getString("application_name"),
|
||||||
rs.getString("status"), rs.getString("correlation_id"),
|
rs.getString("status"), rs.getString("correlation_id"),
|
||||||
rs.getString("exchange_id"),
|
rs.getString("exchange_id"),
|
||||||
toInstant(rs, "start_time"), toInstant(rs, "end_time"),
|
toInstant(rs, "start_time"), toInstant(rs, "end_time"),
|
||||||
@@ -115,7 +115,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
|||||||
new ProcessorRecord(
|
new ProcessorRecord(
|
||||||
rs.getString("execution_id"), rs.getString("processor_id"),
|
rs.getString("execution_id"), rs.getString("processor_id"),
|
||||||
rs.getString("processor_type"), rs.getString("diagram_node_id"),
|
rs.getString("processor_type"), rs.getString("diagram_node_id"),
|
||||||
rs.getString("group_name"), rs.getString("route_id"),
|
rs.getString("application_name"), rs.getString("route_id"),
|
||||||
rs.getInt("depth"), rs.getString("parent_processor_id"),
|
rs.getInt("depth"), rs.getString("parent_processor_id"),
|
||||||
rs.getString("status"),
|
rs.getString("status"),
|
||||||
toInstant(rs, "start_time"), toInstant(rs, "end_time"),
|
toInstant(rs, "start_time"), toInstant(rs, "end_time"),
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package com.cameleer3.server.app.storage;
|
|||||||
|
|
||||||
import com.cameleer3.server.core.security.OidcConfig;
|
import com.cameleer3.server.core.security.OidcConfig;
|
||||||
import com.cameleer3.server.core.security.OidcConfigRepository;
|
import com.cameleer3.server.core.security.OidcConfigRepository;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.sql.Array;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -13,47 +14,49 @@ import java.util.Optional;
|
|||||||
public class PostgresOidcConfigRepository implements OidcConfigRepository {
|
public class PostgresOidcConfigRepository implements OidcConfigRepository {
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public PostgresOidcConfigRepository(JdbcTemplate jdbc) {
|
public PostgresOidcConfigRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<OidcConfig> find() {
|
public Optional<OidcConfig> find() {
|
||||||
var results = jdbc.query(
|
List<OidcConfig> results = jdbc.query(
|
||||||
"SELECT * FROM oidc_config WHERE config_id = 'default'",
|
"SELECT config_val FROM server_config WHERE config_key = 'oidc'",
|
||||||
(rs, rowNum) -> {
|
(rs, rowNum) -> {
|
||||||
Array arr = rs.getArray("default_roles");
|
String json = rs.getString("config_val");
|
||||||
String[] roles = arr != null ? (String[]) arr.getArray() : new String[0];
|
try {
|
||||||
return new OidcConfig(
|
return objectMapper.readValue(json, OidcConfig.class);
|
||||||
rs.getBoolean("enabled"), rs.getString("issuer_uri"),
|
} catch (JsonProcessingException e) {
|
||||||
rs.getString("client_id"), rs.getString("client_secret"),
|
throw new RuntimeException("Failed to deserialize OIDC config", e);
|
||||||
rs.getString("roles_claim"), List.of(roles),
|
}
|
||||||
rs.getBoolean("auto_signup"), rs.getString("display_name_claim"));
|
|
||||||
});
|
});
|
||||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void save(OidcConfig config) {
|
public void save(OidcConfig config) {
|
||||||
|
String json;
|
||||||
|
try {
|
||||||
|
json = objectMapper.writeValueAsString(config);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new RuntimeException("Failed to serialize OIDC config", e);
|
||||||
|
}
|
||||||
|
|
||||||
jdbc.update("""
|
jdbc.update("""
|
||||||
INSERT INTO oidc_config (config_id, enabled, issuer_uri, client_id, client_secret,
|
INSERT INTO server_config (config_key, config_val, updated_at)
|
||||||
roles_claim, default_roles, auto_signup, display_name_claim, updated_at)
|
VALUES ('oidc', ?::jsonb, now())
|
||||||
VALUES ('default', ?, ?, ?, ?, ?, ?, ?, ?, now())
|
ON CONFLICT (config_key) DO UPDATE SET
|
||||||
ON CONFLICT (config_id) DO UPDATE SET
|
config_val = EXCLUDED.config_val,
|
||||||
enabled = EXCLUDED.enabled, issuer_uri = EXCLUDED.issuer_uri,
|
|
||||||
client_id = EXCLUDED.client_id, client_secret = EXCLUDED.client_secret,
|
|
||||||
roles_claim = EXCLUDED.roles_claim, default_roles = EXCLUDED.default_roles,
|
|
||||||
auto_signup = EXCLUDED.auto_signup, display_name_claim = EXCLUDED.display_name_claim,
|
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
""",
|
""",
|
||||||
config.enabled(), config.issuerUri(), config.clientId(), config.clientSecret(),
|
json);
|
||||||
config.rolesClaim(), config.defaultRoles().toArray(new String[0]),
|
|
||||||
config.autoSignup(), config.displayNameClaim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete() {
|
public void delete() {
|
||||||
jdbc.update("DELETE FROM oidc_config WHERE config_id = 'default'");
|
jdbc.update("DELETE FROM server_config WHERE config_key = 'oidc'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ public class PostgresStatsStore implements StatsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ExecutionStats statsForApp(Instant from, Instant to, String groupName) {
|
public ExecutionStats statsForApp(Instant from, Instant to, String applicationName) {
|
||||||
return queryStats("stats_1m_app", from, to, List.of(
|
return queryStats("stats_1m_app", from, to, List.of(
|
||||||
new Filter("group_name", groupName)));
|
new Filter("application_name", applicationName)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -56,9 +56,9 @@ public class PostgresStatsStore implements StatsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String groupName) {
|
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName) {
|
||||||
return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of(
|
return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of(
|
||||||
new Filter("group_name", groupName)), true);
|
new Filter("application_name", applicationName)), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ public class PostgresThresholdRepository implements ThresholdRepository {
|
|||||||
@Override
|
@Override
|
||||||
public Optional<ThresholdConfig> find() {
|
public Optional<ThresholdConfig> find() {
|
||||||
List<ThresholdConfig> results = jdbc.query(
|
List<ThresholdConfig> results = jdbc.query(
|
||||||
"SELECT config FROM admin_thresholds WHERE id = 1",
|
"SELECT config_val FROM server_config WHERE config_key = 'thresholds'",
|
||||||
(rs, rowNum) -> {
|
(rs, rowNum) -> {
|
||||||
String json = rs.getString("config");
|
String json = rs.getString("config_val");
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(json, ThresholdConfig.class);
|
return objectMapper.readValue(json, ThresholdConfig.class);
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
@@ -46,10 +46,10 @@ public class PostgresThresholdRepository implements ThresholdRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
jdbc.update("""
|
jdbc.update("""
|
||||||
INSERT INTO admin_thresholds (id, config, updated_by, updated_at)
|
INSERT INTO server_config (config_key, config_val, updated_by, updated_at)
|
||||||
VALUES (1, ?::jsonb, ?, now())
|
VALUES ('thresholds', ?::jsonb, ?, now())
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (config_key) DO UPDATE SET
|
||||||
config = EXCLUDED.config,
|
config_val = EXCLUDED.config_val,
|
||||||
updated_by = EXCLUDED.updated_by,
|
updated_by = EXCLUDED.updated_by,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ CREATE TABLE users (
|
|||||||
provider TEXT NOT NULL,
|
provider TEXT NOT NULL,
|
||||||
email TEXT,
|
email TEXT,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
|
password_hash TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
@@ -39,12 +40,20 @@ CREATE TABLE groups (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Built-in Admins group
|
||||||
|
INSERT INTO groups (id, name) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000010', 'Admins');
|
||||||
|
|
||||||
CREATE TABLE group_roles (
|
CREATE TABLE group_roles (
|
||||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
PRIMARY KEY (group_id, role_id)
|
PRIMARY KEY (group_id, role_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Assign ADMIN role to Admins group
|
||||||
|
INSERT INTO group_roles (group_id, role_id) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
|
||||||
|
|
||||||
CREATE TABLE user_groups (
|
CREATE TABLE user_groups (
|
||||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
@@ -70,7 +79,7 @@ CREATE TABLE executions (
|
|||||||
execution_id TEXT NOT NULL,
|
execution_id TEXT NOT NULL,
|
||||||
route_id TEXT NOT NULL,
|
route_id TEXT NOT NULL,
|
||||||
agent_id TEXT NOT NULL,
|
agent_id TEXT NOT NULL,
|
||||||
group_name TEXT NOT NULL,
|
application_name TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
correlation_id TEXT,
|
correlation_id TEXT,
|
||||||
exchange_id TEXT,
|
exchange_id TEXT,
|
||||||
@@ -89,7 +98,7 @@ SELECT create_hypertable('executions', 'start_time', chunk_time_interval => INTE
|
|||||||
|
|
||||||
CREATE INDEX idx_executions_agent_time ON executions (agent_id, start_time DESC);
|
CREATE INDEX idx_executions_agent_time ON executions (agent_id, start_time DESC);
|
||||||
CREATE INDEX idx_executions_route_time ON executions (route_id, start_time DESC);
|
CREATE INDEX idx_executions_route_time ON executions (route_id, start_time DESC);
|
||||||
CREATE INDEX idx_executions_group_time ON executions (group_name, start_time DESC);
|
CREATE INDEX idx_executions_app_time ON executions (application_name, start_time DESC);
|
||||||
CREATE INDEX idx_executions_correlation ON executions (correlation_id);
|
CREATE INDEX idx_executions_correlation ON executions (correlation_id);
|
||||||
|
|
||||||
CREATE TABLE processor_executions (
|
CREATE TABLE processor_executions (
|
||||||
@@ -98,7 +107,7 @@ CREATE TABLE processor_executions (
|
|||||||
processor_id TEXT NOT NULL,
|
processor_id TEXT NOT NULL,
|
||||||
processor_type TEXT NOT NULL,
|
processor_type TEXT NOT NULL,
|
||||||
diagram_node_id TEXT,
|
diagram_node_id TEXT,
|
||||||
group_name TEXT NOT NULL,
|
application_name TEXT NOT NULL,
|
||||||
route_id TEXT NOT NULL,
|
route_id TEXT NOT NULL,
|
||||||
depth INT NOT NULL,
|
depth INT NOT NULL,
|
||||||
parent_processor_id TEXT,
|
parent_processor_id TEXT,
|
||||||
@@ -153,22 +162,56 @@ CREATE TABLE route_diagrams (
|
|||||||
CREATE INDEX idx_diagrams_route_agent ON route_diagrams (route_id, agent_id);
|
CREATE INDEX idx_diagrams_route_agent ON route_diagrams (route_id, agent_id);
|
||||||
|
|
||||||
-- =============================================================
|
-- =============================================================
|
||||||
-- OIDC configuration
|
-- Agent events
|
||||||
-- =============================================================
|
-- =============================================================
|
||||||
|
|
||||||
CREATE TABLE oidc_config (
|
CREATE TABLE agent_events (
|
||||||
config_id TEXT PRIMARY KEY DEFAULT 'default',
|
id BIGSERIAL PRIMARY KEY,
|
||||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
agent_id TEXT NOT NULL,
|
||||||
issuer_uri TEXT,
|
app_id TEXT NOT NULL,
|
||||||
client_id TEXT,
|
event_type TEXT NOT NULL,
|
||||||
client_secret TEXT,
|
detail TEXT,
|
||||||
roles_claim TEXT,
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
default_roles TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
auto_signup BOOLEAN DEFAULT false,
|
|
||||||
display_name_claim TEXT,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_agent_events_agent ON agent_events(agent_id, timestamp DESC);
|
||||||
|
CREATE INDEX idx_agent_events_app ON agent_events(app_id, timestamp DESC);
|
||||||
|
CREATE INDEX idx_agent_events_time ON agent_events(timestamp DESC);
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Server configuration
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE server_config (
|
||||||
|
config_key TEXT PRIMARY KEY,
|
||||||
|
config_val JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_by TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Admin
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
target TEXT,
|
||||||
|
detail JSONB,
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
|
||||||
|
CREATE INDEX idx_audit_log_username ON audit_log (username);
|
||||||
|
CREATE INDEX idx_audit_log_category ON audit_log (category);
|
||||||
|
CREATE INDEX idx_audit_log_action ON audit_log (action);
|
||||||
|
CREATE INDEX idx_audit_log_target ON audit_log (target);
|
||||||
|
|
||||||
-- =============================================================
|
-- =============================================================
|
||||||
-- Continuous aggregates
|
-- Continuous aggregates
|
||||||
-- =============================================================
|
-- =============================================================
|
||||||
@@ -188,16 +231,12 @@ WHERE status IS NOT NULL
|
|||||||
GROUP BY bucket
|
GROUP BY bucket
|
||||||
WITH NO DATA;
|
WITH NO DATA;
|
||||||
|
|
||||||
SELECT add_continuous_aggregate_policy('stats_1m_all',
|
|
||||||
start_offset => INTERVAL '1 hour',
|
|
||||||
end_offset => INTERVAL '1 minute',
|
|
||||||
schedule_interval => INTERVAL '1 minute');
|
|
||||||
|
|
||||||
CREATE MATERIALIZED VIEW stats_1m_app
|
CREATE MATERIALIZED VIEW stats_1m_app
|
||||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
SELECT
|
SELECT
|
||||||
time_bucket('1 minute', start_time) AS bucket,
|
time_bucket('1 minute', start_time) AS bucket,
|
||||||
group_name,
|
application_name,
|
||||||
COUNT(*) AS total_count,
|
COUNT(*) AS total_count,
|
||||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||||
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
||||||
@@ -206,19 +245,15 @@ SELECT
|
|||||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||||
FROM executions
|
FROM executions
|
||||||
WHERE status IS NOT NULL
|
WHERE status IS NOT NULL
|
||||||
GROUP BY bucket, group_name
|
GROUP BY bucket, application_name
|
||||||
WITH NO DATA;
|
WITH NO DATA;
|
||||||
|
|
||||||
SELECT add_continuous_aggregate_policy('stats_1m_app',
|
|
||||||
start_offset => INTERVAL '1 hour',
|
|
||||||
end_offset => INTERVAL '1 minute',
|
|
||||||
schedule_interval => INTERVAL '1 minute');
|
|
||||||
|
|
||||||
CREATE MATERIALIZED VIEW stats_1m_route
|
CREATE MATERIALIZED VIEW stats_1m_route
|
||||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
SELECT
|
SELECT
|
||||||
time_bucket('1 minute', start_time) AS bucket,
|
time_bucket('1 minute', start_time) AS bucket,
|
||||||
group_name,
|
application_name,
|
||||||
route_id,
|
route_id,
|
||||||
COUNT(*) AS total_count,
|
COUNT(*) AS total_count,
|
||||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||||
@@ -228,19 +263,15 @@ SELECT
|
|||||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||||
FROM executions
|
FROM executions
|
||||||
WHERE status IS NOT NULL
|
WHERE status IS NOT NULL
|
||||||
GROUP BY bucket, group_name, route_id
|
GROUP BY bucket, application_name, route_id
|
||||||
WITH NO DATA;
|
WITH NO DATA;
|
||||||
|
|
||||||
SELECT add_continuous_aggregate_policy('stats_1m_route',
|
|
||||||
start_offset => INTERVAL '1 hour',
|
|
||||||
end_offset => INTERVAL '1 minute',
|
|
||||||
schedule_interval => INTERVAL '1 minute');
|
|
||||||
|
|
||||||
CREATE MATERIALIZED VIEW stats_1m_processor
|
CREATE MATERIALIZED VIEW stats_1m_processor
|
||||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
SELECT
|
SELECT
|
||||||
time_bucket('1 minute', start_time) AS bucket,
|
time_bucket('1 minute', start_time) AS bucket,
|
||||||
group_name,
|
application_name,
|
||||||
route_id,
|
route_id,
|
||||||
processor_type,
|
processor_type,
|
||||||
COUNT(*) AS total_count,
|
COUNT(*) AS total_count,
|
||||||
@@ -249,41 +280,24 @@ SELECT
|
|||||||
MAX(duration_ms) AS duration_max,
|
MAX(duration_ms) AS duration_max,
|
||||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||||
FROM processor_executions
|
FROM processor_executions
|
||||||
GROUP BY bucket, group_name, route_id, processor_type
|
GROUP BY bucket, application_name, route_id, processor_type
|
||||||
WITH NO DATA;
|
WITH NO DATA;
|
||||||
|
|
||||||
SELECT add_continuous_aggregate_policy('stats_1m_processor',
|
|
||||||
start_offset => INTERVAL '1 hour',
|
|
||||||
end_offset => INTERVAL '1 minute',
|
|
||||||
schedule_interval => INTERVAL '1 minute');
|
|
||||||
|
|
||||||
-- =============================================================
|
CREATE MATERIALIZED VIEW stats_1m_processor_detail
|
||||||
-- Admin
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
-- =============================================================
|
SELECT
|
||||||
|
time_bucket('1 minute', start_time) AS bucket,
|
||||||
|
application_name,
|
||||||
|
route_id,
|
||||||
|
processor_id,
|
||||||
|
processor_type,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||||
|
SUM(duration_ms) AS duration_sum,
|
||||||
|
MAX(duration_ms) AS duration_max,
|
||||||
|
approx_percentile(0.99, percentile_agg(duration_ms)) AS p99_duration
|
||||||
|
FROM processor_executions
|
||||||
|
GROUP BY bucket, application_name, route_id, processor_id, processor_type
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
CREATE TABLE admin_thresholds (
|
|
||||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
|
||||||
config JSONB NOT NULL DEFAULT '{}',
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_by TEXT NOT NULL,
|
|
||||||
CONSTRAINT single_row CHECK (id = 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE audit_log (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
category TEXT NOT NULL,
|
|
||||||
target TEXT,
|
|
||||||
detail JSONB,
|
|
||||||
result TEXT NOT NULL,
|
|
||||||
ip_address TEXT,
|
|
||||||
user_agent TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
|
|
||||||
CREATE INDEX idx_audit_log_username ON audit_log (username);
|
|
||||||
CREATE INDEX idx_audit_log_category ON audit_log (category);
|
|
||||||
CREATE INDEX idx_audit_log_action ON audit_log (action);
|
|
||||||
CREATE INDEX idx_audit_log_target ON audit_log (target);
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
-- Built-in Admins group
|
|
||||||
INSERT INTO groups (id, name) VALUES
|
|
||||||
('00000000-0000-0000-0000-000000000010', 'Admins');
|
|
||||||
|
|
||||||
-- Assign ADMIN role to Admins group
|
|
||||||
INSERT INTO group_roles (group_id, role_id) VALUES
|
|
||||||
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- V2__policies.sql - TimescaleDB policies (must run outside transaction)
|
||||||
|
-- flyway:executeInTransaction=false
|
||||||
|
|
||||||
|
-- Agent metrics retention & compression
|
||||||
|
ALTER TABLE agent_metrics SET (timescaledb.compress);
|
||||||
|
SELECT add_retention_policy('agent_metrics', INTERVAL '90 days', if_not_exists => true);
|
||||||
|
SELECT add_compression_policy('agent_metrics', INTERVAL '7 days', if_not_exists => true);
|
||||||
|
|
||||||
|
-- Continuous aggregate refresh policies
|
||||||
|
SELECT add_continuous_aggregate_policy('stats_1m_all',
|
||||||
|
start_offset => INTERVAL '1 hour',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '1 minute',
|
||||||
|
if_not_exists => true);
|
||||||
|
|
||||||
|
SELECT add_continuous_aggregate_policy('stats_1m_app',
|
||||||
|
start_offset => INTERVAL '1 hour',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '1 minute',
|
||||||
|
if_not_exists => true);
|
||||||
|
|
||||||
|
SELECT add_continuous_aggregate_policy('stats_1m_route',
|
||||||
|
start_offset => INTERVAL '1 hour',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '1 minute',
|
||||||
|
if_not_exists => true);
|
||||||
|
|
||||||
|
SELECT add_continuous_aggregate_policy('stats_1m_processor',
|
||||||
|
start_offset => INTERVAL '1 hour',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '1 minute',
|
||||||
|
if_not_exists => true);
|
||||||
|
|
||||||
|
SELECT add_continuous_aggregate_policy('stats_1m_processor_detail',
|
||||||
|
start_offset => INTERVAL '1 hour',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '1 minute',
|
||||||
|
if_not_exists => true);
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE users ADD COLUMN password_hash TEXT;
|
|
||||||
@@ -42,6 +42,9 @@ public abstract class AbstractPostgresIT {
|
|||||||
registry.add("spring.datasource.password", postgres::getPassword);
|
registry.add("spring.datasource.password", postgres::getPassword);
|
||||||
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
|
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
|
||||||
registry.add("spring.flyway.enabled", () -> "true");
|
registry.add("spring.flyway.enabled", () -> "true");
|
||||||
|
registry.add("spring.flyway.url", postgres::getJdbcUrl);
|
||||||
|
registry.add("spring.flyway.user", postgres::getUsername);
|
||||||
|
registry.add("spring.flyway.password", postgres::getPassword);
|
||||||
registry.add("opensearch.url", opensearch::getHttpHostAddress);
|
registry.add("opensearch.url", opensearch::getHttpHostAddress);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ public class TestSecurityHelper {
|
|||||||
/**
|
/**
|
||||||
* Returns a valid JWT access token with the given roles (no agent registration).
|
* Returns a valid JWT access token with the given roles (no agent registration).
|
||||||
*/
|
*/
|
||||||
public String createToken(String subject, String group, List<String> roles) {
|
public String createToken(String subject, String application, List<String> roles) {
|
||||||
return jwtService.createAccessToken(subject, group, roles);
|
return jwtService.createAccessToken(subject, application, roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,17 +38,17 @@ class AgentCommandControllerIT extends AbstractPostgresIT {
|
|||||||
operatorJwt = securityHelper.operatorToken();
|
operatorJwt = securityHelper.operatorToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<String> registerAgent(String agentId, String name, String group) {
|
private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
|
||||||
String json = """
|
String json = """
|
||||||
{
|
{
|
||||||
"agentId": "%s",
|
"agentId": "%s",
|
||||||
"name": "%s",
|
"name": "%s",
|
||||||
"group": "%s",
|
"application": "%s",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": ["route-1"],
|
"routeIds": ["route-1"],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
}
|
}
|
||||||
""".formatted(agentId, name, group);
|
""".formatted(agentId, name, application);
|
||||||
|
|
||||||
return restTemplate.postForEntity(
|
return restTemplate.postForEntity(
|
||||||
"/api/v1/agents/register",
|
"/api/v1/agents/register",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class AgentRegistrationControllerIT extends AbstractPostgresIT {
|
|||||||
{
|
{
|
||||||
"agentId": "%s",
|
"agentId": "%s",
|
||||||
"name": "%s",
|
"name": "%s",
|
||||||
"group": "test-group",
|
"application": "test-group",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": ["route-1", "route-2"],
|
"routeIds": ["route-1", "route-2"],
|
||||||
"capabilities": {"tracing": true}
|
"capabilities": {"tracing": true}
|
||||||
|
|||||||
@@ -53,17 +53,17 @@ class AgentSseControllerIT extends AbstractPostgresIT {
|
|||||||
operatorJwt = securityHelper.operatorToken();
|
operatorJwt = securityHelper.operatorToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<String> registerAgent(String agentId, String name, String group) {
|
private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
|
||||||
String json = """
|
String json = """
|
||||||
{
|
{
|
||||||
"agentId": "%s",
|
"agentId": "%s",
|
||||||
"name": "%s",
|
"name": "%s",
|
||||||
"group": "%s",
|
"application": "%s",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": ["route-1"],
|
"routeIds": ["route-1"],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
}
|
}
|
||||||
""".formatted(agentId, name, group);
|
""".formatted(agentId, name, application);
|
||||||
|
|
||||||
return restTemplate.postForEntity(
|
return restTemplate.postForEntity(
|
||||||
"/api/v1/agents/register",
|
"/api/v1/agents/register",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class ThresholdAdminControllerIT extends AbstractPostgresIT {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
adminJwt = securityHelper.adminToken();
|
adminJwt = securityHelper.adminToken();
|
||||||
viewerJwt = securityHelper.viewerToken();
|
viewerJwt = securityHelper.viewerToken();
|
||||||
|
jdbcTemplate.update("DELETE FROM server_config WHERE config_key = 'thresholds'");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class BootstrapTokenIT extends AbstractPostgresIT {
|
|||||||
{
|
{
|
||||||
"agentId": "bootstrap-test-agent",
|
"agentId": "bootstrap-test-agent",
|
||||||
"name": "Bootstrap Test",
|
"name": "Bootstrap Test",
|
||||||
"group": "test-group",
|
"application": "test-group",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": [],
|
"routeIds": [],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
@@ -97,7 +97,7 @@ class BootstrapTokenIT extends AbstractPostgresIT {
|
|||||||
{
|
{
|
||||||
"agentId": "bootstrap-test-previous",
|
"agentId": "bootstrap-test-previous",
|
||||||
"name": "Previous Token Test",
|
"name": "Previous Token Test",
|
||||||
"group": "test-group",
|
"application": "test-group",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": [],
|
"routeIds": [],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class JwtRefreshIT extends AbstractPostgresIT {
|
|||||||
{
|
{
|
||||||
"agentId": "%s",
|
"agentId": "%s",
|
||||||
"name": "Refresh Test Agent",
|
"name": "Refresh Test Agent",
|
||||||
"group": "test-group",
|
"application": "test-group",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": [],
|
"routeIds": [],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
@@ -79,6 +79,8 @@ class JwtRefreshIT extends AbstractPostgresIT {
|
|||||||
|
|
||||||
JsonNode body = objectMapper.readTree(response.getBody());
|
JsonNode body = objectMapper.readTree(response.getBody());
|
||||||
assertThat(body.get("accessToken").asText()).isNotEmpty();
|
assertThat(body.get("accessToken").asText()).isNotEmpty();
|
||||||
|
assertThat(body.get("refreshToken").asText()).isNotEmpty();
|
||||||
|
assertThat(body.get("refreshToken").asText()).isNotEqualTo(refreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class JwtServiceTest {
|
|||||||
String token = jwtService.createAccessToken("user:admin", "user", roles);
|
String token = jwtService.createAccessToken("user:admin", "user", roles);
|
||||||
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
|
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
|
||||||
assertEquals("user:admin", result.subject());
|
assertEquals("user:admin", result.subject());
|
||||||
assertEquals("user", result.group());
|
assertEquals("user", result.application());
|
||||||
assertEquals(roles, result.roles());
|
assertEquals(roles, result.roles());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ class JwtServiceTest {
|
|||||||
String token = jwtService.createRefreshToken("agent-1", "default", roles);
|
String token = jwtService.createRefreshToken("agent-1", "default", roles);
|
||||||
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
|
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
|
||||||
assertEquals("agent-1", result.subject());
|
assertEquals("agent-1", result.subject());
|
||||||
assertEquals("default", result.group());
|
assertEquals("default", result.application());
|
||||||
assertEquals(roles, result.roles());
|
assertEquals(roles, result.roles());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class RegistrationSecurityIT extends AbstractPostgresIT {
|
|||||||
{
|
{
|
||||||
"agentId": "%s",
|
"agentId": "%s",
|
||||||
"name": "Security Test Agent",
|
"name": "Security Test Agent",
|
||||||
"group": "test-group",
|
"application": "test-group",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": [],
|
"routeIds": [],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class SseSigningIT extends AbstractPostgresIT {
|
|||||||
{
|
{
|
||||||
"agentId": "%s",
|
"agentId": "%s",
|
||||||
"name": "SSE Signing Test Agent",
|
"name": "SSE Signing Test Agent",
|
||||||
"group": "test-group",
|
"application": "test-group",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": ["route-1"],
|
"routeIds": ["route-1"],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ class PostgresStatsStoreIT extends AbstractPostgresIT {
|
|||||||
assertFalse(ts.buckets().isEmpty());
|
assertFalse(ts.buckets().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void insertExecution(String id, String routeId, String groupName,
|
private void insertExecution(String id, String routeId, String applicationName,
|
||||||
String status, Instant startTime, long durationMs) {
|
String status, Instant startTime, long durationMs) {
|
||||||
executionStore.upsert(new ExecutionRecord(
|
executionStore.upsert(new ExecutionRecord(
|
||||||
id, routeId, "agent-1", groupName, status, null, null,
|
id, routeId, "agent-1", applicationName, status, null, null,
|
||||||
startTime, startTime.plusMillis(durationMs), durationMs,
|
startTime, startTime.plusMillis(durationMs), durationMs,
|
||||||
status.equals("FAILED") ? "error" : null, null, null));
|
status.equals("FAILED") ? "error" : null, null, null));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.cameleer3.server.core.agent;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record AgentEventRecord(
|
||||||
|
long id,
|
||||||
|
String agentId,
|
||||||
|
String appId,
|
||||||
|
String eventType,
|
||||||
|
String detail,
|
||||||
|
Instant timestamp
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.cameleer3.server.core.agent;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface AgentEventRepository {
|
||||||
|
|
||||||
|
void insert(String agentId, String appId, String eventType, String detail);
|
||||||
|
|
||||||
|
List<AgentEventRecord> query(String appId, String agentId, Instant from, Instant to, int limit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.cameleer3.server.core.agent;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class AgentEventService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AgentEventService.class);
|
||||||
|
|
||||||
|
private final AgentEventRepository repository;
|
||||||
|
|
||||||
|
public AgentEventService(AgentEventRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordEvent(String agentId, String appId, String eventType, String detail) {
|
||||||
|
log.debug("Recording agent event: agent={}, app={}, type={}", agentId, appId, eventType);
|
||||||
|
repository.insert(agentId, appId, eventType, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AgentEventRecord> queryEvents(String appId, String agentId, Instant from, Instant to, int limit) {
|
||||||
|
return repository.query(appId, agentId, from, to, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import java.util.Map;
|
|||||||
*
|
*
|
||||||
* @param id agent-provided persistent identifier
|
* @param id agent-provided persistent identifier
|
||||||
* @param name human-readable agent name
|
* @param name human-readable agent name
|
||||||
* @param group logical grouping (e.g., "order-service-prod")
|
* @param application application name (e.g., "order-service-prod")
|
||||||
* @param version agent software version
|
* @param version agent software version
|
||||||
* @param routeIds list of Camel route IDs managed by this agent
|
* @param routeIds list of Camel route IDs managed by this agent
|
||||||
* @param capabilities agent-declared capabilities (free-form)
|
* @param capabilities agent-declared capabilities (free-form)
|
||||||
@@ -25,7 +25,7 @@ import java.util.Map;
|
|||||||
public record AgentInfo(
|
public record AgentInfo(
|
||||||
String id,
|
String id,
|
||||||
String name,
|
String name,
|
||||||
String group,
|
String application,
|
||||||
String version,
|
String version,
|
||||||
List<String> routeIds,
|
List<String> routeIds,
|
||||||
Map<String, Object> capabilities,
|
Map<String, Object> capabilities,
|
||||||
@@ -36,28 +36,28 @@ public record AgentInfo(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
public AgentInfo withState(AgentState newState) {
|
public AgentInfo withState(AgentState newState) {
|
||||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||||
newState, registeredAt, lastHeartbeat, staleTransitionTime);
|
newState, registeredAt, lastHeartbeat, staleTransitionTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentInfo withLastHeartbeat(Instant newLastHeartbeat) {
|
public AgentInfo withLastHeartbeat(Instant newLastHeartbeat) {
|
||||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||||
state, registeredAt, newLastHeartbeat, staleTransitionTime);
|
state, registeredAt, newLastHeartbeat, staleTransitionTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentInfo withRegisteredAt(Instant newRegisteredAt) {
|
public AgentInfo withRegisteredAt(Instant newRegisteredAt) {
|
||||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||||
state, newRegisteredAt, lastHeartbeat, staleTransitionTime);
|
state, newRegisteredAt, lastHeartbeat, staleTransitionTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentInfo withStaleTransitionTime(Instant newStaleTransitionTime) {
|
public AgentInfo withStaleTransitionTime(Instant newStaleTransitionTime) {
|
||||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||||
state, registeredAt, lastHeartbeat, newStaleTransitionTime);
|
state, registeredAt, lastHeartbeat, newStaleTransitionTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentInfo withMetadata(String name, String group, String version,
|
public AgentInfo withMetadata(String name, String application, String version,
|
||||||
List<String> routeIds, Map<String, Object> capabilities) {
|
List<String> routeIds, Map<String, Object> capabilities) {
|
||||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||||
state, registeredAt, lastHeartbeat, staleTransitionTime);
|
state, registeredAt, lastHeartbeat, staleTransitionTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ public class AgentRegistryService {
|
|||||||
* Register a new agent or re-register an existing one.
|
* Register a new agent or re-register an existing one.
|
||||||
* Re-registration updates metadata, transitions state to LIVE, and resets timestamps.
|
* Re-registration updates metadata, transitions state to LIVE, and resets timestamps.
|
||||||
*/
|
*/
|
||||||
public AgentInfo register(String id, String name, String group, String version,
|
public AgentInfo register(String id, String name, String application, String version,
|
||||||
List<String> routeIds, Map<String, Object> capabilities) {
|
List<String> routeIds, Map<String, Object> capabilities) {
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
AgentInfo newAgent = new AgentInfo(id, name, group, version,
|
AgentInfo newAgent = new AgentInfo(id, name, application, version,
|
||||||
List.copyOf(routeIds), Map.copyOf(capabilities),
|
List.copyOf(routeIds), Map.copyOf(capabilities),
|
||||||
AgentState.LIVE, now, now, null);
|
AgentState.LIVE, now, now, null);
|
||||||
|
|
||||||
@@ -55,13 +55,13 @@ public class AgentRegistryService {
|
|||||||
// Re-registration: update metadata, reset to LIVE
|
// Re-registration: update metadata, reset to LIVE
|
||||||
log.info("Agent {} re-registering (was {})", id, existing.state());
|
log.info("Agent {} re-registering (was {})", id, existing.state());
|
||||||
return existing
|
return existing
|
||||||
.withMetadata(name, group, version, List.copyOf(routeIds), Map.copyOf(capabilities))
|
.withMetadata(name, application, version, List.copyOf(routeIds), Map.copyOf(capabilities))
|
||||||
.withState(AgentState.LIVE)
|
.withState(AgentState.LIVE)
|
||||||
.withLastHeartbeat(now)
|
.withLastHeartbeat(now)
|
||||||
.withRegisteredAt(now)
|
.withRegisteredAt(now)
|
||||||
.withStaleTransitionTime(null);
|
.withStaleTransitionTime(null);
|
||||||
}
|
}
|
||||||
log.info("Agent {} registered (name={}, group={})", id, name, group);
|
log.info("Agent {} registered (name={}, application={})", id, name, application);
|
||||||
return newAgent;
|
return newAgent;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,11 +168,11 @@ public class AgentRegistryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return all agents belonging to the given application group.
|
* Return all agents belonging to the given application.
|
||||||
*/
|
*/
|
||||||
public List<AgentInfo> findByGroup(String group) {
|
public List<AgentInfo> findByApplication(String application) {
|
||||||
return agents.values().stream()
|
return agents.values().stream()
|
||||||
.filter(a -> group.equals(a.group()))
|
.filter(a -> application.equals(a.application()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class DetailService {
|
|||||||
List<ProcessorNode> roots = buildTree(processors);
|
List<ProcessorNode> roots = buildTree(processors);
|
||||||
return new ExecutionDetail(
|
return new ExecutionDetail(
|
||||||
exec.executionId(), exec.routeId(), exec.agentId(),
|
exec.executionId(), exec.routeId(), exec.agentId(),
|
||||||
|
exec.applicationName(),
|
||||||
exec.status(), exec.startTime(), exec.endTime(),
|
exec.status(), exec.startTime(), exec.endTime(),
|
||||||
exec.durationMs() != null ? exec.durationMs() : 0L,
|
exec.durationMs() != null ? exec.durationMs() : 0L,
|
||||||
exec.correlationId(), exec.exchangeId(),
|
exec.correlationId(), exec.exchangeId(),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public record ExecutionDetail(
|
|||||||
String executionId,
|
String executionId,
|
||||||
String routeId,
|
String routeId,
|
||||||
String agentId,
|
String agentId,
|
||||||
|
String applicationName,
|
||||||
String status,
|
String status,
|
||||||
Instant startTime,
|
Instant startTime,
|
||||||
Instant endTime,
|
Instant endTime,
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ public class SearchIndexer implements SearchIndexerStats {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
searchIndex.index(new ExecutionDocument(
|
searchIndex.index(new ExecutionDocument(
|
||||||
exec.executionId(), exec.routeId(), exec.agentId(), exec.groupName(),
|
exec.executionId(), exec.routeId(), exec.agentId(), exec.applicationName(),
|
||||||
exec.status(), exec.correlationId(), exec.exchangeId(),
|
exec.status(), exec.correlationId(), exec.exchangeId(),
|
||||||
exec.startTime(), exec.endTime(), exec.durationMs(),
|
exec.startTime(), exec.endTime(), exec.durationMs(),
|
||||||
exec.errorMessage(), exec.errorStacktrace(), processorDocs));
|
exec.errorMessage(), exec.errorStacktrace(), processorDocs));
|
||||||
|
|||||||
@@ -38,18 +38,18 @@ public class IngestionService {
|
|||||||
this.bodySizeLimit = bodySizeLimit;
|
this.bodySizeLimit = bodySizeLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ingestExecution(String agentId, String groupName, RouteExecution execution) {
|
public void ingestExecution(String agentId, String applicationName, RouteExecution execution) {
|
||||||
ExecutionRecord record = toExecutionRecord(agentId, groupName, execution);
|
ExecutionRecord record = toExecutionRecord(agentId, applicationName, execution);
|
||||||
executionStore.upsert(record);
|
executionStore.upsert(record);
|
||||||
|
|
||||||
if (execution.getProcessors() != null && !execution.getProcessors().isEmpty()) {
|
if (execution.getProcessors() != null && !execution.getProcessors().isEmpty()) {
|
||||||
List<ProcessorRecord> processors = flattenProcessors(
|
List<ProcessorRecord> processors = flattenProcessors(
|
||||||
execution.getProcessors(), record.executionId(),
|
execution.getProcessors(), record.executionId(),
|
||||||
record.startTime(), groupName, execution.getRouteId(),
|
record.startTime(), applicationName, execution.getRouteId(),
|
||||||
null, 0);
|
null, 0);
|
||||||
executionStore.upsertProcessors(
|
executionStore.upsertProcessors(
|
||||||
record.executionId(), record.startTime(),
|
record.executionId(), record.startTime(),
|
||||||
groupName, execution.getRouteId(), processors);
|
applicationName, execution.getRouteId(), processors);
|
||||||
}
|
}
|
||||||
|
|
||||||
eventPublisher.accept(new ExecutionUpdatedEvent(
|
eventPublisher.accept(new ExecutionUpdatedEvent(
|
||||||
@@ -72,13 +72,13 @@ public class IngestionService {
|
|||||||
return metricsBuffer;
|
return metricsBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ExecutionRecord toExecutionRecord(String agentId, String groupName,
|
private ExecutionRecord toExecutionRecord(String agentId, String applicationName,
|
||||||
RouteExecution exec) {
|
RouteExecution exec) {
|
||||||
String diagramHash = diagramStore
|
String diagramHash = diagramStore
|
||||||
.findContentHashForRoute(exec.getRouteId(), agentId)
|
.findContentHashForRoute(exec.getRouteId(), agentId)
|
||||||
.orElse("");
|
.orElse("");
|
||||||
return new ExecutionRecord(
|
return new ExecutionRecord(
|
||||||
exec.getExchangeId(), exec.getRouteId(), agentId, groupName,
|
exec.getExchangeId(), exec.getRouteId(), agentId, applicationName,
|
||||||
exec.getStatus() != null ? exec.getStatus().name() : "RUNNING",
|
exec.getStatus() != null ? exec.getStatus().name() : "RUNNING",
|
||||||
exec.getCorrelationId(), exec.getExchangeId(),
|
exec.getCorrelationId(), exec.getExchangeId(),
|
||||||
exec.getStartTime(), exec.getEndTime(),
|
exec.getStartTime(), exec.getEndTime(),
|
||||||
@@ -90,13 +90,13 @@ public class IngestionService {
|
|||||||
|
|
||||||
private List<ProcessorRecord> flattenProcessors(
|
private List<ProcessorRecord> flattenProcessors(
|
||||||
List<ProcessorExecution> processors, String executionId,
|
List<ProcessorExecution> processors, String executionId,
|
||||||
java.time.Instant execStartTime, String groupName, String routeId,
|
java.time.Instant execStartTime, String applicationName, String routeId,
|
||||||
String parentProcessorId, int depth) {
|
String parentProcessorId, int depth) {
|
||||||
List<ProcessorRecord> flat = new ArrayList<>();
|
List<ProcessorRecord> flat = new ArrayList<>();
|
||||||
for (ProcessorExecution p : processors) {
|
for (ProcessorExecution p : processors) {
|
||||||
flat.add(new ProcessorRecord(
|
flat.add(new ProcessorRecord(
|
||||||
executionId, p.getProcessorId(), p.getProcessorType(),
|
executionId, p.getProcessorId(), p.getProcessorType(),
|
||||||
p.getDiagramNodeId(), groupName, routeId,
|
p.getDiagramNodeId(), applicationName, routeId,
|
||||||
depth, parentProcessorId,
|
depth, parentProcessorId,
|
||||||
p.getStatus() != null ? p.getStatus().name() : "RUNNING",
|
p.getStatus() != null ? p.getStatus().name() : "RUNNING",
|
||||||
p.getStartTime() != null ? p.getStartTime() : execStartTime,
|
p.getStartTime() != null ? p.getStartTime() : execStartTime,
|
||||||
@@ -109,7 +109,7 @@ public class IngestionService {
|
|||||||
if (p.getChildren() != null) {
|
if (p.getChildren() != null) {
|
||||||
flat.addAll(flattenProcessors(
|
flat.addAll(flattenProcessors(
|
||||||
p.getChildren(), executionId, execStartTime,
|
p.getChildren(), executionId, execStartTime,
|
||||||
groupName, routeId, p.getProcessorId(), depth + 1));
|
applicationName, routeId, p.getProcessorId(), depth + 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return flat;
|
return flat;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public record ExecutionSummary(
|
|||||||
String executionId,
|
String executionId,
|
||||||
String routeId,
|
String routeId,
|
||||||
String agentId,
|
String agentId,
|
||||||
|
String applicationName,
|
||||||
String status,
|
String status,
|
||||||
Instant startTime,
|
Instant startTime,
|
||||||
Instant endTime,
|
Instant endTime,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import java.util.List;
|
|||||||
* @param routeId exact match on route_id
|
* @param routeId exact match on route_id
|
||||||
* @param agentId exact match on agent_id
|
* @param agentId exact match on agent_id
|
||||||
* @param processorType matches processor_types array via has()
|
* @param processorType matches processor_types array via has()
|
||||||
* @param group application group filter (resolved to agentIds server-side)
|
* @param application application name filter (resolved to agentIds server-side)
|
||||||
* @param agentIds list of agent IDs (resolved from group, used for IN clause)
|
* @param agentIds list of agent IDs (resolved from group, used for IN clause)
|
||||||
* @param offset pagination offset (0-based)
|
* @param offset pagination offset (0-based)
|
||||||
* @param limit page size (default 50, max 500)
|
* @param limit page size (default 50, max 500)
|
||||||
@@ -43,7 +43,7 @@ public record SearchRequest(
|
|||||||
String routeId,
|
String routeId,
|
||||||
String agentId,
|
String agentId,
|
||||||
String processorType,
|
String processorType,
|
||||||
String group,
|
String application,
|
||||||
List<String> agentIds,
|
List<String> agentIds,
|
||||||
int offset,
|
int offset,
|
||||||
int limit,
|
int limit,
|
||||||
@@ -80,12 +80,12 @@ public record SearchRequest(
|
|||||||
return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time");
|
return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a copy with resolved agentIds (from group lookup). */
|
/** Create a copy with resolved agentIds (from application name lookup). */
|
||||||
public SearchRequest withAgentIds(List<String> resolvedAgentIds) {
|
public SearchRequest withAgentIds(List<String> resolvedAgentIds) {
|
||||||
return new SearchRequest(
|
return new SearchRequest(
|
||||||
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||||
text, textInBody, textInHeaders, textInErrors,
|
text, textInBody, textInHeaders, textInErrors,
|
||||||
routeId, agentId, processorType, group, resolvedAgentIds,
|
routeId, agentId, processorType, application, resolvedAgentIds,
|
||||||
offset, limit, sortField, sortDir
|
offset, limit, sortField, sortDir
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ public class SearchService {
|
|||||||
return statsStore.stats(from, to);
|
return statsStore.stats(from, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ExecutionStats statsForApp(Instant from, Instant to, String applicationName) {
|
||||||
|
return statsStore.statsForApp(from, to, applicationName);
|
||||||
|
}
|
||||||
|
|
||||||
public ExecutionStats stats(Instant from, Instant to, String routeId, List<String> agentIds) {
|
public ExecutionStats stats(Instant from, Instant to, String routeId, List<String> agentIds) {
|
||||||
return statsStore.statsForRoute(from, to, routeId, agentIds);
|
return statsStore.statsForRoute(from, to, routeId, agentIds);
|
||||||
}
|
}
|
||||||
@@ -36,6 +40,10 @@ public class SearchService {
|
|||||||
return statsStore.timeseries(from, to, bucketCount);
|
return statsStore.timeseries(from, to, bucketCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName) {
|
||||||
|
return statsStore.timeseriesForApp(from, to, bucketCount, applicationName);
|
||||||
|
}
|
||||||
|
|
||||||
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount,
|
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount,
|
||||||
String routeId, List<String> agentIds) {
|
String routeId, List<String> agentIds) {
|
||||||
return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds);
|
return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds);
|
||||||
|
|||||||
@@ -14,21 +14,21 @@ public interface JwtService {
|
|||||||
/**
|
/**
|
||||||
* Validated JWT payload.
|
* Validated JWT payload.
|
||||||
*
|
*
|
||||||
* @param subject the {@code sub} claim (agent ID or {@code user:<username>})
|
* @param subject the {@code sub} claim (agent ID or {@code user:<username>})
|
||||||
* @param group the {@code group} claim
|
* @param application the {@code group} claim (application name)
|
||||||
* @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]})
|
* @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]})
|
||||||
*/
|
*/
|
||||||
record JwtValidationResult(String subject, String group, List<String> roles) {}
|
record JwtValidationResult(String subject, String application, List<String> roles) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a signed access JWT with the given subject, group, and roles.
|
* Creates a signed access JWT with the given subject, application, and roles.
|
||||||
*/
|
*/
|
||||||
String createAccessToken(String subject, String group, List<String> roles);
|
String createAccessToken(String subject, String application, List<String> roles);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a signed refresh JWT with the given subject, group, and roles.
|
* Creates a signed refresh JWT with the given subject, application, and roles.
|
||||||
*/
|
*/
|
||||||
String createRefreshToken(String subject, String group, List<String> roles);
|
String createRefreshToken(String subject, String application, List<String> roles);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates an access token and returns the full validation result.
|
* Validates an access token and returns the full validation result.
|
||||||
@@ -46,12 +46,12 @@ public interface JwtService {
|
|||||||
|
|
||||||
// --- Backward-compatible defaults (delegate to role-aware methods) ---
|
// --- Backward-compatible defaults (delegate to role-aware methods) ---
|
||||||
|
|
||||||
default String createAccessToken(String subject, String group) {
|
default String createAccessToken(String subject, String application) {
|
||||||
return createAccessToken(subject, group, List.of());
|
return createAccessToken(subject, application, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
default String createRefreshToken(String subject, String group) {
|
default String createRefreshToken(String subject, String application) {
|
||||||
return createRefreshToken(subject, group, List.of());
|
return createRefreshToken(subject, application, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
default String validateAndExtractAgentId(String token) {
|
default String validateAndExtractAgentId(String token) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public interface ExecutionStore {
|
|||||||
void upsert(ExecutionRecord execution);
|
void upsert(ExecutionRecord execution);
|
||||||
|
|
||||||
void upsertProcessors(String executionId, Instant startTime,
|
void upsertProcessors(String executionId, Instant startTime,
|
||||||
String groupName, String routeId,
|
String applicationName, String routeId,
|
||||||
List<ProcessorRecord> processors);
|
List<ProcessorRecord> processors);
|
||||||
|
|
||||||
Optional<ExecutionRecord> findById(String executionId);
|
Optional<ExecutionRecord> findById(String executionId);
|
||||||
@@ -17,7 +17,7 @@ public interface ExecutionStore {
|
|||||||
List<ProcessorRecord> findProcessors(String executionId);
|
List<ProcessorRecord> findProcessors(String executionId);
|
||||||
|
|
||||||
record ExecutionRecord(
|
record ExecutionRecord(
|
||||||
String executionId, String routeId, String agentId, String groupName,
|
String executionId, String routeId, String agentId, String applicationName,
|
||||||
String status, String correlationId, String exchangeId,
|
String status, String correlationId, String exchangeId,
|
||||||
Instant startTime, Instant endTime, Long durationMs,
|
Instant startTime, Instant endTime, Long durationMs,
|
||||||
String errorMessage, String errorStacktrace, String diagramContentHash
|
String errorMessage, String errorStacktrace, String diagramContentHash
|
||||||
@@ -25,7 +25,7 @@ public interface ExecutionStore {
|
|||||||
|
|
||||||
record ProcessorRecord(
|
record ProcessorRecord(
|
||||||
String executionId, String processorId, String processorType,
|
String executionId, String processorId, String processorType,
|
||||||
String diagramNodeId, String groupName, String routeId,
|
String diagramNodeId, String applicationName, String routeId,
|
||||||
int depth, String parentProcessorId, String status,
|
int depth, String parentProcessorId, String status,
|
||||||
Instant startTime, Instant endTime, Long durationMs,
|
Instant startTime, Instant endTime, Long durationMs,
|
||||||
String errorMessage, String errorStacktrace,
|
String errorMessage, String errorStacktrace,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public interface StatsStore {
|
|||||||
ExecutionStats stats(Instant from, Instant to);
|
ExecutionStats stats(Instant from, Instant to);
|
||||||
|
|
||||||
// Per-app stats (stats_1m_app)
|
// Per-app stats (stats_1m_app)
|
||||||
ExecutionStats statsForApp(Instant from, Instant to, String groupName);
|
ExecutionStats statsForApp(Instant from, Instant to, String applicationName);
|
||||||
|
|
||||||
// Per-route stats (stats_1m_route), optionally scoped to specific agents
|
// Per-route stats (stats_1m_route), optionally scoped to specific agents
|
||||||
ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds);
|
ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds);
|
||||||
@@ -24,7 +24,7 @@ public interface StatsStore {
|
|||||||
StatsTimeseries timeseries(Instant from, Instant to, int bucketCount);
|
StatsTimeseries timeseries(Instant from, Instant to, int bucketCount);
|
||||||
|
|
||||||
// Per-app timeseries
|
// Per-app timeseries
|
||||||
StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String groupName);
|
StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName);
|
||||||
|
|
||||||
// Per-route timeseries, optionally scoped to specific agents
|
// Per-route timeseries, optionally scoped to specific agents
|
||||||
StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount,
|
StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import java.time.Instant;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record ExecutionDocument(
|
public record ExecutionDocument(
|
||||||
String executionId, String routeId, String agentId, String groupName,
|
String executionId, String routeId, String agentId, String applicationName,
|
||||||
String status, String correlationId, String exchangeId,
|
String status, String correlationId, String exchangeId,
|
||||||
Instant startTime, Instant endTime, Long durationMs,
|
Instant startTime, Instant endTime, Long durationMs,
|
||||||
String errorMessage, String errorStacktrace,
|
String errorMessage, String errorStacktrace,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class AgentRegistryServiceTest {
|
|||||||
assertThat(agent).isNotNull();
|
assertThat(agent).isNotNull();
|
||||||
assertThat(agent.id()).isEqualTo("agent-1");
|
assertThat(agent.id()).isEqualTo("agent-1");
|
||||||
assertThat(agent.name()).isEqualTo("Order Agent");
|
assertThat(agent.name()).isEqualTo("Order Agent");
|
||||||
assertThat(agent.group()).isEqualTo("order-svc");
|
assertThat(agent.application()).isEqualTo("order-svc");
|
||||||
assertThat(agent.version()).isEqualTo("1.0.0");
|
assertThat(agent.version()).isEqualTo("1.0.0");
|
||||||
assertThat(agent.routeIds()).containsExactly("route1", "route2");
|
assertThat(agent.routeIds()).containsExactly("route1", "route2");
|
||||||
assertThat(agent.capabilities()).containsEntry("feature", "tracing");
|
assertThat(agent.capabilities()).containsEntry("feature", "tracing");
|
||||||
@@ -52,7 +52,7 @@ class AgentRegistryServiceTest {
|
|||||||
|
|
||||||
assertThat(updated.id()).isEqualTo("agent-1");
|
assertThat(updated.id()).isEqualTo("agent-1");
|
||||||
assertThat(updated.name()).isEqualTo("New Name");
|
assertThat(updated.name()).isEqualTo("New Name");
|
||||||
assertThat(updated.group()).isEqualTo("new-group");
|
assertThat(updated.application()).isEqualTo("new-group");
|
||||||
assertThat(updated.version()).isEqualTo("2.0.0");
|
assertThat(updated.version()).isEqualTo("2.0.0");
|
||||||
assertThat(updated.routeIds()).containsExactly("route1", "route2");
|
assertThat(updated.routeIds()).containsExactly("route1", "route2");
|
||||||
assertThat(updated.capabilities()).containsEntry("new", "cap");
|
assertThat(updated.capabilities()).containsEntry("new", "cap");
|
||||||
|
|||||||
1204
docs/superpowers/plans/2026-03-17-rbac-crud-gaps.md
Normal file
1204
docs/superpowers/plans/2026-03-17-rbac-crud-gaps.md
Normal file
File diff suppressed because it is too large
Load Diff
2392
docs/superpowers/plans/2026-03-17-rbac-management.md
Normal file
2392
docs/superpowers/plans/2026-03-17-rbac-management.md
Normal file
File diff suppressed because it is too large
Load Diff
1625
docs/superpowers/plans/2026-03-23-ui-mock-alignment.md
Normal file
1625
docs/superpowers/plans/2026-03-23-ui-mock-alignment.md
Normal file
File diff suppressed because it is too large
Load Diff
142
docs/superpowers/specs/2026-03-17-rbac-crud-gaps-design.md
Normal file
142
docs/superpowers/specs/2026-03-17-rbac-crud-gaps-design.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# RBAC CRUD Gaps — Design Specification
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add missing CRUD and assignment UI to the RBAC management page, fix date formatting, seed a built-in Admins group, and fix dashboard diagram ordering.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Parent spec: `docs/superpowers/specs/2026-03-17-rbac-management-design.md`
|
||||||
|
- Visual prototype: `examples/RBAC/rbac_management_ui.html`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. Users Tab — Delete + Assignments
|
||||||
|
|
||||||
|
Users cannot be created manually (they arrive via login). The detail pane gains:
|
||||||
|
|
||||||
|
- **Delete button** in the detail header area. Uses existing `ConfirmDeleteDialog` with the user's `displayName` as the confirmation string. Calls `useDeleteUser()`. **Guard:** the currently authenticated user (from `useAuthStore`) cannot delete themselves — button disabled with tooltip "Cannot delete your own account".
|
||||||
|
- **Group membership section** — "+ Add" chip opens a **multi-select dropdown** listing all groups the user is NOT already a member of. Checkboxes for batch selection, "Apply" button to commit. Calls are batched via `Promise.allSettled()` — if any fail, show an inline error, invalidate queries regardless to refresh. Existing group chips gain an "x" remove button calling `useRemoveUserFromGroup()`.
|
||||||
|
- **Direct roles section** — the existing "Effective roles" section renders both direct and inherited roles. The "+ Add" multi-select dropdown lists roles not yet directly assigned. Calls `useAssignRoleToUser()` (batched via `Promise.allSettled()`). Direct role chips gain an "x" button calling `useRemoveRoleFromUser()`. Inherited role chips (dashed border) do NOT get remove buttons — they can only be removed by changing group membership or group role assignments.
|
||||||
|
- **Created field** — change from date-only to full date+time: `new Date(createdAt).toLocaleString()`.
|
||||||
|
- **Mutation button states** — all action buttons (delete, remove chip "x") disable while their mutation is in-flight to prevent double-clicks.
|
||||||
|
|
||||||
|
### 2. Groups Tab — CRUD + Assignments
|
||||||
|
|
||||||
|
- **"+ Add group" button** in the panel header (`.btnAdd` style exists). Opens an inline form below the search bar with: name text input, optional parent group dropdown, "Create" button. Calls `useCreateGroup()`. Form clears and closes on success. On error: shows error message inline.
|
||||||
|
- **Delete button** in detail pane header. Uses `ConfirmDeleteDialog` with group name. Calls `useDeleteGroup()`. Resets selected group. **Guard:** the built-in Admins group (`SystemRole.ADMINS_GROUP_ID`) cannot be deleted — button disabled with tooltip "Built-in group cannot be deleted".
|
||||||
|
- **Assigned roles section** — "+ Add" multi-select dropdown listing roles not yet assigned to this group. Batched via `Promise.allSettled()`. Calls `useAssignRoleToGroup()`. Role chips gain "x" for `useRemoveRoleFromGroup()`.
|
||||||
|
- **Parent group** — shown as a dropdown in the detail header area, allowing re-parenting. Calls `useUpdateGroup()`. The dropdown excludes the group itself and its transitive descendants (cycle prevention — requires recursive traversal of `childGroups` on each `GroupDetail`). Setting to empty/none makes it top-level.
|
||||||
|
|
||||||
|
### 3. Roles Tab — CRUD
|
||||||
|
|
||||||
|
- **"+ Add role" button** in panel header. Opens an inline form: name (required), description (optional), scope (optional, free-text, defaults to "custom"). Calls `useCreateRole()`.
|
||||||
|
- **Delete button** in detail pane header. **Disabled for system roles** (lock icon + tooltip "System roles cannot be deleted"). Custom roles use `ConfirmDeleteDialog` with role name → `useDeleteRole()`.
|
||||||
|
- No assignment UI on the roles tab — assignments are managed from the User and Group detail panes.
|
||||||
|
|
||||||
|
### 4. Multi-Select Dropdown Component
|
||||||
|
|
||||||
|
A reusable component used across all assignment actions:
|
||||||
|
|
||||||
|
```
|
||||||
|
Props:
|
||||||
|
items: { id: string; label: string }[] — available items to pick from
|
||||||
|
onApply: (selectedIds: string[]) => void — called with all checked IDs
|
||||||
|
placeholder?: string — search filter placeholder
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Opens as a positioned dropdown below the "+ Add" chip
|
||||||
|
- Search/filter input at top
|
||||||
|
- Checkbox list of items (max-height with scroll)
|
||||||
|
- "Apply" button at bottom (disabled when nothing selected)
|
||||||
|
- Closes on Apply, Escape, or click-outside
|
||||||
|
- Shows count badge on Apply button: "Apply (3)"
|
||||||
|
|
||||||
|
Styling: background `var(--bg-raised)`, border `var(--border)`, border-radius `var(--radius-md)`, items with `var(--bg-hover)` on hover, checkboxes with `var(--amber)` accent.
|
||||||
|
|
||||||
|
### 5. Inline Create Form
|
||||||
|
|
||||||
|
A reusable pattern for "Add group" and "Add role":
|
||||||
|
|
||||||
|
- Appears below the search bar in the list pane, pushing content down
|
||||||
|
- Input fields with labels
|
||||||
|
- "Create" and "Cancel" buttons
|
||||||
|
- On success: closes form, clears inputs, new entity appears in list
|
||||||
|
- On error: shows error message inline
|
||||||
|
- "Create" button disabled while mutation is in-flight
|
||||||
|
|
||||||
|
### 6. Built-in Admins Group Seed
|
||||||
|
|
||||||
|
**Database migration** — new `V2__admin_group_seed.sql` (V1 is already deployed, V2-V10 were deleted in the migration consolidation so V2 is safe):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Built-in Admins group
|
||||||
|
INSERT INTO groups (id, name) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000010', 'Admins');
|
||||||
|
|
||||||
|
-- Assign ADMIN role to Admins group
|
||||||
|
INSERT INTO group_roles (group_id, role_id) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
|
||||||
|
```
|
||||||
|
|
||||||
|
**SystemRole.java** — add constants:
|
||||||
|
```java
|
||||||
|
public static final UUID ADMINS_GROUP_ID = UUID.fromString("00000000-0000-0000-0000-000000000010");
|
||||||
|
```
|
||||||
|
|
||||||
|
**UiAuthController.login()** — after upserting the user and assigning ADMIN role, also add to Admins group:
|
||||||
|
```java
|
||||||
|
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend guard:** The Admins group UUID is hardcoded as a constant in the frontend to disable deletion. Alternatively, check if a group's ID matches a known system group ID.
|
||||||
|
|
||||||
|
### 7. Dashboard Diagram Ordering
|
||||||
|
|
||||||
|
The inheritance diagram's three columns (Groups → Roles → Users) must show items in a consistent, matching order:
|
||||||
|
|
||||||
|
- **Groups column**: alphabetical by name, children indented under parents
|
||||||
|
- **Roles column**: iterate groups top-to-bottom, collect their direct roles, deduplicate preserving first-seen order. Roles not assigned to any group are omitted from the diagram.
|
||||||
|
- **Users column**: alphabetical by display name
|
||||||
|
|
||||||
|
Sort explicitly in `DashboardTab.tsx` before rendering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
### Frontend — Modified
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `ui/src/pages/admin/rbac/UsersTab.tsx` | Delete button, group/role assignment dropdowns, date format fix, self-delete guard |
|
||||||
|
| `ui/src/pages/admin/rbac/GroupsTab.tsx` | Add group form, delete button, role assignment dropdown, parent group dropdown, Admins guard |
|
||||||
|
| `ui/src/pages/admin/rbac/RolesTab.tsx` | Add role form, delete button (disabled for system) |
|
||||||
|
| `ui/src/pages/admin/rbac/DashboardTab.tsx` | Sort diagram columns consistently |
|
||||||
|
| `ui/src/pages/admin/rbac/RbacPage.module.css` | Styles for multi-select dropdown, inline create form, delete button, action chips, remove buttons |
|
||||||
|
|
||||||
|
### Frontend — New
|
||||||
|
| File | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx` | Reusable multi-select picker with search, checkboxes, batch apply |
|
||||||
|
|
||||||
|
### Backend — Modified
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `cameleer3-server-core/.../rbac/SystemRole.java` | Add `ADMINS_GROUP_ID` constant |
|
||||||
|
| `cameleer3-server-app/.../security/UiAuthController.java` | Add admin user to Admins group on login |
|
||||||
|
|
||||||
|
### Backend — New Migration
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `cameleer3-server-app/src/main/resources/db/migration/V2__admin_group_seed.sql` | Seed Admins group + ADMIN role assignment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Editing user profile fields (name, email) — users are managed by their identity provider
|
||||||
|
- Drag-and-drop group hierarchy management
|
||||||
|
- Role permission editing (custom roles have no effect on Spring Security yet)
|
||||||
327
docs/superpowers/specs/2026-03-17-rbac-management-design.md
Normal file
327
docs/superpowers/specs/2026-03-17-rbac-management-design.md
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
# RBAC Management — Design Specification
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement a full RBAC management system (issue #41) with group hierarchy, role inheritance, and a management UI integrated into the admin section. Replace the flat `users.roles` text array with a proper relational model.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Functional spec: `examples/RBAC/rbac-ui-spec.md`
|
||||||
|
- Visual prototype: `examples/RBAC/rbac_management_ui.html`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
Squash V1–V10 Flyway migrations into a single `V1__init.sql`. The `users` table drops the `roles TEXT[]` column. New tables:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RBAC: all roles — system roles seeded with fixed UUIDs, custom roles created by admins
|
||||||
|
CREATE TABLE roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
scope TEXT NOT NULL DEFAULT 'custom',
|
||||||
|
system BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed system roles with fixed UUIDs (stable across environments)
|
||||||
|
INSERT INTO roles (id, name, description, scope, system) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000001', 'AGENT', 'Agent registration and data ingestion', 'system-wide', true),
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'VIEWER', 'Read-only access to dashboards and data', 'system-wide', true),
|
||||||
|
('00000000-0000-0000-0000-000000000003', 'OPERATOR', 'Operational commands (start/stop/configure agents)', 'system-wide', true),
|
||||||
|
('00000000-0000-0000-0000-000000000004', 'ADMIN', 'Full administrative access', 'system-wide', true);
|
||||||
|
|
||||||
|
-- RBAC: groups with self-referential hierarchy
|
||||||
|
CREATE TABLE groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
parent_group_id UUID REFERENCES groups(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Join: roles assigned to groups (system + custom)
|
||||||
|
CREATE TABLE group_roles (
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (group_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Join: direct group membership for users
|
||||||
|
CREATE TABLE user_groups (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Join: direct role assignments to users (system + custom)
|
||||||
|
CREATE TABLE user_roles (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for join query performance
|
||||||
|
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
|
||||||
|
CREATE INDEX idx_user_groups_user_id ON user_groups(user_id);
|
||||||
|
CREATE INDEX idx_group_roles_group_id ON group_roles(group_id);
|
||||||
|
CREATE INDEX idx_groups_parent ON groups(parent_group_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `roles TEXT[]` column is removed from `users`. All roles (system and custom) live in the `roles` table. System roles are seeded rows with `system = true` and fixed UUIDs — the application prevents their deletion or modification.
|
||||||
|
|
||||||
|
### System Roles
|
||||||
|
|
||||||
|
The four system roles (AGENT, VIEWER, OPERATOR, ADMIN) are:
|
||||||
|
- Seeded as rows in the `roles` table with `system = true` and fixed UUIDs
|
||||||
|
- Assigned to users via the same `user_roles` join table as custom roles
|
||||||
|
- Protected by application logic: creation, deletion, and name/scope modification are rejected
|
||||||
|
- Displayed in the UI as read-only entries (lock icon, non-deletable)
|
||||||
|
- Used by Spring Security / JWT for authorization decisions
|
||||||
|
|
||||||
|
Custom roles (`system = false`) are application-defined and have no effect on Spring Security — they serve the RBAC management model only (for future permission expansion).
|
||||||
|
|
||||||
|
The `scope` field distinguishes role domains: system roles use `system-wide`, custom roles can use descriptive scopes like `monitoring:read`, `config:write` for future permission gating.
|
||||||
|
|
||||||
|
### Domain Model (Java)
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Existing, modified — drop roles field
|
||||||
|
public record UserInfo(
|
||||||
|
String userId,
|
||||||
|
String provider,
|
||||||
|
String email,
|
||||||
|
String displayName,
|
||||||
|
Instant createdAt
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// New — enriched user for admin API responses
|
||||||
|
public record UserDetail(
|
||||||
|
String userId,
|
||||||
|
String provider,
|
||||||
|
String email,
|
||||||
|
String displayName,
|
||||||
|
Instant createdAt,
|
||||||
|
List<RoleSummary> directRoles, // from user_roles join (system + custom)
|
||||||
|
List<GroupSummary> directGroups, // from user_groups join
|
||||||
|
List<RoleSummary> effectiveRoles, // computed: union of direct + inherited via groups
|
||||||
|
List<GroupSummary> effectiveGroups // computed: direct groups + their ancestor chain
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record GroupDetail(
|
||||||
|
UUID id,
|
||||||
|
String name,
|
||||||
|
UUID parentGroupId, // nullable
|
||||||
|
Instant createdAt,
|
||||||
|
List<RoleSummary> directRoles,
|
||||||
|
List<RoleSummary> effectiveRoles, // direct + inherited from parent chain
|
||||||
|
List<UserSummary> members, // direct members
|
||||||
|
List<GroupSummary> childGroups
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record RoleDetail(
|
||||||
|
UUID id,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
String scope,
|
||||||
|
boolean system, // true for AGENT/VIEWER/OPERATOR/ADMIN
|
||||||
|
Instant createdAt,
|
||||||
|
List<GroupSummary> assignedGroups,
|
||||||
|
List<UserSummary> directUsers,
|
||||||
|
List<UserSummary> effectivePrincipals // all users who hold this role
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Summaries for embedding in detail responses
|
||||||
|
public record UserSummary(String userId, String displayName, String provider) {}
|
||||||
|
public record GroupSummary(UUID id, String name) {}
|
||||||
|
public record RoleSummary(UUID id, String name, boolean system, String source) {}
|
||||||
|
// source: "direct" | group name (for inherited)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inheritance Logic
|
||||||
|
|
||||||
|
Server-side computation in a service class (e.g., `RbacService`):
|
||||||
|
|
||||||
|
1. **Effective groups for user**: Start from `user_groups` (direct memberships), then for each group walk `parent_group_id` chain upward to collect all ancestor groups. The union is every group the user is transitively a member of.
|
||||||
|
2. **Effective roles for user**: Direct `user_roles` + all `group_roles` for every effective group. Both system and custom roles flow through the same path.
|
||||||
|
3. **Effective roles for group**: Direct `group_roles` + inherited from parent chain.
|
||||||
|
4. **Effective principals for role**: All users who hold the role directly + all users in any group that has the role (transitively).
|
||||||
|
|
||||||
|
No role negation — roles only grant, never deny.
|
||||||
|
|
||||||
|
**Cycle detection**: When setting `parent_group_id` on a group, the application must walk the proposed parent chain upward and reject the update if it would create a cycle (i.e., the group appears in its own ancestor chain). Return HTTP 409 Conflict.
|
||||||
|
|
||||||
|
### Auth Integration
|
||||||
|
|
||||||
|
`JwtService` and `SecurityConfig` read system roles from `user_roles` joined to `roles WHERE system = true`, instead of `users.roles`. The `UserRepository` methods that currently read/write `users.roles` are updated to use the join table. JWT claims remain unchanged (`roles: ["ADMIN", "VIEWER"]`).
|
||||||
|
|
||||||
|
OIDC auto-signup: When a user is auto-registered via OIDC token exchange, they get a row in `users` with `provider = "oidc:<issuer>"` and a default system role (VIEWER) via `user_roles`. No group membership by default.
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
All under `/api/v1/admin/` prefix, protected by `@PreAuthorize("hasRole('ADMIN')")`.
|
||||||
|
|
||||||
|
The existing `PUT /users/{userId}/roles` bulk endpoint is removed. Role assignments use individual add/remove endpoints.
|
||||||
|
|
||||||
|
All mutation endpoints log to the `AuditService` (category: `USER_MGMT` for user operations, `RBAC` for group/role operations).
|
||||||
|
|
||||||
|
**Users** — response type: `UserDetail`
|
||||||
|
|
||||||
|
| Method | Path | Description | Request Body |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/users` | List all users with effective roles/groups | — |
|
||||||
|
| GET | `/users/{id}` | Full user detail | — |
|
||||||
|
| POST | `/users/{id}/roles/{roleId}` | Assign role to user (system or custom) | — |
|
||||||
|
| DELETE | `/users/{id}/roles/{roleId}` | Remove role from user | — |
|
||||||
|
| POST | `/users/{id}/groups/{groupId}` | Add user to group | — |
|
||||||
|
| DELETE | `/users/{id}/groups/{groupId}` | Remove user from group | — |
|
||||||
|
| DELETE | `/users/{id}` | Delete user | — |
|
||||||
|
|
||||||
|
**Groups** — response type: `GroupDetail`
|
||||||
|
|
||||||
|
| Method | Path | Description | Request Body |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/groups` | List all groups with hierarchy | — |
|
||||||
|
| GET | `/groups/{id}` | Full group detail | — |
|
||||||
|
| POST | `/groups` | Create group | `{ name, parentGroupId? }` |
|
||||||
|
| PUT | `/groups/{id}` | Update group | `{ name?, parentGroupId? }` — returns 409 on cycle |
|
||||||
|
| DELETE | `/groups/{id}` | Delete group — cascades role/member associations; child groups become top-level (parent set to null) | — |
|
||||||
|
| POST | `/groups/{id}/roles/{roleId}` | Assign role to group | — |
|
||||||
|
| DELETE | `/groups/{id}/roles/{roleId}` | Remove role from group | — |
|
||||||
|
|
||||||
|
**Roles** — response type: `RoleDetail`
|
||||||
|
|
||||||
|
| Method | Path | Description | Request Body |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/roles` | List all roles (system + custom) | — |
|
||||||
|
| GET | `/roles/{id}` | Role detail | — |
|
||||||
|
| POST | `/roles` | Create custom role | `{ name, description?, scope? }` |
|
||||||
|
| PUT | `/roles/{id}` | Update custom role (rejects system roles) | `{ name?, description?, scope? }` |
|
||||||
|
| DELETE | `/roles/{id}` | Delete custom role (rejects system roles) | — |
|
||||||
|
|
||||||
|
**Dashboard:**
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/rbac/stats` | `{ userCount, activeUserCount, groupCount, maxGroupDepth, roleCount }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
New route at `/admin/rbac` in `router.tsx`, lazy-loaded:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const RbacPage = lazy(() => import('./pages/admin/rbac/RbacPage').then(m => ({ default: m.RbacPage })));
|
||||||
|
// ...
|
||||||
|
{ path: 'admin/rbac', element: <Suspense fallback={null}><RbacPage /></Suspense> }
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `AppSidebar` ADMIN_LINKS to add `{ to: '/admin/rbac', label: 'User Management' }`.
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pages/admin/rbac/
|
||||||
|
├── RbacPage.tsx ← ADMIN role gate + tab navigation
|
||||||
|
├── RbacPage.module.css ← All RBAC-specific styles
|
||||||
|
├── DashboardTab.tsx ← Stat cards + inheritance diagram
|
||||||
|
├── UsersTab.tsx ← Split pane orchestrator
|
||||||
|
├── GroupsTab.tsx ← Split pane orchestrator
|
||||||
|
├── RolesTab.tsx ← Split pane orchestrator
|
||||||
|
├── components/
|
||||||
|
│ ├── EntityListPane.tsx ← Reusable: search input + scrollable card list
|
||||||
|
│ ├── EntityCard.tsx ← Single list row: avatar, name, meta, tags, status dot
|
||||||
|
│ ├── UserDetail.tsx ← Header, fields, groups, effective roles, group tree
|
||||||
|
│ ├── GroupDetail.tsx ← Header, fields, members, children, roles, hierarchy
|
||||||
|
│ ├── RoleDetail.tsx ← Header, fields, assigned groups/users, effective principals
|
||||||
|
│ ├── InheritanceChip.tsx ← Chip with dashed border + "↑ Source" annotation
|
||||||
|
│ ├── GroupTree.tsx ← Indented tree with corner connectors
|
||||||
|
│ ├── EntityAvatar.tsx ← Circle (user), rounded-square (group/role), color by type
|
||||||
|
│ ├── OidcBadge.tsx ← Small badge showing OIDC provider origin
|
||||||
|
│ ├── InheritanceDiagram.tsx ← Three-column Groups→Roles→Users read-only diagram
|
||||||
|
│ └── InheritanceNote.tsx ← Green-bordered explanation block
|
||||||
|
api/queries/admin/
|
||||||
|
│ └── rbac.ts ← useUsers, useUser, useGroups, useGroup, useRoles, useRole, useRbacStats + mutation hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tab Navigation
|
||||||
|
|
||||||
|
`RbacPage` uses a horizontal tab bar (Dashboard | Users | Groups | Roles) with URL-synced active state via query parameter (`?tab=users`). Each tab renders its content below the tab bar in the full main panel area.
|
||||||
|
|
||||||
|
### Split Pane Layout
|
||||||
|
|
||||||
|
Users, Groups, and Roles tabs share the same layout:
|
||||||
|
- **Left (52%)**: `EntityListPane` with search input + scrollable entity cards
|
||||||
|
- **Right (48%)**: Detail pane showing selected entity, or empty state prompt
|
||||||
|
- Resizable via `ResizableDivider` (existing shared component)
|
||||||
|
|
||||||
|
### Entity Card Patterns
|
||||||
|
|
||||||
|
**User card:** Circle avatar (initials, blue tint) + name + email/primary-group meta + role tags (amber) + group tags (green) + status dot + OIDC badge if `provider !== "local"`
|
||||||
|
|
||||||
|
**Group card:** Rounded-square avatar (initials, green/amber/red by domain) + name + parent/member-count meta + role tags (direct solid, inherited faded+italic)
|
||||||
|
|
||||||
|
**Role card:** Rounded-square avatar (initials, amber tint) + name + description/assignment-count meta + assigned-to tags. System roles show a lock icon.
|
||||||
|
|
||||||
|
### Badge/Chip Styling
|
||||||
|
|
||||||
|
Following the spec and existing CSS token system:
|
||||||
|
|
||||||
|
| Chip type | Background | Border | Text |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Role (direct) | `var(--amber-dim)` | solid `var(--amber)` | amber text |
|
||||||
|
| Role (inherited) | transparent | dashed `var(--amber)` | faded amber, italic |
|
||||||
|
| Group | `var(--green-dim)` / `#E1F5EE` | solid green | green text |
|
||||||
|
| OIDC badge | `var(--cyan-dim)` | solid cyan | cyan text, shows provider |
|
||||||
|
| System role | Same as role but with lock icon | — | — |
|
||||||
|
|
||||||
|
Inherited role chips include `↑ GroupName` annotation in the detail pane.
|
||||||
|
|
||||||
|
### OIDC Badge
|
||||||
|
|
||||||
|
Displayed on user cards and user detail when `provider !== "local"`. Shows a small cyan-tinted pill with the provider name (e.g., "OIDC" or the issuer hostname). Positioned after the user's name in the card, and as a field in the detail pane.
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
Client-side filtering on entity list panes — filter by any visible text (name, email, group, role). Sufficient for the expected user count.
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
- React Query for all server state (users, groups, roles, stats)
|
||||||
|
- Local `useState` for selected entity, search filter, active tab
|
||||||
|
- Mutations invalidate related queries (e.g., updating a user's groups invalidates both user and group queries)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
1. Delete all V1–V10 migration files
|
||||||
|
2. Create single `V1__init.sql` containing the full consolidated schema
|
||||||
|
3. Deployed environments: drop and recreate the database (data loss accepted)
|
||||||
|
4. CI/CD: no special handling — clean database on deploy
|
||||||
|
5. Update `application.yml` if needed: `spring.flyway.clean-on-validation-error: true` or manual DB drop
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Permission-based access control (custom roles don't gate endpoints — system roles do)
|
||||||
|
- Audit log panel within RBAC (existing audit log page covers this)
|
||||||
|
- Bulk import/export of users or groups
|
||||||
|
- SCIM provisioning
|
||||||
|
- Role negation / deny rules
|
||||||
576
docs/superpowers/specs/2026-03-23-ui-mock-alignment-design.md
Normal file
576
docs/superpowers/specs/2026-03-23-ui-mock-alignment-design.md
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
# UI Mock Alignment Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-23
|
||||||
|
**Status:** Reviewed
|
||||||
|
**Scope:** Close all gaps between `@cameleer/design-system` mocks and the cameleer3-server UI
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The `@cameleer/design-system` package (v0.0.2) contains fully realized mock pages demonstrating the target UX for the Cameleer3 monitoring platform. The current server UI was built as a first pass and has significant deviations from these mocks across every page. This spec defines the work to align them.
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- Business context columns (Order ID, Customer) — not applicable to current data model
|
||||||
|
- Application log streaming — agent does not send logs; placeholder only
|
||||||
|
|
||||||
|
## 1. Backend — New Endpoints
|
||||||
|
|
||||||
|
### 1a. Processor Stats Endpoint
|
||||||
|
|
||||||
|
**`GET /api/v1/routes/metrics/processors`**
|
||||||
|
|
||||||
|
Exposes per-processor statistics. The current `stats_1m_processor` continuous aggregate groups by `(bucket, group_name, route_id, processor_type)` and lacks `processor_id`. TimescaleDB continuous aggregates cannot be ALTERed to add GROUP BY columns.
|
||||||
|
|
||||||
|
**Migration:** Add `V7__processor_stats_by_id.sql` creating a new continuous aggregate `stats_1m_processor_detail`:
|
||||||
|
```sql
|
||||||
|
CREATE MATERIALIZED VIEW stats_1m_processor_detail
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('1 minute', start_time) AS bucket,
|
||||||
|
group_name,
|
||||||
|
route_id,
|
||||||
|
processor_id,
|
||||||
|
processor_type,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||||
|
SUM(duration_ms) AS duration_sum,
|
||||||
|
MAX(duration_ms) AS duration_max,
|
||||||
|
approx_percentile(0.99, percentile_agg(duration_ms)) AS p99_duration
|
||||||
|
FROM processor_executions
|
||||||
|
GROUP BY bucket, group_name, route_id, processor_id, processor_type;
|
||||||
|
```
|
||||||
|
Leave the original `stats_1m_processor` intact (used elsewhere).
|
||||||
|
|
||||||
|
**Controller:** Add new method in existing `RouteMetricsController.java` (shares `/api/v1/routes/metrics` base path) rather than a separate controller.
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
- `routeId` (required) — filter by route
|
||||||
|
- `appId` (optional) — filter by application
|
||||||
|
- `from` / `to` (optional) — time window, defaults to last 24h
|
||||||
|
|
||||||
|
**Response:** `List<ProcessorMetrics>`
|
||||||
|
```java
|
||||||
|
record ProcessorMetrics(
|
||||||
|
String processorId, // unique processor ID within the route
|
||||||
|
String processorType, // e.g. "to", "process", "choice"
|
||||||
|
String routeId,
|
||||||
|
String appId,
|
||||||
|
long totalCount,
|
||||||
|
long failedCount,
|
||||||
|
double avgDurationMs,
|
||||||
|
double p99DurationMs,
|
||||||
|
double errorRate // failedCount / totalCount
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security:** VIEWER+ role. Already covered by existing `GET /api/v1/routes/**` wildcard in `SecurityConfig`.
|
||||||
|
|
||||||
|
### 1b. Agent Metrics Query Endpoint
|
||||||
|
|
||||||
|
**`GET /api/v1/agents/{agentId}/metrics`**
|
||||||
|
|
||||||
|
Queries the `agent_metrics` hypertable and returns time-bucketed series.
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
- `names` (required) — comma-separated metric names (e.g. `jvm.cpu.process,jvm.memory.heap.used`)
|
||||||
|
- `from` / `to` (optional) — time window, defaults to last 1h
|
||||||
|
- `buckets` (optional, default 60) — number of time buckets
|
||||||
|
|
||||||
|
**Response:** `AgentMetricsResponse`
|
||||||
|
```java
|
||||||
|
record AgentMetricsResponse(
|
||||||
|
Map<String, List<MetricBucket>> metrics
|
||||||
|
)
|
||||||
|
|
||||||
|
record MetricBucket(
|
||||||
|
Instant time,
|
||||||
|
double value // avg within bucket
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation:** Use `time_bucket()` on `agent_metrics.collected_at`, grouped by `metric_name`, averaged by `metric_value`. Filter by `agent_id` and optional `tags` if needed.
|
||||||
|
|
||||||
|
**Security:** VIEWER+ role. Requires new `SecurityConfig` rule: `GET /api/v1/agents/*/metrics` (existing `/api/v1/agents` rule is exact-match only, does not cover sub-paths).
|
||||||
|
|
||||||
|
### 1c. Enrich AgentInstanceResponse
|
||||||
|
|
||||||
|
Add fields to existing `AgentInstanceResponse`:
|
||||||
|
```java
|
||||||
|
// existing fields...
|
||||||
|
String version, // from AgentInfo.version in registry
|
||||||
|
Map<String, Object> capabilities // from AgentInfo.capabilities
|
||||||
|
```
|
||||||
|
|
||||||
|
These values are already stored in the `AgentRegistry`'s `AgentInfo` objects. The `AgentRegistrationController.listAgents()` method just needs to map them into the response DTO.
|
||||||
|
|
||||||
|
### 1d. Password Reset Endpoint
|
||||||
|
|
||||||
|
**`POST /api/v1/admin/users/{userId}/password`**
|
||||||
|
|
||||||
|
The current `UpdateUserRequest` has no password field. Add a dedicated endpoint for admin password reset.
|
||||||
|
|
||||||
|
**Request body:** `SetPasswordRequest`
|
||||||
|
```java
|
||||||
|
record SetPasswordRequest(
|
||||||
|
@NotBlank String password
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** 204 No Content
|
||||||
|
|
||||||
|
**Implementation:** Hash password with same BCrypt encoder used in `createUser`, update `users.password_hash` column.
|
||||||
|
|
||||||
|
**Security:** ADMIN role required (same as other user management endpoints).
|
||||||
|
|
||||||
|
**New files:** `cameleer3-server-app/.../dto/SetPasswordRequest.java`; new method in `UserAdminController`.
|
||||||
|
|
||||||
|
## 2. Dashboard Enhancements
|
||||||
|
|
||||||
|
### 2a. DetailPanel — Errors Section
|
||||||
|
|
||||||
|
When the selected execution has a non-null `errorMessage`:
|
||||||
|
- Insert an "Errors" section between Overview and Processors in the DetailPanel
|
||||||
|
- Display:
|
||||||
|
- Error class: parsed from `errorMessage` (text before `:` or first line)
|
||||||
|
- Error message: remainder of `errorMessage`
|
||||||
|
- Stack trace: `errorStackTrace` in a collapsible `CodeBlock` (content prop)
|
||||||
|
- Use `Alert` variant="error" for the error class/message, `Collapsible` + `CodeBlock` for the stack trace
|
||||||
|
|
||||||
|
### 2b. DetailPanel — Route Flow Tab
|
||||||
|
|
||||||
|
Add a third tab to the DetailPanel tabs: **Overview | Processors | Route Flow**
|
||||||
|
|
||||||
|
- Fetch diagram via `useDiagramByRoute(execution.groupName, execution.routeId)`
|
||||||
|
- Render `RouteFlow` component from design system
|
||||||
|
- Overlay execution data: map each `ProcessorNode` status onto diagram nodes using `diagramNodeId`
|
||||||
|
- Color nodes by status: success (green), failed (red), running (blue)
|
||||||
|
- Show duration labels on nodes
|
||||||
|
- **RouteFlow overlay API:** The `RouteFlow` component accepts execution data to color nodes. During implementation, read the `RouteFlow.tsx` source in the design system to confirm the exact props interface (likely an `overlays` or `nodeStates` prop mapping node IDs to status/duration). Map `ProcessorNode.diagramNodeId` → `PositionedNode.id` to connect execution data to diagram nodes.
|
||||||
|
|
||||||
|
### 2c. Stat Card Alignment
|
||||||
|
|
||||||
|
Change the 5 stat cards to match mock semantics:
|
||||||
|
|
||||||
|
| Position | Label | Value | Source |
|
||||||
|
|----------|-------|-------|--------|
|
||||||
|
| 1 | Throughput | exchanges/s | `totalCount / timeWindowSeconds` from stats |
|
||||||
|
| 2 | Error Rate | % | `failedCount / totalCount * 100` from stats |
|
||||||
|
| 3 | Avg Latency | ms | `avgDurationMs` from stats |
|
||||||
|
| 4 | P99 Latency | ms | `p99LatencyMs` from stats |
|
||||||
|
| 5 | In-Flight | count | `activeCount` from stats |
|
||||||
|
|
||||||
|
Each card includes:
|
||||||
|
- `Sparkline` from timeseries buckets (existing)
|
||||||
|
- Trend arrow: compare current vs `prev*` fields, show up/down indicator
|
||||||
|
|
||||||
|
## 3. Exchange Detail Enhancements
|
||||||
|
|
||||||
|
### 3a. Correlation Chain
|
||||||
|
|
||||||
|
Below the exchange header card, add a "Correlation Chain" section:
|
||||||
|
|
||||||
|
- **Data source:** `POST /search/executions` with filter `{ correlationId: execution.correlationId }`
|
||||||
|
- **Rendering:** Horizontal chain of small cards connected by arrows
|
||||||
|
- Each card: route name, `StatusDot`, duration, relative timestamp
|
||||||
|
- Current exchange highlighted
|
||||||
|
- Click navigates to that exchange (`/exchanges/:id`)
|
||||||
|
- **Conditional:** Only show section when correlationId is present and search returns > 1 result
|
||||||
|
- **Limit:** Request with `limit: 20` to prevent excessive results. If more exist, show "+N more" link
|
||||||
|
- **Hook:** `useCorrelationChain(correlationId)` — new query hook wrapping the search call
|
||||||
|
|
||||||
|
### 3b. Timeline / Flow Toggle
|
||||||
|
|
||||||
|
Above the processor timeline section:
|
||||||
|
|
||||||
|
- Add `SegmentedTabs` with options: **Timeline** | **Flow**
|
||||||
|
- **Timeline** (default): existing `ProcessorTimeline` component (Gantt view)
|
||||||
|
- **Flow**: `RouteFlow` component with execution overlay
|
||||||
|
- Fetch diagram via `useDiagramByRoute(execution.groupName, execution.routeId)` (same as 2b)
|
||||||
|
- Color nodes by processor status, show duration labels
|
||||||
|
- Clicking a processor node in either view selects it and loads its snapshot
|
||||||
|
|
||||||
|
### 3c. Header Enrichment
|
||||||
|
|
||||||
|
Add to the exchange header:
|
||||||
|
- Processor count: `execution.processors.length` (or recursive count for nested trees)
|
||||||
|
- Display as a stat in the header's right section alongside duration
|
||||||
|
|
||||||
|
## 4. Route Detail Page (NEW)
|
||||||
|
|
||||||
|
**New page** at `/routes/:appId/:routeId`. Currently this path renders a filtered `RoutesMetrics`; replace with a dedicated route detail page. The filtered table/chart view from `RoutesMetrics` is not lost — it is subsumed by the Performance and Recent Executions tabs in the new page, which provide the same data in a richer context alongside the route diagram.
|
||||||
|
|
||||||
|
Update `router.tsx`: the `/routes/:appId/:routeId` route imports a new `RouteDetail` component instead of `RoutesMetrics`. The `/routes` and `/routes/:appId` routes remain unchanged (continue to render `RoutesMetrics`).
|
||||||
|
|
||||||
|
### 4a. Route Header Card
|
||||||
|
|
||||||
|
Card displaying:
|
||||||
|
- Route name (`routeId`) and application name (`appId`)
|
||||||
|
- Health status from route catalog (`useRouteCatalog()` filtered)
|
||||||
|
- Exchange count (last 24h)
|
||||||
|
- Last seen timestamp
|
||||||
|
- Back link to `/routes/:appId`
|
||||||
|
|
||||||
|
### 4b. Route Diagram + Processor Stats (Side-by-Side)
|
||||||
|
|
||||||
|
Two-column grid:
|
||||||
|
- **Left:** `RouteFlow` component rendering the route diagram
|
||||||
|
- Data from `useDiagramByRoute(appId, routeId)` or `useDiagramLayout(contentHash)`
|
||||||
|
- **Right:** Processor stats table from new endpoint (1a)
|
||||||
|
- `DataTable` columns: Processor ID, Type, Executions, Avg Duration, P99 Duration, Error Rate
|
||||||
|
- Data from `useProcessorMetrics(routeId, appId)`
|
||||||
|
|
||||||
|
### 4c. Tabbed Section
|
||||||
|
|
||||||
|
`Tabs` component with three tabs:
|
||||||
|
|
||||||
|
**Performance tab:**
|
||||||
|
- 2x2 chart grid (same pattern as RoutesMetrics) filtered to this specific route
|
||||||
|
- Data from `useStatsTimeseries(from, to, routeId, appId)`
|
||||||
|
|
||||||
|
**Recent Executions tab:**
|
||||||
|
- `DataTable` showing recent executions for this route
|
||||||
|
- Data from `useSearchExecutions({ routeId, group: appId, limit: 20, sortField: 'startTime', sortDir: 'desc' })`
|
||||||
|
- Columns: Status, Execution ID, Duration, Start Time, Error Message
|
||||||
|
- Row click navigates to `/exchanges/:id`
|
||||||
|
|
||||||
|
**Error Patterns tab:**
|
||||||
|
- Group failed executions by `errorMessage`
|
||||||
|
- Display: error message (truncated), count, last occurrence timestamp, link to sample execution
|
||||||
|
- Data from `useSearchExecutions({ routeId, group: appId, status: 'FAILED', limit: 100 })` — client-side grouping by `errorMessage`
|
||||||
|
|
||||||
|
### 4d. CSS Module
|
||||||
|
|
||||||
|
`RouteDetail.module.css` with classes:
|
||||||
|
- `.headerCard` — surface card, padding, margin-bottom
|
||||||
|
- `.diagramStatsGrid` — 2-column grid
|
||||||
|
- `.diagramPane` / `.statsPane` — surface cards
|
||||||
|
- `.tabSection` — margin-top
|
||||||
|
- `.chartGrid` — 2x2 grid (reuse pattern from RoutesMetrics)
|
||||||
|
|
||||||
|
## 5. Agent Health Enhancements
|
||||||
|
|
||||||
|
### 5a. DetailPanel (Slide-In)
|
||||||
|
|
||||||
|
Add a `DetailPanel` from the design system, triggered by clicking an instance row in a GroupCard.
|
||||||
|
|
||||||
|
**Overview tab:**
|
||||||
|
- Status with `StatusDot` and `Badge`
|
||||||
|
- Application name, version (from enriched AgentInstanceResponse, section 1c)
|
||||||
|
- Uptime (formatted), last heartbeat (relative time)
|
||||||
|
- TPS, error rate
|
||||||
|
- Active routes / total routes
|
||||||
|
- Memory usage: `ProgressBar` — data from `GET /agents/{id}/metrics?names=jvm.memory.heap.used,jvm.memory.heap.max&buckets=1` (single latest bucket gives the most recent averaged value)
|
||||||
|
- CPU usage: `ProgressBar` — data from `GET /agents/{id}/metrics?names=jvm.cpu.process&buckets=1` (single latest bucket)
|
||||||
|
|
||||||
|
**Performance tab:**
|
||||||
|
- Two `LineChart` components:
|
||||||
|
- Throughput over time (from timeseries stats filtered by agentId, or from agent metrics)
|
||||||
|
- Error rate over time
|
||||||
|
|
||||||
|
### 5b. Instance Table Enrichment
|
||||||
|
|
||||||
|
Add columns to the instance rows within each `GroupCard`:
|
||||||
|
|
||||||
|
| Column | Source |
|
||||||
|
|--------|--------|
|
||||||
|
| Status dot | `agent.status` (existing) |
|
||||||
|
| Instance name | `agent.name` (existing) |
|
||||||
|
| State badge | `agent.status` (existing) |
|
||||||
|
| Uptime | `agent.uptimeSeconds` → formatted "2d 4h" / "15m" |
|
||||||
|
| TPS | `agent.tps` (existing) |
|
||||||
|
| Error rate | `agent.errorRate` → percentage |
|
||||||
|
| Last heartbeat | `agent.lastHeartbeat` → relative "2m ago" |
|
||||||
|
| Link | Icon button → `/agents/:appId/:instanceId` |
|
||||||
|
|
||||||
|
### 5c. Alert Banners
|
||||||
|
|
||||||
|
When a `GroupCard` contains instances with status `DEAD`:
|
||||||
|
- Show `Alert` variant="error" at the top of the card body
|
||||||
|
- Message: `"N instance(s) unreachable"` where N is the count of DEAD instances
|
||||||
|
|
||||||
|
### 5d. Stat Card Alignment
|
||||||
|
|
||||||
|
Replace current 4 cards (Total, Live, Stale, Dead) with 5 cards matching mock:
|
||||||
|
|
||||||
|
| Label | Value | Accent |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| Total Agents | count (subtitle: "N live / N stale / N dead") | default |
|
||||||
|
| Applications | count of unique appIds | default |
|
||||||
|
| Active Routes | sum of activeRoutes across live agents | default |
|
||||||
|
| Total TPS | sum of tps across live agents | default |
|
||||||
|
| Dead | count of dead agents | error |
|
||||||
|
|
||||||
|
### 5e. Scope Trail
|
||||||
|
|
||||||
|
Add breadcrumb below stat cards:
|
||||||
|
- All agents view: `Agents` with live `Badge` showing "N live"
|
||||||
|
- Filtered by app: `Agents` > `{appName}` with health `Badge` (live/stale/dead color)
|
||||||
|
|
||||||
|
## 6. Agent Instance Enhancements
|
||||||
|
|
||||||
|
### 6a. JVM Metrics Charts (3x2 Grid)
|
||||||
|
|
||||||
|
Replace current 2-column chart grid with 3x2 grid. All data from new endpoint (1b).
|
||||||
|
|
||||||
|
| Chart | Type | Metric Name(s) |
|
||||||
|
|-------|------|----------------|
|
||||||
|
| CPU Usage | AreaChart | `jvm.cpu.process` (0-1 scale, display as %) |
|
||||||
|
| Memory (Heap) | AreaChart | `jvm.memory.heap.used` + `jvm.memory.heap.max` (two series) |
|
||||||
|
| Throughput | AreaChart | from `useStatsTimeseries` filtered by agent (existing) |
|
||||||
|
| Error Rate | LineChart | from `useStatsTimeseries` filtered by agent (existing) |
|
||||||
|
| Thread Count | LineChart | `jvm.threads.count` |
|
||||||
|
| GC Pauses | BarChart | `jvm.gc.time` |
|
||||||
|
|
||||||
|
**Hook:** `useAgentMetrics(agentId, metricNames[], from, to, buckets)` — wraps endpoint 1b.
|
||||||
|
|
||||||
|
### 6b. Process Information Card
|
||||||
|
|
||||||
|
Card with key-value pairs:
|
||||||
|
|
||||||
|
| Key | Source |
|
||||||
|
|-----|--------|
|
||||||
|
| JVM Version | `agent.capabilities.jvmVersion` or parse from registration |
|
||||||
|
| Camel Version | `agent.capabilities.camelVersion` |
|
||||||
|
| Spring Boot | `agent.capabilities.springBootVersion` |
|
||||||
|
| Started | `agent.registeredAt` formatted |
|
||||||
|
| Capabilities | render as tags: tracing, metrics, diagrams, replay |
|
||||||
|
|
||||||
|
Data from enriched `AgentInstanceResponse` (section 1c). If version details aren't in current capabilities, they can be added to agent registration in a future iteration — show what's available.
|
||||||
|
|
||||||
|
### 6c. Stat Card Alignment
|
||||||
|
|
||||||
|
Replace current 4 cards with 5 cards matching mock:
|
||||||
|
|
||||||
|
| Label | Value | Source |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| CPU | % | latest `jvm.cpu.process` from agent metrics |
|
||||||
|
| Memory | % | latest `heap.used / heap.max * 100` |
|
||||||
|
| Throughput | req/s | `agent.tps` |
|
||||||
|
| Errors | % | `agent.errorRate` |
|
||||||
|
| Uptime | formatted | `agent.uptimeSeconds` |
|
||||||
|
|
||||||
|
CPU and Memory require a small fetch from endpoint 1b (latest single value).
|
||||||
|
|
||||||
|
### 6d. Application Log Placeholder
|
||||||
|
|
||||||
|
Below the EventFeed card, add an `EmptyState` component:
|
||||||
|
- Title: "Application Logs"
|
||||||
|
- Description: "Application log streaming is not yet available"
|
||||||
|
- No action button
|
||||||
|
|
||||||
|
### 6e. Version Badge in Scope Trail
|
||||||
|
|
||||||
|
Breadcrumb: `Agents` > `{appName}` > `{instanceName}`
|
||||||
|
- Add `Badge` next to instance name showing version (from enriched response)
|
||||||
|
- Add `StatusDot` + status `Badge` for visual state
|
||||||
|
|
||||||
|
## 7. Admin & Miscellaneous
|
||||||
|
|
||||||
|
### 7a. OIDC Config — Default Roles
|
||||||
|
|
||||||
|
Add a "Default Roles" section to the OIDC config page:
|
||||||
|
- Display current default roles as `Tag` components (removable, click X to remove)
|
||||||
|
- `Input` + "Add" `Button` to add a role
|
||||||
|
- Validate against existing roles from `useRoles()` query
|
||||||
|
- Persist via existing OIDC config save endpoint
|
||||||
|
|
||||||
|
### 7b. OIDC Config — ConfirmDialog on Delete
|
||||||
|
|
||||||
|
Replace direct delete button with `ConfirmDialog`:
|
||||||
|
- Message: "Delete OIDC configuration? All OIDC users will lose access."
|
||||||
|
- Require typing "DELETE" to confirm
|
||||||
|
|
||||||
|
### 7c. Design System Update
|
||||||
|
|
||||||
|
Update `@cameleer/design-system` from `^0.0.1` to `^0.0.2` in `ui/package.json`.
|
||||||
|
|
||||||
|
**TopBar `onLogout` prop:** Replace the custom `Dropdown` + `Avatar` logout hack in `LayoutShell.tsx` with the TopBar's new `onLogout` prop:
|
||||||
|
```tsx
|
||||||
|
<TopBar
|
||||||
|
breadcrumb={breadcrumb}
|
||||||
|
user={{ name: username }}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the manual Avatar/Dropdown logout code.
|
||||||
|
|
||||||
|
**Verification needed during implementation:** Confirm that the TopBar v0.0.2 renders a user avatar/menu internally when `user` + `onLogout` are provided. If it only renders a bare logout button without the "Signed in as" display, keep the custom Avatar/Dropdown and just wire up the TopBar's `onLogout` as an additional trigger.
|
||||||
|
|
||||||
|
### 7d. Regenerate schema.d.ts
|
||||||
|
|
||||||
|
After backend endpoints are added, regenerate types from the running server:
|
||||||
|
```bash
|
||||||
|
npm run generate-api:live
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures all new DTOs (`ProcessorMetrics`, `AgentMetricsResponse`, `MetricBucket`, enriched `AgentInstanceResponse`) are accurately typed.
|
||||||
|
|
||||||
|
## 8. RBAC / User Management Overhaul
|
||||||
|
|
||||||
|
The current RBAC page is a basic DataTable + Modal CRUD interface. The design system mock implements a split-pane detail-oriented admin panel with rich interactions. This section describes the full rebuild.
|
||||||
|
|
||||||
|
### 8a. Layout — Split-Pane Replaces DataTable
|
||||||
|
|
||||||
|
All three tabs (Users, Groups, Roles) adopt the same layout pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┬────────────────────┐
|
||||||
|
│ List Pane (52%) │ Detail Pane (48%) │
|
||||||
|
│ │ │
|
||||||
|
│ [Search input] │ [Selected entity │
|
||||||
|
│ [+ Create button] │ detail view] │
|
||||||
|
│ │ │
|
||||||
|
│ [Inline create form]│ │
|
||||||
|
│ │ │
|
||||||
|
│ [Scrollable entity │ │
|
||||||
|
│ list with avatars, │ │
|
||||||
|
│ badges, tags] │ │
|
||||||
|
│ │ │
|
||||||
|
└─────────────────────┴────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**New file:** `ui/src/pages/Admin/UserManagement.module.css`
|
||||||
|
- `.splitPane` — CSS grid `52fr 48fr`, full height
|
||||||
|
- `.listPane` — scrollable, border-right
|
||||||
|
- `.detailPane` — scrollable, padding
|
||||||
|
- `.entityItem` / `.entityItemSelected` — list items with hover/selected states
|
||||||
|
- `.entityInfo`, `.entityName`, `.entityMeta`, `.entityTags` — list item layout
|
||||||
|
- `.createForm`, `.createFormActions` — inline form styling
|
||||||
|
- `.metaGrid` — key-value metadata layout
|
||||||
|
- `.sectionTags` — tag group with wrap
|
||||||
|
- `.inheritedNote` — small italic annotation text
|
||||||
|
- `.securitySection` / `.resetForm` — password management styling
|
||||||
|
|
||||||
|
**Keep existing stat cards** above tabs — these are a useful addition not present in the mock.
|
||||||
|
|
||||||
|
### 8b. Users Tab
|
||||||
|
|
||||||
|
**List pane:**
|
||||||
|
- **Search:** `Input` with search icon, filters across username, displayName, email (client-side)
|
||||||
|
- **Create button:** "+ Add User" opens inline form (not modal)
|
||||||
|
- **Inline create form:**
|
||||||
|
- `Input`: Username (required), Display Name, Email
|
||||||
|
- `Input`: Password (required)
|
||||||
|
- Client-side validation: duplicate username check, required fields
|
||||||
|
- Cancel + Create buttons
|
||||||
|
- **Note:** Admin-created users are always local. OIDC users are auto-provisioned on first login (no admin creation needed). The create form does not include a provider selector.
|
||||||
|
- **Entity list:** `role="listbox"`, each item `role="option"` with `tabIndex={0}`
|
||||||
|
- `Avatar` (initials, size sm)
|
||||||
|
- Display name + provider `Badge` (if not local)
|
||||||
|
- Email + group path in meta line
|
||||||
|
- Direct roles and groups as small `Badge` tags
|
||||||
|
- Click or Enter/Space to select → populates detail pane
|
||||||
|
|
||||||
|
**Detail pane (when user selected):**
|
||||||
|
- **Header:** `Avatar` (lg) + Display name (`InlineEdit` for rename) + Email + Delete button
|
||||||
|
- **Status:** "Active" `Tag`
|
||||||
|
- **Metadata grid:** User ID (`MonoText`), Created (formatted date+time), Provider
|
||||||
|
- **Security section:**
|
||||||
|
- Local users: masked password display + "Reset password" button → toggles inline form (new password `Input` + Cancel/Set)
|
||||||
|
- OIDC users: `InfoCallout` "Password managed by identity provider"
|
||||||
|
- **Group membership:**
|
||||||
|
- Current groups as removable `Tag` components
|
||||||
|
- `MultiSelect` dropdown to add groups
|
||||||
|
- Warning on removal if inherited roles would be revoked
|
||||||
|
- **Effective roles:**
|
||||||
|
- Direct roles: removable `Tag` (warning color)
|
||||||
|
- Inherited roles: dashed `Badge` with "↑ groupName" source notation (opacity 0.65, non-removable)
|
||||||
|
- `MultiSelect` to add direct roles
|
||||||
|
- Note: "Roles with ↑ are inherited through group membership"
|
||||||
|
- **Delete:** `ConfirmDialog` requiring username to be typed. Self-delete guard (can't delete own account).
|
||||||
|
|
||||||
|
**API hooks used:** `useUsers`, `useUser`, `useCreateUser`, `useUpdateUser`, `useDeleteUser`, `useAssignRoleToUser`, `useRemoveRoleFromUser`, `useAddUserToGroup`, `useRemoveUserFromGroup`, `useGroups`, `useRoles`
|
||||||
|
|
||||||
|
### 8c. Groups Tab
|
||||||
|
|
||||||
|
**List pane:**
|
||||||
|
- **Search:** filter by group name
|
||||||
|
- **Create form:** inline with name + parent group `Select` dropdown (options: "Top-level" + all existing groups)
|
||||||
|
- **Entity list:**
|
||||||
|
- `Avatar` + group name
|
||||||
|
- Meta: "Child of {parent}" or "Top-level" + child count + member count
|
||||||
|
- Role tags
|
||||||
|
|
||||||
|
**Detail pane:**
|
||||||
|
- **Header:** Group name (`InlineEdit` for non-built-in) + parent info + Delete button (disabled for built-in Admins group)
|
||||||
|
- **Metadata:** Group ID (`MonoText`)
|
||||||
|
- **Parent group:** display current parent
|
||||||
|
- **Members:** removable `Tag` list + `MultiSelect` to add users. Note: "+ all members of child groups" if applicable
|
||||||
|
- **Child groups:** removable `Tag` list + `MultiSelect` to add existing groups as children. Circular reference prevention (can't add ancestor as child)
|
||||||
|
- **Assigned roles:** removable `Tag` list + `MultiSelect` to add roles. Warning on removal: "Removing {role} from {group} will affect N member(s). Continue?"
|
||||||
|
- **Delete:** `ConfirmDialog`. Guard: built-in Admins group cannot be deleted.
|
||||||
|
|
||||||
|
**API hooks used:** `useGroups`, `useGroup`, `useCreateGroup`, `useUpdateGroup`, `useDeleteGroup`, `useAssignRoleToGroup`, `useRemoveRoleFromGroup`
|
||||||
|
|
||||||
|
### 8d. Roles Tab
|
||||||
|
|
||||||
|
**List pane:**
|
||||||
|
- **Search:** filter by role name
|
||||||
|
- **Create form:** inline with name (auto-uppercase) + description
|
||||||
|
- **Entity list:**
|
||||||
|
- `Avatar` + role name + "system" `Badge` (if system role)
|
||||||
|
- Meta: description + assignment count
|
||||||
|
- Tags: assigned groups (success color) + direct users
|
||||||
|
|
||||||
|
**Detail pane:**
|
||||||
|
- **Header:** Role name + description + Delete button (disabled for system roles)
|
||||||
|
- **Metadata:** Role ID (`MonoText`), scope, type (system/custom — "System role (read-only)")
|
||||||
|
- **Assigned to groups:** view-only `Tag` list (shows which groups have this role)
|
||||||
|
- **Assigned to users (direct):** view-only `Tag` list
|
||||||
|
- **Effective principals:** filled `Badge` (direct assignment) + dashed `Badge` (inherited via group). Note: "Dashed entries inherit this role through group membership"
|
||||||
|
|
||||||
|
**API hooks used:** `useRoles`, `useRole`, `useCreateRole`, `useUpdateRole`, `useDeleteRole`
|
||||||
|
|
||||||
|
### 8e. Shared Patterns
|
||||||
|
|
||||||
|
- **Toast notifications** for all mutations (create, update, delete, assign, remove) — use `useToast` from design system
|
||||||
|
- **Cascade warnings** when actions affect other entities (removing role from group, removing user from group with roles)
|
||||||
|
- **Keyboard accessibility:** Enter/Space to select, ARIA roles (`listbox`, `option`), `aria-selected`
|
||||||
|
- **Mutation button states:** disable while in-flight, show spinner
|
||||||
|
- **ToastProvider:** Add `ToastProvider` from design system to `LayoutShell.tsx` (or app root in `main.tsx`) to enable `useToast()` hook across admin pages
|
||||||
|
- **Graceful empty states:** When agent metrics are unavailable (agent not sending a particular metric), show per-chart empty state rather than crashing. Check metric name existence in response before rendering.
|
||||||
|
|
||||||
|
## File Impact Summary
|
||||||
|
|
||||||
|
### New files:
|
||||||
|
- `ui/src/pages/Routes/RouteDetail.tsx` + `RouteDetail.module.css`
|
||||||
|
- `ui/src/pages/Admin/UserManagement.module.css`
|
||||||
|
- `ui/src/pages/Admin/UsersTab.tsx`
|
||||||
|
- `ui/src/pages/Admin/GroupsTab.tsx`
|
||||||
|
- `ui/src/pages/Admin/RolesTab.tsx`
|
||||||
|
- `ui/src/api/queries/agent-metrics.ts` (useAgentMetrics hook)
|
||||||
|
- `ui/src/api/queries/processor-metrics.ts` (useProcessorMetrics hook)
|
||||||
|
- `ui/src/api/queries/correlation.ts` (useCorrelationChain hook)
|
||||||
|
- `cameleer3-server-app/.../controller/AgentMetricsController.java`
|
||||||
|
- `cameleer3-server-app/.../dto/ProcessorMetrics.java`
|
||||||
|
- `cameleer3-server-app/.../dto/AgentMetricsResponse.java`
|
||||||
|
- `cameleer3-server-app/.../dto/MetricBucket.java`
|
||||||
|
- `cameleer3-server-app/.../dto/SetPasswordRequest.java`
|
||||||
|
- `cameleer3-server-app/src/main/resources/db/migration/V7__processor_stats_by_id.sql`
|
||||||
|
|
||||||
|
### Modified files:
|
||||||
|
- `ui/package.json` — design system `^0.0.2`
|
||||||
|
- `ui/src/router.tsx` — add RouteDetail route
|
||||||
|
- `ui/src/components/LayoutShell.tsx` — TopBar `onLogout` prop, remove Dropdown/Avatar
|
||||||
|
- `ui/src/pages/Dashboard/Dashboard.tsx` — error section, RouteFlow tab, stat card changes
|
||||||
|
- `ui/src/pages/Dashboard/Dashboard.module.css` — new classes
|
||||||
|
- `ui/src/pages/ExchangeDetail/ExchangeDetail.tsx` — correlation chain, flow toggle, processor count
|
||||||
|
- `ui/src/pages/ExchangeDetail/ExchangeDetail.module.css` — new classes
|
||||||
|
- `ui/src/pages/Routes/RoutesMetrics.tsx` — stat card adjustments
|
||||||
|
- `ui/src/pages/AgentHealth/AgentHealth.tsx` — DetailPanel, table enrichment, alert banners, stat cards, scope trail
|
||||||
|
- `ui/src/pages/AgentHealth/AgentHealth.module.css` — new classes
|
||||||
|
- `ui/src/pages/AgentInstance/AgentInstance.tsx` — 3x2 charts, process info, stat cards, log placeholder, version badge
|
||||||
|
- `ui/src/pages/AgentInstance/AgentInstance.module.css` — new classes
|
||||||
|
- `ui/src/pages/Admin/RbacPage.tsx` — restructured to container with split-pane tabs
|
||||||
|
- `ui/src/pages/Admin/OidcConfigPage.tsx` — default roles, ConfirmDialog
|
||||||
|
- `ui/src/api/schema.d.ts` — regenerated with new types
|
||||||
|
- `cameleer3-server-app/.../dto/AgentInstanceResponse.java` — add version, capabilities
|
||||||
|
- `cameleer3-server-app/.../controller/AgentRegistrationController.java` — map version/capabilities
|
||||||
|
- `cameleer3-server-app/.../controller/RouteMetricsController.java` — add processor stats method
|
||||||
|
- `cameleer3-server-app/.../controller/UserAdminController.java` — add password reset method
|
||||||
|
- `cameleer3-server-app/.../SecurityConfig.java` — add rule for `GET /api/v1/agents/*/metrics`
|
||||||
|
- `ui/src/main.tsx` or `ui/src/components/LayoutShell.tsx` — add `ToastProvider`
|
||||||
|
- `cameleer3-server-app/.../OpenApiConfig.java` — register new DTOs
|
||||||
|
|
||||||
|
### Backend migration:
|
||||||
|
- `V7__processor_stats_by_id.sql` — new `stats_1m_processor_detail` continuous aggregate with `processor_id` grouping
|
||||||
261
docs/ui-mocks/camel-developer-review.md
Normal file
261
docs/ui-mocks/camel-developer-review.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
# Cameleer3 Dashboard Review -- Senior Camel Developer Perspective
|
||||||
|
|
||||||
|
**Reviewer**: Senior Apache Camel Developer (10+ years, Java DSL / Spring Boot)
|
||||||
|
**Artifact reviewed**: `mock-v2-light.html` -- Operations Dashboard (v2 synthesis)
|
||||||
|
**Date**: 2026-03-17
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What the Dashboard Gets RIGHT
|
||||||
|
|
||||||
|
### Business ID as First-Class Citizen
|
||||||
|
The Order ID and Customer columns in the execution table are exactly what I need. When support calls me about "order OP-88421", I can paste that into the search and find the execution immediately. Every other monitoring tool I have used forces me to map business IDs to correlation IDs manually. This alone would save me 10-15 minutes per incident.
|
||||||
|
|
||||||
|
### Inline Error Previews
|
||||||
|
Showing the exception message directly in the table row without requiring a click-through is genuinely useful. The two error examples in the mock (`HttpOperationFailedException` with a 504, `SQLTransientConnectionException` with HikariPool exhaustion) are realistic Camel exceptions. I can scan the error list and immediately tell whether it is a downstream timeout or a connection pool issue. That distinction determines whether I investigate our code or page the DBA.
|
||||||
|
|
||||||
|
### Processor Timeline (Gantt View)
|
||||||
|
The processor timeline in the detail panel is the single most valuable feature. Seeing that `to(payment-api)` consumed 280ms out of a 412ms total execution, while `enrich(inventory)` took 85ms, immediately tells me WHERE the bottleneck is. In my experience, 95% of Camel performance issues are in external calls, and this view pinpoints them. The color coding (green/yellow/red) for processor bars makes the slow step obvious at a glance.
|
||||||
|
|
||||||
|
### SLA Awareness Baked In
|
||||||
|
The SLA threshold line on the latency chart, the "SLA" tag on slow durations, and the "CLOSE" warning on the p99 card are exactly the kind of proactive indicators I want. Most monitoring tools show me raw numbers; this dashboard shows me numbers in context. I know immediately that 287ms p99 is dangerously close to our 300ms SLA.
|
||||||
|
|
||||||
|
### Shift-Aware Time Context
|
||||||
|
The "since 06:00" shift concept is something I have never seen in a developer tool but actually matches how production support works. When I start my day shift, I want to see what happened overnight and what is happening now, not a rolling 24-hour window that mixes yesterday afternoon with this morning.
|
||||||
|
|
||||||
|
### Agent Health in Sidebar
|
||||||
|
Seeing agent status (live/stale/dead), throughput per agent, and error rates at a glance in the sidebar is practical. When an agent goes stale, I know to check if a pod restarted or if there is a network partition.
|
||||||
|
|
||||||
|
### Application-to-Route Navigation Hierarchy
|
||||||
|
The sidebar tree (Applications > order-service > Routes > order-intake, order-enrichment, etc.) matches how I think about Camel deployments. I have multiple applications, each with multiple routes. Being able to filter by application first, then drill into routes, is the right hierarchy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. What is MISSING or Could Be Better
|
||||||
|
|
||||||
|
### 2.1 Exchange Body/Header Inspection -- CRITICAL GAP
|
||||||
|
|
||||||
|
**Pain point**: The "Exchange" tab exists in the detail panel tabs but its content is not shown. This is the single most important debugging feature for a Camel developer. When a message fails at step 5 of 7, I need to see:
|
||||||
|
- What was the original inbound message (before any transformation)?
|
||||||
|
- What did the exchange body look like at each processor step?
|
||||||
|
- Which headers were present at each step, and which were added/removed?
|
||||||
|
- What was the exception body (often different from the exception message)?
|
||||||
|
|
||||||
|
**How to address it**: The Exchange tab should show a step-by-step diff view of the exchange. For each processor in the route, show the body (with a JSON/XML pretty-printer) and the headers map. Highlight headers that were added at that step. Allow comparing any two steps side-by-side. Show the original inbound message prominently at the top.
|
||||||
|
|
||||||
|
**Priority**: **Must-Have**. Without this, the dashboard is an operations monitor, not a debugging tool. This is the difference between "I can see something failed" and "I can see WHY it failed."
|
||||||
|
|
||||||
|
### 2.2 Route Diagram / Visual Graph -- MENTIONED BUT NOT SHOWN
|
||||||
|
|
||||||
|
**Pain point**: The "View Route Diagram" button exists in the detail actions, but there is no mockup of what the route diagram looks like. As a Camel developer, I need to see the DAG (directed acyclic graph) of my route: from(jms:orders) -> unmarshal -> validate -> choice -> [branch A: enrich -> transform -> to(http)] [branch B: log -> to(dlq)]. I also need to see execution overlay on the diagram -- which path did THIS specific exchange take, and how long did each node take.
|
||||||
|
|
||||||
|
**How to address it**: Add a Route Diagram page/view that shows:
|
||||||
|
- The route definition as an interactive DAG (nodes = processors, edges = flow)
|
||||||
|
- Execution overlay: color-code each node by success/failure for a specific execution
|
||||||
|
- Aggregate overlay: color-code each node by throughput/error rate over a time window
|
||||||
|
- Highlight the path taken by the selected exchange (dim the branches not taken)
|
||||||
|
- Show inter-route connections (e.g., `direct:`, `seda:`, `vm:` endpoints linking routes)
|
||||||
|
|
||||||
|
**Priority**: **Must-Have**. Cameleer already has `RouteGraph` data from agents -- this is the tool's differentiating feature.
|
||||||
|
|
||||||
|
### 2.3 Cross-Route Correlation / Message Tracing
|
||||||
|
|
||||||
|
**Pain point**: A single business transaction (e.g., an order) often spans multiple routes: `order-intake` -> `order-enrichment` -> `payment-process` -> `shipment-dispatch`. The dashboard shows each route execution as a separate row. There is no way to see the full journey of order OP-88421 across all routes.
|
||||||
|
|
||||||
|
**How to address it**: Add a "Transaction Trace" or "Message Flow" view that:
|
||||||
|
- Groups all executions sharing a breadcrumbId or correlation ID
|
||||||
|
- Shows them as a horizontal timeline or waterfall chart
|
||||||
|
- Highlights which route in the chain failed
|
||||||
|
- Works across `direct:`, `seda:`, and `vm:` endpoints that link routes
|
||||||
|
|
||||||
|
The search bar says "Search by Order ID, correlation ID" which is a good start, but the results should show the correlated group, not just individual rows.
|
||||||
|
|
||||||
|
**Priority**: **Must-Have**. Splitter/aggregator patterns and multi-route flows are the norm, not the exception, in real Camel applications.
|
||||||
|
|
||||||
|
### 2.4 Dead Letter Queue Monitoring
|
||||||
|
|
||||||
|
**Pain point**: When messages fail and are routed to a dead letter channel (which is the standard Camel error handling pattern), I need to know: how many messages are in the DLQ, what are they, how long have they been there, and can I retry them?
|
||||||
|
|
||||||
|
**How to address it**: Add a DLQ section or page showing:
|
||||||
|
- Count of messages per dead letter endpoint
|
||||||
|
- Age distribution (how many are from today vs. last week)
|
||||||
|
- Message preview (body + headers + the exception that caused routing to DLQ)
|
||||||
|
- Retry action (re-submit the message to the original route)
|
||||||
|
- Purge action (acknowledge and discard)
|
||||||
|
|
||||||
|
**Priority**: **Must-Have**. DLQ management is a daily production task.
|
||||||
|
|
||||||
|
### 2.5 Per-Processor Statistics (Aggregate View)
|
||||||
|
|
||||||
|
**Pain point**: The processor timeline in the detail panel shows per-processor timing for a single execution. But I also need aggregate statistics: for processor `to(payment-api)`, what is the p50/p95/p99 latency over the last hour? How many times did it fail? Is it getting slower over time?
|
||||||
|
|
||||||
|
**How to address it**: Clicking a processor name in the timeline should show aggregate stats for that processor. Alternatively, the Route Detail page should have a "Processors" tab with a table of all processors in the route, their call count, success rate, and latency percentiles.
|
||||||
|
|
||||||
|
**Priority**: **Must-Have**. Identifying a chronically slow processor is different from identifying a one-off slow execution.
|
||||||
|
|
||||||
|
### 2.6 Error Pattern Grouping / Top Errors
|
||||||
|
|
||||||
|
**Pain point**: The dashboard shows individual error rows. When there are 38 errors, I do not want to scroll through all 38. I want to see: "23 of the 38 errors are `HttpOperationFailedException` on `payment-process`, 10 are `SQLTransientConnectionException` on `order-enrichment`, 5 are `ValidationException` on `order-intake`." The design notes mention "Top error pattern grouping panel" from the operator expert, but it is not in the final mock.
|
||||||
|
|
||||||
|
**How to address it**: Add an error summary panel above or alongside the execution table showing errors grouped by exception class + route. Each group should show count, first/last occurrence, and whether the count is trending up.
|
||||||
|
|
||||||
|
**Priority**: **Must-Have**. Pattern recognition is more important than individual error viewing.
|
||||||
|
|
||||||
|
### 2.7 Route Status Management
|
||||||
|
|
||||||
|
**Pain point**: I need to know which routes are started, stopped, or suspended. And I need the ability to stop/start/suspend individual routes without redeploying. This is routine in production -- temporarily suspending a route that is flooding a downstream system.
|
||||||
|
|
||||||
|
**How to address it**: The sidebar route list should show route status (started/stopped/suspended) with icons. Right-click or action menu on a route should offer start/stop/suspend. This maps directly to Camel's route controller API.
|
||||||
|
|
||||||
|
**Priority**: **Nice-to-Have** for v1, **Must-Have** for v2. Operators will ask for this quickly.
|
||||||
|
|
||||||
|
### 2.8 Route Version Comparison
|
||||||
|
|
||||||
|
**Pain point**: After a deployment, I want to compare the current route definition with the previous version. Did someone add a processor? Change an endpoint URI? Route definition drift is a real source of production issues.
|
||||||
|
|
||||||
|
**How to address it**: Store route graph snapshots per deployment/version. Show a diff view highlighting added/removed/modified processors.
|
||||||
|
|
||||||
|
**Priority**: **Nice-to-Have**. Valuable but less urgent than the above.
|
||||||
|
|
||||||
|
### 2.9 Thread Pool / Resource Monitoring
|
||||||
|
|
||||||
|
**Pain point**: Camel's default thread pool max is 20. When all threads are consumed, messages queue up silently. The HikariPool error in the mock is a perfect example -- pool exhaustion. I need visibility into thread pool utilization, connection pool utilization, and inflight exchange count.
|
||||||
|
|
||||||
|
**How to address it**: Add a "Resources" section (either in the agent detail or a separate page) showing:
|
||||||
|
- Camel thread pool utilization (active/max)
|
||||||
|
- Connection pool utilization (from endpoint components)
|
||||||
|
- Inflight exchange count per route
|
||||||
|
- Consumer prefetch/backlog (for JMS/Kafka consumers)
|
||||||
|
|
||||||
|
**Priority**: **Nice-to-Have** initially, but becomes **Must-Have** when debugging pool exhaustion issues.
|
||||||
|
|
||||||
|
### 2.10 Saved Searches / Alert Rules
|
||||||
|
|
||||||
|
**Pain point**: I find myself searching for the same patterns repeatedly: "errors on payment-process in the last hour", "executions over 500ms for order-enrichment". There is no way to save these as bookmarks or convert them into alert rules.
|
||||||
|
|
||||||
|
**How to address it**: Allow saving filter configurations as named views. Allow converting a saved search into an alerting rule (email/webhook when count exceeds threshold).
|
||||||
|
|
||||||
|
**Priority**: **Nice-to-Have**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Specific Page/Feature Recommendations
|
||||||
|
|
||||||
|
### 3.1 Route Detail Page
|
||||||
|
|
||||||
|
When I click a route name (e.g., `order-intake`) from the sidebar, I should see:
|
||||||
|
|
||||||
|
- **Header**: Route name, status (started/stopped), uptime, route definition source (Java DSL / XML / YAML)
|
||||||
|
- **KPI Strip**: Total executions, success rate, p50/p99 latency, inflight count, throughput -- all for this route only
|
||||||
|
- **Processor Table**: Every processor in the route with columns: name, type, call count, success rate, p50 latency, p99 latency, total time %. Sortable by any column. This is where I find the bottleneck processor.
|
||||||
|
- **Route Diagram**: Interactive DAG with execution overlay. Nodes sized by throughput, colored by error rate. Clicking a node filters the execution list to that processor.
|
||||||
|
- **Recent Executions**: Filtered version of the main table, showing only this route's executions.
|
||||||
|
- **Error Patterns**: Top errors for this route, grouped by exception class.
|
||||||
|
|
||||||
|
### 3.2 Exchange / Message Inspector
|
||||||
|
|
||||||
|
When I click "Exchange" tab in the detail panel:
|
||||||
|
|
||||||
|
- **Inbound Message**: The original message as received by the route's consumer. Body + headers. Shown prominently, always visible.
|
||||||
|
- **Step-by-Step Trace**: For each processor, show the exchange state AFTER that processor ran. Diff mode should highlight what changed (body mutations, added headers, removed headers).
|
||||||
|
- **Properties**: Camel exchange properties (not just headers). Properties often carry routing decisions.
|
||||||
|
- **Exception**: If the exchange failed, show the caught exception, the handled flag, and whether it was routed to a dead letter channel.
|
||||||
|
- **Response**: If the route produces a response (e.g., REST endpoint), show the outbound body.
|
||||||
|
|
||||||
|
Display format should auto-detect JSON/XML and pretty-print. Binary payloads should show hex dump with size.
|
||||||
|
|
||||||
|
### 3.3 Metrics Dashboard (Developer vs. Operator KPIs)
|
||||||
|
|
||||||
|
The current metrics (throughput, latency p99, error rate) are operator KPIs. A Camel developer also needs:
|
||||||
|
|
||||||
|
**Developer KPIs** (add a "Developer" metrics view):
|
||||||
|
- Per-processor latency breakdown (stacked bar: which processors consume the most time)
|
||||||
|
- External endpoint response time (HTTP, DB, JMS) -- separate from Camel processing time
|
||||||
|
- Type converter cache hit rate (rarely needed, but valuable when debugging serialization issues)
|
||||||
|
- Redelivery count (how many messages required retries before succeeding)
|
||||||
|
- Content-based router distribution (for `choice()` routes: how many messages went down each branch)
|
||||||
|
|
||||||
|
**Operator KPIs** (already well-covered):
|
||||||
|
- Throughput, error rate, latency percentiles -- these are solid as-is
|
||||||
|
|
||||||
|
### 3.4 Dead Letter Queue View
|
||||||
|
|
||||||
|
A dedicated DLQ page:
|
||||||
|
|
||||||
|
- **Summary Cards**: One card per DLQ endpoint (e.g., `jms:DLQ.orders`, `seda:error-handler`), showing message count, oldest message age, newest message timestamp.
|
||||||
|
- **Message List**: Table with columns: original route, exception class, business ID, timestamp, retry count.
|
||||||
|
- **Message Detail**: Click a DLQ message to see the exchange snapshot (body + headers + exception) at the time of failure.
|
||||||
|
- **Actions**: Retry (re-submit to original endpoint), Retry All (bulk retry for a pattern), Discard, Move to another queue.
|
||||||
|
- **Filters**: By exception type, by route, by age.
|
||||||
|
|
||||||
|
### 3.5 Route Comparison
|
||||||
|
|
||||||
|
Two use cases:
|
||||||
|
|
||||||
|
1. **Version diff**: Compare route graph v3.2.0 vs. v3.2.1. Show added/removed/modified processors as a visual diff on the DAG.
|
||||||
|
2. **Performance comparison**: Compare this week's latency distribution for `payment-process` with last week's. Overlay histograms. Useful for validating that a deployment improved (or degraded) performance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Information Architecture Critique
|
||||||
|
|
||||||
|
### What Works
|
||||||
|
- **Sidebar hierarchy** (Applications > Routes) is correct and matches how Camel projects are structured.
|
||||||
|
- **Health strip at top** provides instant situational awareness without scrolling.
|
||||||
|
- **Master-detail pattern** (table + slide-in panel) avoids page navigation for quick inspection. This keeps context.
|
||||||
|
- **Keyboard shortcuts** (Ctrl+K search, arrow navigation, Esc to close) are the right accelerators for power users.
|
||||||
|
|
||||||
|
### What Needs Adjustment
|
||||||
|
|
||||||
|
**The sidebar is too flat.** It shows applications and routes in the same list, but there is no way to navigate to:
|
||||||
|
- A dedicated Route Detail page (with per-processor stats, diagram, error patterns)
|
||||||
|
- An Agent Detail page (with resource utilization, version info, configuration)
|
||||||
|
- A DLQ page
|
||||||
|
- A Search/Trace page (for cross-route correlation)
|
||||||
|
|
||||||
|
Recommendation: Add top-level navigation items to the sidebar:
|
||||||
|
```
|
||||||
|
Dashboard (the current view)
|
||||||
|
Routes (route list with status, drill into route detail)
|
||||||
|
Traces (cross-route message flow / correlation)
|
||||||
|
Errors (grouped error patterns, DLQ)
|
||||||
|
Agents (agent health, resource utilization)
|
||||||
|
Diagrams (route graph visualization)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Route click should go deeper.** Currently, clicking a route in the sidebar filters the execution table. This is useful, but clicking the route NAME in a table row or in the detail panel should navigate to a dedicated Route Detail page with per-processor aggregate stats and the route diagram.
|
||||||
|
|
||||||
|
**Search results need grouping.** The Ctrl+K search bar says "Search by Order ID, route, error..." but search results should group by correlation ID when searching by business ID. If I search for "OP-88421", I want to see ALL executions related to that order across all routes, not just the one row in `payment-process`.
|
||||||
|
|
||||||
|
**1-click access priorities:**
|
||||||
|
- Health overview: 1 click (current: 0 clicks -- it is the home page -- good)
|
||||||
|
- Filter by errors only: 1 click (current: 1 click on Error pill -- good)
|
||||||
|
- View a specific execution's processor timeline: 2 clicks (current: 1 click on row -- good)
|
||||||
|
- View exchange body/headers: should be 2 clicks (click row, click Exchange tab). Currently not implemented.
|
||||||
|
- View route diagram: should be 2 clicks (click route name, see diagram). Currently requires finding the button in the detail panel.
|
||||||
|
- Cross-route trace: should be 2 clicks (click correlation ID or business ID, see trace). Currently not possible.
|
||||||
|
- DLQ status: should be 1 click from sidebar. Currently not available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Score Card
|
||||||
|
|
||||||
|
| Dimension | Score (1-10) | Notes |
|
||||||
|
|-----------------------------|:---:|-------|
|
||||||
|
| Transaction tracking | 4 | Individual executions visible, but no cross-route transaction view. Correlation ID shown but not actionable. |
|
||||||
|
| Root cause analysis | 6 | Processor timeline identifies the slow/failing step. Error messages shown inline. But no exchange body inspection, no stack trace expansion, no header diff. |
|
||||||
|
| Performance monitoring | 7 | Throughput, latency p99, error rate charts with SLA lines are solid. Missing per-processor aggregate stats and resource utilization. |
|
||||||
|
| Route visualization | 3 | Route names in sidebar, but no actual route diagram/DAG. The "View Route Diagram" button exists with no destination. This is Cameleer's key differentiator -- it must ship. |
|
||||||
|
| Exchange/message visibility | 2 | Exchange tab exists but has no content. No body inspection, no header view, no step-by-step diff. This is the most critical gap. |
|
||||||
|
| Correlation/tracing | 3 | Correlation ID displayed in detail panel, but no way to trace a message across routes. No breadcrumb linking. No transaction waterfall. |
|
||||||
|
| Overall daily usefulness | 5 | As an operations monitor (is anything broken right now?), it scores 7-8. As a developer debugging tool (why is it broken and how do I fix it?), it scores 3-4. The gap is in the debugging/inspection features. |
|
||||||
|
|
||||||
|
### Summary Verdict
|
||||||
|
|
||||||
|
The dashboard is a **strong operations monitor** -- it answers "what is happening right now?" effectively. The health strip, SLA awareness, shift context, business ID columns, and inline error previews are genuinely useful and better than most tools I have used.
|
||||||
|
|
||||||
|
However, it is a **weak debugging tool** -- it does not yet answer "why did this specific message fail?" or "what did the exchange look like at each step?" The Exchange tab, route diagram, cross-route tracing, and error pattern grouping are the features that would make this a daily-driver tool rather than a pretty overview I glance at in the morning.
|
||||||
|
|
||||||
|
The processor Gantt chart in the detail panel is the single best feature in the entire dashboard. Build on that. Make it clickable (click a processor to see the exchange state at that point). Add aggregate stats. Link it to the route diagram. That is where this tool becomes indispensable.
|
||||||
|
|
||||||
|
**Bottom line**: Ship the exchange inspector, the route diagram, and cross-route tracing, and this goes from a 5/10 to an 8/10 daily-use tool.
|
||||||
1502
docs/ui-mocks/mock-design-expert.html
Normal file
1502
docs/ui-mocks/mock-design-expert.html
Normal file
File diff suppressed because one or more lines are too long
1651
docs/ui-mocks/mock-operator-expert.html
Normal file
1651
docs/ui-mocks/mock-operator-expert.html
Normal file
File diff suppressed because it is too large
Load Diff
1565
docs/ui-mocks/mock-usability-expert.html
Normal file
1565
docs/ui-mocks/mock-usability-expert.html
Normal file
File diff suppressed because it is too large
Load Diff
1707
docs/ui-mocks/mock-v2-dark.html
Normal file
1707
docs/ui-mocks/mock-v2-dark.html
Normal file
File diff suppressed because one or more lines are too long
2076
docs/ui-mocks/mock-v2-light.html
Normal file
2076
docs/ui-mocks/mock-v2-light.html
Normal file
File diff suppressed because one or more lines are too long
1490
docs/ui-mocks/mock-v3-agent-health.html
Normal file
1490
docs/ui-mocks/mock-v3-agent-health.html
Normal file
File diff suppressed because one or more lines are too long
1945
docs/ui-mocks/mock-v3-exchange-detail.html
Normal file
1945
docs/ui-mocks/mock-v3-exchange-detail.html
Normal file
File diff suppressed because one or more lines are too long
2177
docs/ui-mocks/mock-v3-metrics-dashboard.html
Normal file
2177
docs/ui-mocks/mock-v3-metrics-dashboard.html
Normal file
File diff suppressed because one or more lines are too long
2336
docs/ui-mocks/mock-v3-route-detail.html
Normal file
2336
docs/ui-mocks/mock-v3-route-detail.html
Normal file
File diff suppressed because one or more lines are too long
1
ui/.npmrc
Normal file
1
ui/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS build
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
ARG REGISTRY_TOKEN
|
||||||
RUN npm ci
|
COPY package.json package-lock.json .npmrc ./
|
||||||
|
RUN echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc && \
|
||||||
|
npm ci && \
|
||||||
|
rm -f .npmrc
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
91
ui/README.md
91
ui/README.md
@@ -1,73 +1,32 @@
|
|||||||
# React + TypeScript + Vite
|
# Cameleer3 UI
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
React SPA built with [@cameleer/design-system](https://gitea.siegeln.net/cameleer/design-system), TanStack Query, and Zustand.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
## Development
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
```bash
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
npm install
|
||||||
|
npm run dev
|
||||||
## 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:
|
By default the dev server proxies `/api/*` to `http://localhost:8081`. To proxy to a remote server instead:
|
||||||
|
|
||||||
```js
|
```bash
|
||||||
// eslint.config.js
|
VITE_API_TARGET=http://192.168.50.86:30090 npm run dev
|
||||||
import reactX from 'eslint-plugin-react-x'
|
```
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
No CORS issues — Vite's proxy makes API calls server-side.
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
## Build
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
```bash
|
||||||
extends: [
|
npm run build
|
||||||
// Other configs...
|
```
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
## API Types
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
Regenerate TypeScript types from a running backend:
|
||||||
],
|
|
||||||
languageOptions: {
|
```bash
|
||||||
parserOptions: {
|
npm run generate-api:live
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Cameleer3</title>
|
<title>Cameleer3</title>
|
||||||
<script src="/config.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
541
ui/package-lock.json
generated
541
ui/package-lock.json
generated
@@ -8,22 +8,23 @@
|
|||||||
"name": "ui",
|
"name": "ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cameleer/design-system": "^0.0.3",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"panzoom": "^9.4.3",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router": "^7.13.1",
|
"react-router": "^7.13.1",
|
||||||
"swagger-ui-dist": "^5.32.0",
|
"swagger-ui-dist": "^5.32.0",
|
||||||
"uplot": "^1.6.32",
|
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.0",
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
@@ -197,23 +198,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
||||||
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.28.6",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
||||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -274,10 +275,25 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@cameleer/design-system": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.3/design-system-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-x1mZvgYz7j57xFB26pMh9hn5waSJA1CcRWTgkzleLfaO/CmhekLup1HHlbh0b9SxVci6g2HzbcJldr4kvM1yzg==",
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
||||||
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
|
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -287,9 +303,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
||||||
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
|
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -308,6 +324,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@epic-web/invariant": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||||
@@ -584,26 +607,32 @@
|
|||||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/runtime": {
|
|
||||||
"version": "0.115.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
|
|
||||||
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.115.0",
|
"version": "0.120.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
|
||||||
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
|
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@redocly/ajv": {
|
"node_modules/@redocly/ajv": {
|
||||||
"version": "8.11.2",
|
"version": "8.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||||
@@ -636,9 +665,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@redocly/openapi-core": {
|
"node_modules/@redocly/openapi-core": {
|
||||||
"version": "1.34.10",
|
"version": "1.34.11",
|
||||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.10.tgz",
|
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz",
|
||||||
"integrity": "sha512-XCBR/9WHJ0cpezuunHMZjuFMl4KqUo7eiFwzrQrvm7lTXt0EBd3No8UY+9OyzXpDfreGEMMtxmaLZ+ksVw378g==",
|
"integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -681,9 +710,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
|
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -698,9 +727,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
|
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -715,9 +744,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
|
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -732,9 +761,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
|
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -749,9 +778,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
|
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -766,9 +795,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
|
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -783,9 +812,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
|
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -800,9 +829,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
|
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -817,9 +846,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
|
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -834,9 +863,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
|
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -851,9 +880,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
|
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -868,9 +897,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
|
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -885,9 +914,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
|
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
@@ -902,9 +931,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
|
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -919,9 +948,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
|
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -950,9 +979,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.20",
|
"version": "5.91.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz",
|
||||||
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
"integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -960,12 +989,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.90.21",
|
"version": "5.91.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.91.2.tgz",
|
||||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
"integrity": "sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.20"
|
"@tanstack/query-core": "5.91.2"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1031,17 +1060,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
|
||||||
"integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==",
|
"integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@typescript-eslint/scope-manager": "8.57.0",
|
"@typescript-eslint/scope-manager": "8.57.1",
|
||||||
"@typescript-eslint/type-utils": "8.57.0",
|
"@typescript-eslint/type-utils": "8.57.1",
|
||||||
"@typescript-eslint/utils": "8.57.0",
|
"@typescript-eslint/utils": "8.57.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.57.0",
|
"@typescript-eslint/visitor-keys": "8.57.1",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"ts-api-utils": "^2.4.0"
|
"ts-api-utils": "^2.4.0"
|
||||||
@@ -1054,7 +1083,7 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.57.0",
|
"@typescript-eslint/parser": "^8.57.1",
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
@@ -1070,16 +1099,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz",
|
||||||
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.57.0",
|
"@typescript-eslint/scope-manager": "8.57.1",
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.57.0",
|
"@typescript-eslint/typescript-estree": "8.57.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.57.0",
|
"@typescript-eslint/visitor-keys": "8.57.1",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1095,14 +1124,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz",
|
||||||
"integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==",
|
"integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.57.0",
|
"@typescript-eslint/tsconfig-utils": "^8.57.1",
|
||||||
"@typescript-eslint/types": "^8.57.0",
|
"@typescript-eslint/types": "^8.57.1",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1117,14 +1146,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz",
|
||||||
"integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==",
|
"integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.57.0"
|
"@typescript-eslint/visitor-keys": "8.57.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1135,9 +1164,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz",
|
||||||
"integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==",
|
"integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1152,15 +1181,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz",
|
||||||
"integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==",
|
"integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.57.0",
|
"@typescript-eslint/typescript-estree": "8.57.1",
|
||||||
"@typescript-eslint/utils": "8.57.0",
|
"@typescript-eslint/utils": "8.57.1",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"ts-api-utils": "^2.4.0"
|
"ts-api-utils": "^2.4.0"
|
||||||
},
|
},
|
||||||
@@ -1177,9 +1206,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz",
|
||||||
"integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==",
|
"integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1191,16 +1220,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz",
|
||||||
"integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==",
|
"integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.57.0",
|
"@typescript-eslint/project-service": "8.57.1",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.57.0",
|
"@typescript-eslint/tsconfig-utils": "8.57.1",
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.57.0",
|
"@typescript-eslint/visitor-keys": "8.57.1",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@@ -1271,16 +1300,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz",
|
||||||
"integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==",
|
"integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.57.0",
|
"@typescript-eslint/scope-manager": "8.57.1",
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.57.0"
|
"@typescript-eslint/typescript-estree": "8.57.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1295,13 +1324,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz",
|
||||||
"integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==",
|
"integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
"eslint-visitor-keys": "^5.0.0"
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1401,15 +1430,6 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/amator": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bezier-easing": "^2.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ansi-colors": {
|
"node_modules/ansi-colors": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||||
@@ -1451,9 +1471,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.7",
|
"version": "2.10.9",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz",
|
||||||
"integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==",
|
"integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1463,12 +1483,6 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bezier-easing": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -1525,9 +1539,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001778",
|
"version": "1.0.30001780",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
|
||||||
"integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==",
|
"integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1623,6 +1637,24 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-env": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@epic-web/invariant": "^1.0.0",
|
||||||
|
"cross-spawn": "^7.0.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cross-env": "dist/bin/cross-env.js",
|
||||||
|
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -1681,9 +1713,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.313",
|
"version": "1.5.321",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
|
||||||
"integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==",
|
"integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -1978,9 +2010,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -2597,12 +2629,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ngraph.events": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.36",
|
"version": "2.0.36",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||||
@@ -2709,17 +2735,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/panzoom": {
|
|
||||||
"version": "9.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/panzoom/-/panzoom-9.4.3.tgz",
|
|
||||||
"integrity": "sha512-xaxCpElcRbQsUtIdwlrZA90P90+BHip4Vda2BC8MEb4tkI05PmR6cKECdqUCZ85ZvBHjpI9htJrZBxV5Gp/q/w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"amator": "^1.1.0",
|
|
||||||
"ngraph.events": "^1.2.2",
|
|
||||||
"wheel": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -2791,6 +2806,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pluralize": {
|
"node_modules/pluralize": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||||
@@ -2893,6 +2955,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.13.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -2914,14 +2992,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
|
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.115.0",
|
"@oxc-project/types": "=0.120.0",
|
||||||
"@rolldown/pluginutils": "1.0.0-rc.9"
|
"@rolldown/pluginutils": "1.0.0-rc.10"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rolldown": "bin/cli.mjs"
|
"rolldown": "bin/cli.mjs"
|
||||||
@@ -2930,27 +3008,27 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
|
"@rolldown/binding-android-arm64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
|
"@rolldown/binding-darwin-arm64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
|
"@rolldown/binding-darwin-x64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
|
"@rolldown/binding-freebsd-x64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
|
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
|
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
|
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
|
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
|
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
|
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
|
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
|
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
|
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
|
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
|
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -3036,9 +3114,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/swagger-ui-dist": {
|
"node_modules/swagger-ui-dist": {
|
||||||
"version": "5.32.0",
|
"version": "5.32.1",
|
||||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.1.tgz",
|
||||||
"integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==",
|
"integrity": "sha512-6HQoo7+j8PA2QqP5kgAb9dl1uxUjvR0SAoL/WUp1sTEvm0F6D5npgU2OGCLwl++bIInqGlEUQ2mpuZRZYtyCzQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scarf/scarf": "=1.4.0"
|
"@scarf/scarf": "=1.4.0"
|
||||||
@@ -3062,9 +3140,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.4.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
|
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3123,16 +3201,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz",
|
||||||
"integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==",
|
"integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||||
"@typescript-eslint/parser": "8.57.0",
|
"@typescript-eslint/parser": "8.57.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.57.0",
|
"@typescript-eslint/typescript-estree": "8.57.1",
|
||||||
"@typescript-eslint/utils": "8.57.0"
|
"@typescript-eslint/utils": "8.57.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3184,12 +3262,6 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uplot": {
|
|
||||||
"version": "1.6.32",
|
|
||||||
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
|
|
||||||
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
@@ -3208,17 +3280,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
||||||
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
|
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/runtime": "0.115.0",
|
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"rolldown": "1.0.0-rc.9",
|
"rolldown": "1.0.0-rc.10",
|
||||||
"tinyglobby": "^0.2.15"
|
"tinyglobby": "^0.2.15"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3235,7 +3306,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"@vitejs/devtools": "^0.0.0-alpha.31",
|
"@vitejs/devtools": "^0.1.0",
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
@@ -3286,12 +3357,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wheel": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -3379,9 +3444,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zustand": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.11",
|
"version": "5.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
|
||||||
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
|
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
|
|||||||
@@ -5,29 +5,32 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"dev:local": "cross-env VITE_API_TARGET=http://localhost:8081 vite",
|
||||||
|
"dev:remote": "cross-env VITE_API_TARGET=http://192.168.50.86:30090 vite",
|
||||||
|
"build": "tsc -p tsconfig.app.json --noEmit && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"generate-api": "openapi-typescript src/api/openapi.json -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"
|
"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": {
|
"dependencies": {
|
||||||
|
"@cameleer/design-system": "^0.0.3",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"panzoom": "^9.4.3",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router": "^7.13.1",
|
"react-router": "^7.13.1",
|
||||||
"swagger-ui-dist": "^5.32.0",
|
"swagger-ui-dist": "^5.32.0",
|
||||||
"uplot": "^1.6.32",
|
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.0",
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { adminFetch } from './admin-api';
|
import { adminFetch } from './admin-api';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AuditEvent {
|
export interface AuditEvent {
|
||||||
id: number;
|
id: number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -8,18 +10,18 @@ export interface AuditEvent {
|
|||||||
action: string;
|
action: string;
|
||||||
category: string;
|
category: string;
|
||||||
target: string;
|
target: string;
|
||||||
detail: Record<string, unknown>;
|
detail: Record<string, unknown> | null;
|
||||||
result: string;
|
result: string;
|
||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLogParams {
|
export interface AuditLogParams {
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
username?: string;
|
username?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
order?: string;
|
order?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -34,21 +36,25 @@ export interface AuditLogResponse {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuditLog(params: AuditLogParams) {
|
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params.from) query.set('from', params.from);
|
|
||||||
if (params.to) query.set('to', params.to);
|
|
||||||
if (params.username) query.set('username', params.username);
|
|
||||||
if (params.category) query.set('category', params.category);
|
|
||||||
if (params.search) query.set('search', params.search);
|
|
||||||
if (params.sort) query.set('sort', params.sort);
|
|
||||||
if (params.order) query.set('order', params.order);
|
|
||||||
if (params.page !== undefined) query.set('page', String(params.page));
|
|
||||||
if (params.size !== undefined) query.set('size', String(params.size));
|
|
||||||
const qs = query.toString();
|
|
||||||
|
|
||||||
|
export function useAuditLog(params: AuditLogParams = {}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'audit', params],
|
queryKey: ['admin', 'audit', params],
|
||||||
queryFn: () => adminFetch<AuditLogResponse>(`/audit${qs ? `?${qs}` : ''}`),
|
queryFn: () => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.username) qs.set('username', params.username);
|
||||||
|
if (params.category) qs.set('category', params.category);
|
||||||
|
if (params.search) qs.set('search', params.search);
|
||||||
|
if (params.from) qs.set('from', params.from);
|
||||||
|
if (params.to) qs.set('to', params.to);
|
||||||
|
if (params.sort) qs.set('sort', params.sort);
|
||||||
|
if (params.order) qs.set('order', params.order);
|
||||||
|
if (params.page !== undefined) qs.set('page', String(params.page));
|
||||||
|
if (params.size !== undefined) qs.set('size', String(params.size));
|
||||||
|
const query = qs.toString();
|
||||||
|
return adminFetch<AuditLogResponse>(`/audit${query ? `?${query}` : ''}`);
|
||||||
|
},
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { adminFetch } from './admin-api';
|
import { adminFetch } from './admin-api';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface DatabaseStatus {
|
export interface DatabaseStatus {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
version: string;
|
version: string | null;
|
||||||
host: string;
|
host: string | null;
|
||||||
schema: string;
|
schema: string | null;
|
||||||
timescaleDb: boolean;
|
timescaleDb: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PoolStats {
|
export interface PoolStats {
|
||||||
activeConnections: number;
|
activeConnections: number;
|
||||||
idleConnections: number;
|
idleConnections: number;
|
||||||
pendingThreads: number;
|
threadsAwaitingConnection: number;
|
||||||
maxPoolSize: number;
|
connectionTimeout: number;
|
||||||
maxWaitMs: number;
|
maximumPoolSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TableInfo {
|
export interface TableInfo {
|
||||||
@@ -33,18 +35,21 @@ export interface ActiveQuery {
|
|||||||
query: string;
|
query: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useDatabaseStatus() {
|
export function useDatabaseStatus() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'database', 'status'],
|
queryKey: ['admin', 'database', 'status'],
|
||||||
queryFn: () => adminFetch<DatabaseStatus>('/database/status'),
|
queryFn: () => adminFetch<DatabaseStatus>('/database/status'),
|
||||||
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDatabasePool() {
|
export function useConnectionPool() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'database', 'pool'],
|
queryKey: ['admin', 'database', 'pool'],
|
||||||
queryFn: () => adminFetch<PoolStats>('/database/pool'),
|
queryFn: () => adminFetch<PoolStats>('/database/pool'),
|
||||||
refetchInterval: 15000,
|
refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,23 +57,27 @@ export function useDatabaseTables() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'database', 'tables'],
|
queryKey: ['admin', 'database', 'tables'],
|
||||||
queryFn: () => adminFetch<TableInfo[]>('/database/tables'),
|
queryFn: () => adminFetch<TableInfo[]>('/database/tables'),
|
||||||
|
refetchInterval: 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDatabaseQueries() {
|
export function useActiveQueries() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'database', 'queries'],
|
queryKey: ['admin', 'database', 'queries'],
|
||||||
queryFn: () => adminFetch<ActiveQuery[]>('/database/queries'),
|
queryFn: () => adminFetch<ActiveQuery[]>('/database/queries'),
|
||||||
refetchInterval: 15000,
|
refetchInterval: 5_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mutation Hooks ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useKillQuery() {
|
export function useKillQuery() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (pid: number) => {
|
mutationFn: (pid: number) =>
|
||||||
await adminFetch<void>(`/database/queries/${pid}/kill`, { method: 'POST' });
|
adminFetch<void>(`/database/queries/${pid}/kill`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] });
|
||||||
},
|
},
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { adminFetch } from './admin-api';
|
import { adminFetch } from './admin-api';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface OpenSearchStatus {
|
export interface OpenSearchStatus {
|
||||||
reachable: boolean;
|
connected: boolean;
|
||||||
clusterHealth: string;
|
clusterHealth: string;
|
||||||
version: string;
|
version: string | null;
|
||||||
nodeCount: number;
|
numberOfNodes: number;
|
||||||
host: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PipelineStats {
|
export interface PipelineStats {
|
||||||
queueDepth: number;
|
queueDepth: number;
|
||||||
maxQueueSize: number;
|
maxQueueSize: number;
|
||||||
indexedCount: number;
|
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
|
indexedCount: number;
|
||||||
debounceMs: number;
|
debounceMs: number;
|
||||||
indexingRate: number;
|
indexingRate: number;
|
||||||
lastIndexedAt: string | null;
|
lastIndexedAt: string | null;
|
||||||
@@ -21,15 +23,15 @@ export interface PipelineStats {
|
|||||||
|
|
||||||
export interface IndexInfo {
|
export interface IndexInfo {
|
||||||
name: string;
|
name: string;
|
||||||
health: string;
|
|
||||||
docCount: number;
|
docCount: number;
|
||||||
size: string;
|
size: string;
|
||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
|
health: string;
|
||||||
primaryShards: number;
|
primaryShards: number;
|
||||||
replicaShards: number;
|
replicas: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IndicesPageResponse {
|
export interface IndicesPage {
|
||||||
indices: IndexInfo[];
|
indices: IndexInfo[];
|
||||||
totalIndices: number;
|
totalIndices: number;
|
||||||
totalDocs: number;
|
totalDocs: number;
|
||||||
@@ -44,20 +46,17 @@ export interface PerformanceStats {
|
|||||||
requestCacheHitRate: number;
|
requestCacheHitRate: number;
|
||||||
searchLatencyMs: number;
|
searchLatencyMs: number;
|
||||||
indexingLatencyMs: number;
|
indexingLatencyMs: number;
|
||||||
jvmHeapUsedBytes: number;
|
heapUsedBytes: number;
|
||||||
jvmHeapMaxBytes: number;
|
heapMaxBytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IndicesParams {
|
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||||
search?: string;
|
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpenSearchStatus() {
|
export function useOpenSearchStatus() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'opensearch', 'status'],
|
queryKey: ['admin', 'opensearch', 'status'],
|
||||||
queryFn: () => adminFetch<OpenSearchStatus>('/opensearch/status'),
|
queryFn: () => adminFetch<OpenSearchStatus>('/opensearch/status'),
|
||||||
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,42 +64,41 @@ export function usePipelineStats() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'opensearch', 'pipeline'],
|
queryKey: ['admin', 'opensearch', 'pipeline'],
|
||||||
queryFn: () => adminFetch<PipelineStats>('/opensearch/pipeline'),
|
queryFn: () => adminFetch<PipelineStats>('/opensearch/pipeline'),
|
||||||
refetchInterval: 15000,
|
refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIndices(params: IndicesParams) {
|
export function useOpenSearchIndices(page = 0, size = 20, search = '') {
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params.search) query.set('search', params.search);
|
|
||||||
if (params.page !== undefined) query.set('page', String(params.page));
|
|
||||||
if (params.size !== undefined) query.set('size', String(params.size));
|
|
||||||
const qs = query.toString();
|
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'opensearch', 'indices', params],
|
queryKey: ['admin', 'opensearch', 'indices', page, size, search],
|
||||||
queryFn: () =>
|
queryFn: () => {
|
||||||
adminFetch<IndicesPageResponse>(
|
const params = new URLSearchParams();
|
||||||
`/opensearch/indices${qs ? `?${qs}` : ''}`,
|
params.set('page', String(page));
|
||||||
),
|
params.set('size', String(size));
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
return adminFetch<IndicesPage>(`/opensearch/indices?${params}`);
|
||||||
|
},
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePerformanceStats() {
|
export function useOpenSearchPerformance() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'opensearch', 'performance'],
|
queryKey: ['admin', 'opensearch', 'performance'],
|
||||||
queryFn: () => adminFetch<PerformanceStats>('/opensearch/performance'),
|
queryFn: () => adminFetch<PerformanceStats>('/opensearch/performance'),
|
||||||
refetchInterval: 15000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mutation Hooks ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useDeleteIndex() {
|
export function useDeleteIndex() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (indexName: string) => {
|
mutationFn: (indexName: string) =>
|
||||||
await adminFetch<void>(`/opensearch/indices/${encodeURIComponent(indexName)}`, {
|
adminFetch<void>(`/opensearch/indices/${indexName}`, { method: 'DELETE' }),
|
||||||
method: 'DELETE',
|
onSuccess: () => {
|
||||||
});
|
qc.invalidateQueries({ queryKey: ['admin', 'opensearch', 'indices'] });
|
||||||
},
|
},
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'opensearch', 'indices'] }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { adminFetch } from './admin-api';
|
import { adminFetch } from './admin-api';
|
||||||
|
|
||||||
// ─── Types ───
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface RoleSummary {
|
export interface RoleSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
system: boolean;
|
scope: string;
|
||||||
source: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupSummary {
|
export interface GroupSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
parentGroupId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSummary {
|
export interface UserSummary {
|
||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
provider: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserDetail {
|
export interface UserDetail {
|
||||||
@@ -33,17 +32,6 @@ export interface UserDetail {
|
|||||||
effectiveGroups: GroupSummary[];
|
effectiveGroups: GroupSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupDetail {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
parentGroupId: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
directRoles: RoleSummary[];
|
|
||||||
effectiveRoles: RoleSummary[];
|
|
||||||
members: UserSummary[];
|
|
||||||
childGroups: GroupSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoleDetail {
|
export interface RoleDetail {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -56,6 +44,53 @@ export interface RoleDetail {
|
|||||||
effectivePrincipals: UserSummary[];
|
effectivePrincipals: UserSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentGroupId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
directRoles: RoleSummary[];
|
||||||
|
effectiveRoles: RoleSummary[];
|
||||||
|
members: UserSummary[];
|
||||||
|
childGroups: GroupSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoleRequest {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoleRequest {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGroupRequest {
|
||||||
|
name: string;
|
||||||
|
parentGroupId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGroupRequest {
|
||||||
|
name: string;
|
||||||
|
parentGroupId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats Hook ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface RbacStats {
|
export interface RbacStats {
|
||||||
userCount: number;
|
userCount: number;
|
||||||
activeUserCount: number;
|
activeUserCount: number;
|
||||||
@@ -64,53 +99,6 @@ export interface RbacStats {
|
|||||||
roleCount: number;
|
roleCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Query hooks ───
|
|
||||||
|
|
||||||
export function useUsers() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'rbac', 'users'],
|
|
||||||
queryFn: () => adminFetch<UserDetail[]>('/users'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUser(userId: string | null) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'rbac', 'users', userId],
|
|
||||||
queryFn: () => adminFetch<UserDetail>(`/users/${encodeURIComponent(userId!)}`),
|
|
||||||
enabled: !!userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGroups() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'rbac', 'groups'],
|
|
||||||
queryFn: () => adminFetch<GroupDetail[]>('/groups'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGroup(groupId: string | null) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'rbac', 'groups', groupId],
|
|
||||||
queryFn: () => adminFetch<GroupDetail>(`/groups/${groupId}`),
|
|
||||||
enabled: !!groupId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRoles() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'rbac', 'roles'],
|
|
||||||
queryFn: () => adminFetch<RoleDetail[]>('/roles'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRole(roleId: string | null) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['admin', 'rbac', 'roles', roleId],
|
|
||||||
queryFn: () => adminFetch<RoleDetail>(`/roles/${roleId}`),
|
|
||||||
enabled: !!roleId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRbacStats() {
|
export function useRbacStats() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'rbac', 'stats'],
|
queryKey: ['admin', 'rbac', 'stats'],
|
||||||
@@ -118,162 +106,69 @@ export function useRbacStats() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Mutation hooks ───
|
// ── User Query Hooks ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useAssignRoleToUser() {
|
export function useUsers() {
|
||||||
const qc = useQueryClient();
|
return useQuery({
|
||||||
return useMutation({
|
queryKey: ['admin', 'users'],
|
||||||
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
|
queryFn: () => adminFetch<UserDetail[]>('/users'),
|
||||||
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRemoveRoleFromUser() {
|
export function useUser(userId: string | null) {
|
||||||
const qc = useQueryClient();
|
return useQuery({
|
||||||
return useMutation({
|
queryKey: ['admin', 'users', userId],
|
||||||
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
|
queryFn: () => adminFetch<UserDetail>(`/users/${userId}`),
|
||||||
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'DELETE' }),
|
enabled: !!userId,
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAddUserToGroup() {
|
// ── Role Query Hooks ───────────────────────────────────────────────────
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
export function useRoles() {
|
||||||
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
|
return useQuery({
|
||||||
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'POST' }),
|
queryKey: ['admin', 'roles'],
|
||||||
onSuccess: () => {
|
queryFn: () => adminFetch<RoleDetail[]>('/roles'),
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRemoveUserFromGroup() {
|
export function useRole(roleId: string | null) {
|
||||||
const qc = useQueryClient();
|
return useQuery({
|
||||||
return useMutation({
|
queryKey: ['admin', 'roles', roleId],
|
||||||
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
|
queryFn: () => adminFetch<RoleDetail>(`/roles/${roleId}`),
|
||||||
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'DELETE' }),
|
enabled: !!roleId,
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateGroup() {
|
// ── Group Query Hooks ──────────────────────────────────────────────────
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
export function useGroups() {
|
||||||
mutationFn: (data: { name: string; parentGroupId?: string }) =>
|
return useQuery({
|
||||||
adminFetch<{ id: string }>('/groups', {
|
queryKey: ['admin', 'groups'],
|
||||||
method: 'POST',
|
queryFn: () => adminFetch<GroupDetail[]>('/groups'),
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateGroup() {
|
export function useGroup(groupId: string | null) {
|
||||||
const qc = useQueryClient();
|
return useQuery({
|
||||||
return useMutation({
|
queryKey: ['admin', 'groups', groupId],
|
||||||
mutationFn: ({ id, ...data }: { id: string; name?: string; parentGroupId?: string | null }) =>
|
queryFn: () => adminFetch<GroupDetail>(`/groups/${groupId}`),
|
||||||
adminFetch(`/groups/${id}`, {
|
enabled: !!groupId,
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteGroup() {
|
// ── User Mutation Hooks ────────────────────────────────────────────────
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
adminFetch(`/groups/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAssignRoleToGroup() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
|
|
||||||
adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRemoveRoleFromGroup() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
|
|
||||||
adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateRole() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: { name: string; description?: string; scope?: string }) =>
|
|
||||||
adminFetch<{ id: string }>('/roles', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateRole() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ id, ...data }: { id: string; name?: string; description?: string; scope?: string }) =>
|
|
||||||
adminFetch(`/roles/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteRole() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
adminFetch(`/roles/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateUser() {
|
export function useCreateUser() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { username: string; displayName?: string; email?: string; password?: string }) =>
|
mutationFn: (req: CreateUserRequest) =>
|
||||||
adminFetch<UserDetail>('/users', {
|
adminFetch<UserDetail>('/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(req),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -281,13 +176,13 @@ export function useCreateUser() {
|
|||||||
export function useUpdateUser() {
|
export function useUpdateUser() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ userId, ...data }: { userId: string; displayName?: string; email?: string }) =>
|
mutationFn: ({ userId, ...req }: UpdateUserRequest & { userId: string }) =>
|
||||||
adminFetch(`/users/${encodeURIComponent(userId)}`, {
|
adminFetch<void>(`/users/${userId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(req),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -296,9 +191,176 @@ export function useDeleteUser() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (userId: string) =>
|
mutationFn: (userId: string) =>
|
||||||
adminFetch(`/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
adminFetch<void>(`/users/${userId}`, { method: 'DELETE' }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetPassword() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ userId, password }: { userId: string; password: string }) => {
|
||||||
|
await adminFetch(`/users/${userId}/password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'users'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssignRoleToUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
|
||||||
|
adminFetch<void>(`/users/${userId}/roles/${roleId}`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveRoleFromUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
|
||||||
|
adminFetch<void>(`/users/${userId}/roles/${roleId}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddUserToGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
|
||||||
|
adminFetch<void>(`/users/${userId}/groups/${groupId}`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveUserFromGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
|
||||||
|
adminFetch<void>(`/users/${userId}/groups/${groupId}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Role Mutation Hooks ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCreateRole() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (req: CreateRoleRequest) =>
|
||||||
|
adminFetch<{ id: string }>('/roles', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRole() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...req }: UpdateRoleRequest & { id: string }) =>
|
||||||
|
adminFetch<void>(`/roles/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteRole() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
adminFetch<void>(`/roles/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Group Mutation Hooks ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCreateGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (req: CreateGroupRequest) =>
|
||||||
|
adminFetch<{ id: string }>('/groups', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...req }: UpdateGroupRequest & { id: string }) =>
|
||||||
|
adminFetch<void>(`/groups/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
adminFetch<void>(`/groups/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssignRoleToGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
|
||||||
|
adminFetch<void>(`/groups/${groupId}/roles/${roleId}`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveRoleFromGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
|
||||||
|
adminFetch<void>(`/groups/${groupId}/roles/${roleId}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { adminFetch } from './admin-api';
|
import { adminFetch } from './admin-api';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface DatabaseThresholds {
|
export interface DatabaseThresholds {
|
||||||
connectionPoolWarning: number;
|
connectionPoolWarning: number;
|
||||||
connectionPoolCritical: number;
|
connectionPoolCritical: number;
|
||||||
@@ -24,6 +26,8 @@ export interface ThresholdConfig {
|
|||||||
opensearch: OpenSearchThresholds;
|
opensearch: OpenSearchThresholds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useThresholds() {
|
export function useThresholds() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'thresholds'],
|
queryKey: ['admin', 'thresholds'],
|
||||||
@@ -31,15 +35,18 @@ export function useThresholds() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSaveThresholds() {
|
// ── Mutation Hooks ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useUpdateThresholds() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (body: ThresholdConfig) => {
|
mutationFn: (config: ThresholdConfig) =>
|
||||||
await adminFetch<ThresholdConfig>('/thresholds', {
|
adminFetch<ThresholdConfig>('/thresholds', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(config),
|
||||||
});
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'thresholds'] });
|
||||||
},
|
},
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'thresholds'] }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
26
ui/src/api/queries/agent-metrics.ts
Normal file
26
ui/src/api/queries/agent-metrics.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { config } from '../../config';
|
||||||
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
|
||||||
|
export function useAgentMetrics(agentId: string | null, names: string[], buckets = 60) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['agent-metrics', agentId, names.join(','), buckets],
|
||||||
|
queryFn: async () => {
|
||||||
|
const token = useAuthStore.getState().accessToken;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
names: names.join(','),
|
||||||
|
buckets: String(buckets),
|
||||||
|
});
|
||||||
|
const res = await fetch(`${config.apiBaseUrl}/agents/${agentId}/metrics?${params}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
return res.json() as Promise<{ metrics: Record<string, Array<{ time: string; value: number }>> }>;
|
||||||
|
},
|
||||||
|
enabled: !!agentId && names.length > 0,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,15 +1,40 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../client';
|
import { api } from '../client';
|
||||||
|
import { config } from '../../config';
|
||||||
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
|
||||||
export function useAgents(status?: string) {
|
export function useAgents(status?: string, application?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['agents', status],
|
queryKey: ['agents', status, application],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await api.GET('/agents', {
|
const { data, error } = await api.GET('/agents', {
|
||||||
params: { query: status ? { status } : {} },
|
params: { query: { ...(status ? { status } : {}), ...(application ? { application } : {}) } },
|
||||||
});
|
});
|
||||||
if (error) throw new Error('Failed to load agents');
|
if (error) throw new Error('Failed to load agents');
|
||||||
return data!;
|
return data!;
|
||||||
},
|
},
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentEvents(appId?: string, agentId?: string, limit = 50) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['agents', 'events', appId, agentId, limit],
|
||||||
|
queryFn: async () => {
|
||||||
|
const token = useAuthStore.getState().accessToken;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (appId) params.set('appId', appId);
|
||||||
|
if (agentId) params.set('agentId', agentId);
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
const res = await fetch(`${config.apiBaseUrl}/agents/events-log?${params}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load agent events');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user