Compare commits
43 Commits
4ff01681d4
...
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 |
@@ -51,7 +51,7 @@ public class AgentLifecycleMonitor {
|
|||||||
if (before != null && before != agent.state()) {
|
if (before != null && before != agent.state()) {
|
||||||
String eventType = mapTransitionEvent(before, agent.state());
|
String eventType = mapTransitionEvent(before, agent.state());
|
||||||
if (eventType != null) {
|
if (eventType != null) {
|
||||||
agentEventService.recordEvent(agent.id(), agent.group(), eventType,
|
agentEventService.recordEvent(agent.id(), agent.application(), eventType,
|
||||||
agent.name() + " " + before + " -> " + agent.state());
|
agent.name() + " " + before + " -> " + agent.state());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ public class OpenApiConfig {
|
|||||||
"SearchResultExecutionSummary", "UserInfo",
|
"SearchResultExecutionSummary", "UserInfo",
|
||||||
"ProcessorNode",
|
"ProcessorNode",
|
||||||
"AppCatalogEntry", "RouteSummary", "AgentSummary",
|
"AppCatalogEntry", "RouteSummary", "AgentSummary",
|
||||||
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse"
|
"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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,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);
|
||||||
|
|
||||||
agentEventService.recordEvent(request.agentId(), group, "REGISTERED",
|
agentEventService.recordEvent(request.agentId(), application, "REGISTERED",
|
||||||
"Agent registered: " + request.name());
|
"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(),
|
||||||
@@ -166,8 +166,8 @@ 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.group(), roles);
|
String newRefreshToken = jwtService.createRefreshToken(agentId, agent.application(), roles);
|
||||||
|
|
||||||
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
|
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
|
||||||
}
|
}
|
||||||
@@ -187,13 +187,13 @@ public class AgentRegistrationController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all agents",
|
@Operation(summary = "List all agents",
|
||||||
description = "Returns all registered agents with runtime metrics, 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) {
|
||||||
@@ -207,10 +207,10 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,11 +221,11 @@ public class AgentRegistrationController {
|
|||||||
List<AgentInstanceResponse> response = finalAgents.stream()
|
List<AgentInstanceResponse> response = finalAgents.stream()
|
||||||
.map(a -> {
|
.map(a -> {
|
||||||
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
|
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
|
||||||
double[] m = agentMetrics.get(a.group());
|
double[] m = agentMetrics.get(a.application());
|
||||||
if (m != null) {
|
if (m != null) {
|
||||||
long groupAgentCount = finalAgents.stream()
|
long appAgentCount = finalAgents.stream()
|
||||||
.filter(ag -> ag.group().equals(a.group())).count();
|
.filter(ag -> ag.application().equals(a.application())).count();
|
||||||
double agentTps = groupAgentCount > 0 ? m[0] / groupAgentCount : 0;
|
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
|
||||||
double errorRate = m[1];
|
double errorRate = m[1];
|
||||||
int activeRoutes = (int) m[2];
|
int activeRoutes = (int) m[2];
|
||||||
return dto.withMetrics(agentTps, errorRate, activeRoutes);
|
return dto.withMetrics(agentTps, errorRate, activeRoutes);
|
||||||
@@ -242,19 +242,19 @@ public class AgentRegistrationController {
|
|||||||
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
||||||
try {
|
try {
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT group_name, " +
|
"SELECT application_name, " +
|
||||||
"SUM(total_count) AS total, " +
|
"SUM(total_count) AS total, " +
|
||||||
"SUM(failed_count) AS failed, " +
|
"SUM(failed_count) AS failed, " +
|
||||||
"COUNT(DISTINCT route_id) AS active_routes " +
|
"COUNT(DISTINCT route_id) AS active_routes " +
|
||||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
"GROUP BY group_name",
|
"GROUP BY application_name",
|
||||||
rs -> {
|
rs -> {
|
||||||
long total = rs.getLong("total");
|
long total = rs.getLong("total");
|
||||||
long failed = rs.getLong("failed");
|
long failed = rs.getLong("failed");
|
||||||
double tps = total / 60.0;
|
double tps = total / 60.0;
|
||||||
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||||
int activeRoutes = rs.getInt("active_routes");
|
int activeRoutes = rs.getInt("active_routes");
|
||||||
result.put(rs.getString("group_name"), new double[]{tps, errorRate, activeRoutes});
|
result.put(rs.getString("application_name"), new double[]{tps, errorRate, activeRoutes});
|
||||||
},
|
},
|
||||||
Timestamp.from(from1m), Timestamp.from(now));
|
Timestamp.from(from1m), Timestamp.from(now));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ public class RouteCatalogController {
|
|||||||
public ResponseEntity<List<AppCatalogEntry>> getCatalog() {
|
public ResponseEntity<List<AppCatalogEntry>> getCatalog() {
|
||||||
List<AgentInfo> allAgents = registryService.findAll();
|
List<AgentInfo> allAgents = registryService.findAll();
|
||||||
|
|
||||||
// Group agents by application (group name)
|
// Group agents by application name
|
||||||
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
||||||
.collect(Collectors.groupingBy(AgentInfo::group, LinkedHashMap::new, Collectors.toList()));
|
.collect(Collectors.groupingBy(AgentInfo::application, LinkedHashMap::new, Collectors.toList()));
|
||||||
|
|
||||||
// Collect all distinct routes per app
|
// Collect all distinct routes per app
|
||||||
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
|
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
|
||||||
@@ -73,11 +73,11 @@ public class RouteCatalogController {
|
|||||||
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
||||||
try {
|
try {
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT group_name, route_id, SUM(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
"SELECT application_name, route_id, SUM(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
||||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
"GROUP BY group_name, route_id",
|
"GROUP BY application_name, route_id",
|
||||||
rs -> {
|
rs -> {
|
||||||
String key = rs.getString("group_name") + "/" + rs.getString("route_id");
|
String key = rs.getString("application_name") + "/" + rs.getString("route_id");
|
||||||
routeExchangeCounts.put(key, rs.getLong("cnt"));
|
routeExchangeCounts.put(key, rs.getLong("cnt"));
|
||||||
Timestamp ts = rs.getTimestamp("last_seen");
|
Timestamp ts = rs.getTimestamp("last_seen");
|
||||||
if (ts != null) routeLastSeen.put(key, ts.toInstant());
|
if (ts != null) routeLastSeen.put(key, ts.toInstant());
|
||||||
@@ -91,9 +91,9 @@ public class RouteCatalogController {
|
|||||||
Map<String, Double> agentTps = new LinkedHashMap<>();
|
Map<String, Double> agentTps = new LinkedHashMap<>();
|
||||||
try {
|
try {
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT group_name, SUM(total_count) AS cnt " +
|
"SELECT application_name, SUM(total_count) AS cnt " +
|
||||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
"GROUP BY group_name",
|
"GROUP BY application_name",
|
||||||
rs -> {
|
rs -> {
|
||||||
// This gives per-app TPS; we'll distribute among agents below
|
// This gives per-app TPS; we'll distribute among agents below
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.cameleer3.server.app.controller;
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.ProcessorMetrics;
|
||||||
import com.cameleer3.server.app.dto.RouteMetrics;
|
import com.cameleer3.server.app.dto.RouteMetrics;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
@@ -43,7 +44,7 @@ public class RouteMetricsController {
|
|||||||
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
|
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
|
||||||
|
|
||||||
var sql = new StringBuilder(
|
var sql = new StringBuilder(
|
||||||
"SELECT group_name, route_id, " +
|
"SELECT application_name, route_id, " +
|
||||||
"SUM(total_count) AS total, " +
|
"SUM(total_count) AS total, " +
|
||||||
"SUM(failed_count) AS failed, " +
|
"SUM(failed_count) AS failed, " +
|
||||||
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_dur, " +
|
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_dur, " +
|
||||||
@@ -54,17 +55,17 @@ public class RouteMetricsController {
|
|||||||
params.add(Timestamp.from(toInstant));
|
params.add(Timestamp.from(toInstant));
|
||||||
|
|
||||||
if (appId != null) {
|
if (appId != null) {
|
||||||
sql.append(" AND group_name = ?");
|
sql.append(" AND application_name = ?");
|
||||||
params.add(appId);
|
params.add(appId);
|
||||||
}
|
}
|
||||||
sql.append(" GROUP BY group_name, route_id ORDER BY group_name, route_id");
|
sql.append(" GROUP BY application_name, route_id ORDER BY application_name, route_id");
|
||||||
|
|
||||||
// Key struct for sparkline lookup
|
// Key struct for sparkline lookup
|
||||||
record RouteKey(String appId, String routeId) {}
|
record RouteKey(String appId, String routeId) {}
|
||||||
List<RouteKey> routeKeys = new ArrayList<>();
|
List<RouteKey> routeKeys = new ArrayList<>();
|
||||||
|
|
||||||
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
||||||
String groupName = rs.getString("group_name");
|
String applicationName = rs.getString("application_name");
|
||||||
String routeId = rs.getString("route_id");
|
String routeId = rs.getString("route_id");
|
||||||
long total = rs.getLong("total");
|
long total = rs.getLong("total");
|
||||||
long failed = rs.getLong("failed");
|
long failed = rs.getLong("failed");
|
||||||
@@ -75,8 +76,8 @@ public class RouteMetricsController {
|
|||||||
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||||
double tps = windowSeconds > 0 ? (double) total / windowSeconds : 0.0;
|
double tps = windowSeconds > 0 ? (double) total / windowSeconds : 0.0;
|
||||||
|
|
||||||
routeKeys.add(new RouteKey(groupName, routeId));
|
routeKeys.add(new RouteKey(applicationName, routeId));
|
||||||
return new RouteMetrics(routeId, groupName, total, successRate,
|
return new RouteMetrics(routeId, applicationName, total, successRate,
|
||||||
avgDur, p99Dur, errorRate, tps, List.of());
|
avgDur, p99Dur, errorRate, tps, List.of());
|
||||||
}, params.toArray());
|
}, params.toArray());
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ public class RouteMetricsController {
|
|||||||
"SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " +
|
"SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " +
|
||||||
"COALESCE(SUM(total_count), 0) AS cnt " +
|
"COALESCE(SUM(total_count), 0) AS cnt " +
|
||||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
"AND group_name = ? AND route_id = ? " +
|
"AND application_name = ? AND route_id = ? " +
|
||||||
"GROUP BY period ORDER BY period",
|
"GROUP BY period ORDER BY period",
|
||||||
(rs, rowNum) -> rs.getDouble("cnt"),
|
(rs, rowNum) -> rs.getDouble("cnt"),
|
||||||
bucketSeconds, Timestamp.from(fromInstant), Timestamp.from(toInstant),
|
bucketSeconds, Timestamp.from(fromInstant), Timestamp.from(toInstant),
|
||||||
@@ -108,4 +109,56 @@ public class RouteMetricsController {
|
|||||||
|
|
||||||
return ResponseEntity.ok(metrics);
|
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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,19 @@ import jakarta.validation.constraints.NotNull;
|
|||||||
import java.time.Duration;
|
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 with runtime metrics")
|
@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 tps,
|
||||||
double errorRate,
|
double errorRate,
|
||||||
int activeRoutes,
|
int activeRoutes,
|
||||||
@@ -26,9 +29,10 @@ public record AgentInstanceResponse(
|
|||||||
public static AgentInstanceResponse from(AgentInfo info) {
|
public static AgentInstanceResponse from(AgentInfo info) {
|
||||||
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
|
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.0, 0.0,
|
||||||
0, info.routeIds() != null ? info.routeIds().size() : 0,
|
0, info.routeIds() != null ? info.routeIds().size() : 0,
|
||||||
uptime
|
uptime
|
||||||
@@ -37,7 +41,8 @@ public record AgentInstanceResponse(
|
|||||||
|
|
||||||
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
|
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
|
||||||
return new AgentInstanceResponse(
|
return new AgentInstanceResponse(
|
||||||
id, name, group, status, routeIds, registeredAt, lastHeartbeat,
|
id, name, application, status, routeIds, registeredAt, lastHeartbeat,
|
||||||
|
version, capabilities,
|
||||||
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
|
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
|
||||||
|
) {}
|
||||||
@@ -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,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,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,6 +80,7 @@ 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/agents/events-log").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
-- =============================================================
|
|
||||||
-- Consolidate oidc_config + admin_thresholds → server_config
|
|
||||||
-- =============================================================
|
|
||||||
|
|
||||||
CREATE TABLE server_config (
|
|
||||||
config_key TEXT PRIMARY KEY,
|
|
||||||
config_val JSONB NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_by TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Migrate existing oidc_config row (if any)
|
|
||||||
INSERT INTO server_config (config_key, config_val, updated_at)
|
|
||||||
SELECT 'oidc',
|
|
||||||
jsonb_build_object(
|
|
||||||
'enabled', enabled,
|
|
||||||
'issuerUri', issuer_uri,
|
|
||||||
'clientId', client_id,
|
|
||||||
'clientSecret', client_secret,
|
|
||||||
'rolesClaim', roles_claim,
|
|
||||||
'defaultRoles', to_jsonb(default_roles),
|
|
||||||
'autoSignup', auto_signup,
|
|
||||||
'displayNameClaim', display_name_claim
|
|
||||||
),
|
|
||||||
updated_at
|
|
||||||
FROM oidc_config
|
|
||||||
WHERE config_id = 'default';
|
|
||||||
|
|
||||||
-- Migrate existing admin_thresholds row (if any)
|
|
||||||
INSERT INTO server_config (config_key, config_val, updated_at, updated_by)
|
|
||||||
SELECT 'thresholds', config, updated_at, updated_by
|
|
||||||
FROM admin_thresholds
|
|
||||||
WHERE id = 1;
|
|
||||||
|
|
||||||
DROP TABLE oidc_config;
|
|
||||||
DROP TABLE admin_thresholds;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- Agent lifecycle events for tracking registration, state transitions, etc.
|
|
||||||
CREATE TABLE agent_events (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
agent_id TEXT NOT NULL,
|
|
||||||
app_id TEXT NOT NULL,
|
|
||||||
event_type TEXT NOT NULL,
|
|
||||||
detail TEXT,
|
|
||||||
timestamp 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);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
-- Retention: drop agent_metrics chunks older than 90 days
|
|
||||||
SELECT add_retention_policy('agent_metrics', INTERVAL '90 days', if_not_exists => true);
|
|
||||||
|
|
||||||
-- Compression: compress agent_metrics chunks older than 7 days
|
|
||||||
ALTER TABLE agent_metrics SET (timescaledb.compress);
|
|
||||||
SELECT add_compression_policy('agent_metrics', INTERVAL '7 days', if_not_exists => true);
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
98
ui/package-lock.json
generated
98
ui/package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "ui",
|
"name": "ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cameleer/design-system": "^0.0.1",
|
"@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",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -19,10 +19,12 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -274,9 +276,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cameleer/design-system": {
|
"node_modules/@cameleer/design-system": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.3",
|
||||||
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.1/design-system-0.0.1.tgz",
|
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.3/design-system-0.0.3.tgz",
|
||||||
"integrity": "sha512-8rMAp7JhZBlAw4jcTnSBLuZe8cd94lPAgL96KDtVIk2QpXKdsJLoVfk7CuPG635/h6pu4YKplfBhJmKpsS8A8g==",
|
"integrity": "sha512-x1mZvgYz7j57xFB26pMh9hn5waSJA1CcRWTgkzleLfaO/CmhekLup1HHlbh0b9SxVci6g2HzbcJldr4kvM1yzg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -322,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",
|
||||||
@@ -608,6 +617,22 @@
|
|||||||
"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",
|
||||||
@@ -1612,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",
|
||||||
@@ -2763,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",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"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",
|
"build": "tsc -p tsconfig.app.json --noEmit && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -12,7 +14,7 @@
|
|||||||
"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.1",
|
"@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",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -23,10 +25,12 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
1
ui/src/api/openapi.json
Normal file
1
ui/src/api/openapi.json
Normal file
File diff suppressed because one or more lines are too long
@@ -198,6 +198,19 @@ export function useDeleteUser() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export function useAssignRoleToUser() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@ import { api } from '../client';
|
|||||||
import { config } from '../../config';
|
import { config } from '../../config';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
|
||||||
export function useAgents(status?: string, group?: string) {
|
export function useAgents(status?: string, application?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['agents', status, group],
|
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 } : {}), ...(group ? { group } : {}) } },
|
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!;
|
||||||
|
|||||||
20
ui/src/api/queries/correlation.ts
Normal file
20
ui/src/api/queries/correlation.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../client';
|
||||||
|
|
||||||
|
export function useCorrelationChain(correlationId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['correlation-chain', correlationId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.POST('/search/executions', {
|
||||||
|
body: {
|
||||||
|
correlationId: correlationId!,
|
||||||
|
limit: 20,
|
||||||
|
sortField: 'startTime',
|
||||||
|
sortDir: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!correlationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../client';
|
import { api } from '../client';
|
||||||
|
|
||||||
|
interface DiagramLayout {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
nodes?: Array<{ id?: string; label?: string; type?: string; x?: number; y?: number; width?: number; height?: number }>;
|
||||||
|
edges?: Array<{ from?: string; to?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export function useDiagramLayout(contentHash: string | null) {
|
export function useDiagramLayout(contentHash: string | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['diagrams', 'layout', contentHash],
|
queryKey: ['diagrams', 'layout', contentHash],
|
||||||
@@ -10,22 +17,22 @@ export function useDiagramLayout(contentHash: string | null) {
|
|||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
});
|
});
|
||||||
if (error) throw new Error('Failed to load diagram layout');
|
if (error) throw new Error('Failed to load diagram layout');
|
||||||
return data!;
|
return data as DiagramLayout;
|
||||||
},
|
},
|
||||||
enabled: !!contentHash,
|
enabled: !!contentHash,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDiagramByRoute(group: string | undefined, routeId: string | undefined) {
|
export function useDiagramByRoute(application: string | undefined, routeId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['diagrams', 'byRoute', group, routeId],
|
queryKey: ['diagrams', 'byRoute', application, routeId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await api.GET('/diagrams', {
|
const { data, error } = await api.GET('/diagrams', {
|
||||||
params: { query: { group: group!, routeId: routeId! } },
|
params: { query: { application: application!, routeId: routeId! } },
|
||||||
});
|
});
|
||||||
if (error) throw new Error('Failed to load diagram for route');
|
if (error) throw new Error('Failed to load diagram for route');
|
||||||
return data!;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: !!group && !!routeId,
|
enabled: !!application && !!routeId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ export function useExecutionStats(
|
|||||||
timeFrom: string | undefined,
|
timeFrom: string | undefined,
|
||||||
timeTo: string | undefined,
|
timeTo: string | undefined,
|
||||||
routeId?: string,
|
routeId?: string,
|
||||||
group?: string,
|
application?: string,
|
||||||
) {
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, group],
|
queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, application],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await api.GET('/search/stats', {
|
const { data, error } = await api.GET('/search/stats', {
|
||||||
params: {
|
params: {
|
||||||
@@ -17,7 +17,7 @@ export function useExecutionStats(
|
|||||||
from: timeFrom!,
|
from: timeFrom!,
|
||||||
to: timeTo || undefined,
|
to: timeTo || undefined,
|
||||||
routeId: routeId || undefined,
|
routeId: routeId || undefined,
|
||||||
group: group || undefined,
|
application: application || undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -49,10 +49,10 @@ export function useStatsTimeseries(
|
|||||||
timeFrom: string | undefined,
|
timeFrom: string | undefined,
|
||||||
timeTo: string | undefined,
|
timeTo: string | undefined,
|
||||||
routeId?: string,
|
routeId?: string,
|
||||||
group?: string,
|
application?: string,
|
||||||
) {
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, group],
|
queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, application],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await api.GET('/search/stats/timeseries', {
|
const { data, error } = await api.GET('/search/stats/timeseries', {
|
||||||
params: {
|
params: {
|
||||||
@@ -61,7 +61,7 @@ export function useStatsTimeseries(
|
|||||||
to: timeTo || undefined,
|
to: timeTo || undefined,
|
||||||
buckets: 24,
|
buckets: 24,
|
||||||
routeId: routeId || undefined,
|
routeId: routeId || undefined,
|
||||||
group: group || undefined,
|
application: application || undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
25
ui/src/api/queries/processor-metrics.ts
Normal file
25
ui/src/api/queries/processor-metrics.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { config } from '../../config';
|
||||||
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
|
||||||
|
export function useProcessorMetrics(routeId: string | null, appId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['processor-metrics', routeId, appId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const token = useAuthStore.getState().accessToken;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (routeId) params.set('routeId', routeId);
|
||||||
|
if (appId) params.set('appId', appId);
|
||||||
|
const res = await fetch(`${config.apiBaseUrl}/routes/metrics/processors?${params}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!routeId,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
3770
ui/src/api/schema.d.ts
vendored
3770
ui/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import { Outlet, useNavigate, useLocation } from 'react-router';
|
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||||
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, useCommandPalette, Dropdown, Avatar } from '@cameleer/design-system';
|
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, useCommandPalette } from '@cameleer/design-system';
|
||||||
import { useRouteCatalog } from '../api/queries/catalog';
|
import { useRouteCatalog } from '../api/queries/catalog';
|
||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
@@ -41,6 +41,11 @@ function LayoutContent() {
|
|||||||
}));
|
}));
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
}, [logout, navigate]);
|
||||||
|
|
||||||
const handlePaletteSelect = useCallback((result: any) => {
|
const handlePaletteSelect = useCallback((result: any) => {
|
||||||
if (result.path) navigate(result.path);
|
if (result.path) navigate(result.path);
|
||||||
setPaletteOpen(false);
|
setPaletteOpen(false);
|
||||||
@@ -56,22 +61,11 @@ function LayoutContent() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
<TopBar
|
||||||
<TopBar
|
breadcrumb={breadcrumb}
|
||||||
breadcrumb={breadcrumb}
|
user={username ? { name: username } : undefined}
|
||||||
user={username ? { name: username } : undefined}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
{username && (
|
|
||||||
<Dropdown
|
|
||||||
trigger={<Avatar name={username} size="sm" />}
|
|
||||||
items={[
|
|
||||||
{ label: `Signed in as ${username}`, disabled: true },
|
|
||||||
{ divider: true, label: '' },
|
|
||||||
{ label: 'Logout', onClick: () => { logout(); navigate('/login'); } },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
open={paletteOpen}
|
open={paletteOpen}
|
||||||
onClose={() => setPaletteOpen(false)}
|
onClose={() => setPaletteOpen(false)}
|
||||||
@@ -87,10 +81,12 @@ function LayoutContent() {
|
|||||||
|
|
||||||
export function LayoutShell() {
|
export function LayoutShell() {
|
||||||
return (
|
return (
|
||||||
<CommandPaletteProvider>
|
<ToastProvider>
|
||||||
<GlobalFilterProvider>
|
<CommandPaletteProvider>
|
||||||
<LayoutContent />
|
<GlobalFilterProvider>
|
||||||
</GlobalFilterProvider>
|
<LayoutContent />
|
||||||
</CommandPaletteProvider>
|
</GlobalFilterProvider>
|
||||||
|
</CommandPaletteProvider>
|
||||||
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
ui/src/fonts/dm-sans-400-italic.woff2
Normal file
BIN
ui/src/fonts/dm-sans-400-italic.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/dm-sans-400.woff2
Normal file
BIN
ui/src/fonts/dm-sans-400.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/dm-sans-500.woff2
Normal file
BIN
ui/src/fonts/dm-sans-500.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/dm-sans-600.woff2
Normal file
BIN
ui/src/fonts/dm-sans-600.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/dm-sans-700.woff2
Normal file
BIN
ui/src/fonts/dm-sans-700.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/jetbrains-mono-400.woff2
Normal file
BIN
ui/src/fonts/jetbrains-mono-400.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/jetbrains-mono-500.woff2
Normal file
BIN
ui/src/fonts/jetbrains-mono-500.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/jetbrains-mono-600.woff2
Normal file
BIN
ui/src/fonts/jetbrains-mono-600.woff2
Normal file
Binary file not shown.
@@ -1,4 +1,62 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
/* DM Sans — self-hosted (GDPR compliant) */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'DM Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./fonts/dm-sans-400.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'DM Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./fonts/dm-sans-500.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'DM Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./fonts/dm-sans-600.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'DM Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./fonts/dm-sans-700.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'DM Sans';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./fonts/dm-sans-400-italic.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono — self-hosted (GDPR compliant) */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./fonts/jetbrains-mono-400.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./fonts/jetbrains-mono-500.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./fonts/jetbrains-mono-600.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: 'DM Sans', system-ui, sans-serif;
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
|
|||||||
26
ui/src/pages/Admin/AdminLayout.tsx
Normal file
26
ui/src/pages/Admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||||
|
import { Tabs } from '@cameleer/design-system';
|
||||||
|
|
||||||
|
const ADMIN_TABS = [
|
||||||
|
{ label: 'User Management', value: '/admin/rbac' },
|
||||||
|
{ label: 'Audit Log', value: '/admin/audit' },
|
||||||
|
{ label: 'OIDC', value: '/admin/oidc' },
|
||||||
|
{ label: 'Database', value: '/admin/database' },
|
||||||
|
{ label: 'OpenSearch', value: '/admin/opensearch' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tabs
|
||||||
|
tabs={ADMIN_TABS}
|
||||||
|
active={location.pathname}
|
||||||
|
onChange={(path) => navigate(path)}
|
||||||
|
/>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
402
ui/src/pages/Admin/GroupsTab.tsx
Normal file
402
ui/src/pages/Admin/GroupsTab.tsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
MonoText,
|
||||||
|
Tag,
|
||||||
|
Select,
|
||||||
|
ConfirmDialog,
|
||||||
|
Spinner,
|
||||||
|
InlineEdit,
|
||||||
|
useToast,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import {
|
||||||
|
useGroups,
|
||||||
|
useGroup,
|
||||||
|
useCreateGroup,
|
||||||
|
useUpdateGroup,
|
||||||
|
useDeleteGroup,
|
||||||
|
useAssignRoleToGroup,
|
||||||
|
useRemoveRoleFromGroup,
|
||||||
|
useAddUserToGroup,
|
||||||
|
useRemoveUserFromGroup,
|
||||||
|
useUsers,
|
||||||
|
useRoles,
|
||||||
|
} from '../../api/queries/admin/rbac';
|
||||||
|
import styles from './UserManagement.module.css';
|
||||||
|
|
||||||
|
const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010';
|
||||||
|
|
||||||
|
export default function GroupsTab() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [newGroupName, setNewGroupName] = useState('');
|
||||||
|
const [newGroupParentId, setNewGroupParentId] = useState<string>('');
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [addMemberUserId, setAddMemberUserId] = useState<string>('');
|
||||||
|
const [addRoleId, setAddRoleId] = useState<string>('');
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { data: groups = [], isLoading: groupsLoading } = useGroups();
|
||||||
|
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedGroupId);
|
||||||
|
const { data: users = [] } = useUsers();
|
||||||
|
const { data: roles = [] } = useRoles();
|
||||||
|
|
||||||
|
const createGroup = useCreateGroup();
|
||||||
|
const updateGroup = useUpdateGroup();
|
||||||
|
const deleteGroup = useDeleteGroup();
|
||||||
|
const assignRoleToGroup = useAssignRoleToGroup();
|
||||||
|
const removeRoleFromGroup = useRemoveRoleFromGroup();
|
||||||
|
const addUserToGroup = useAddUserToGroup();
|
||||||
|
const removeUserFromGroup = useRemoveUserFromGroup();
|
||||||
|
|
||||||
|
const filteredGroups = groups.filter((g) =>
|
||||||
|
g.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentOptions = [
|
||||||
|
{ value: '', label: 'Top-level' },
|
||||||
|
...groups.map((g) => ({ value: g.id, label: g.name })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const parentName = (parentGroupId: string | null) => {
|
||||||
|
if (!parentGroupId) return 'Top-level';
|
||||||
|
const parent = groups.find((g) => g.id === parentGroupId);
|
||||||
|
return parent ? parent.name : parentGroupId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const name = newGroupName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
await createGroup.mutateAsync({
|
||||||
|
name,
|
||||||
|
parentGroupId: newGroupParentId || null,
|
||||||
|
});
|
||||||
|
toast({ title: 'Group created', variant: 'success' });
|
||||||
|
setNewGroupName('');
|
||||||
|
setNewGroupParentId('');
|
||||||
|
setShowCreate(false);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to create group', variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async (newName: string) => {
|
||||||
|
if (!selectedGroup) return;
|
||||||
|
try {
|
||||||
|
await updateGroup.mutateAsync({
|
||||||
|
id: selectedGroup.id,
|
||||||
|
name: newName,
|
||||||
|
parentGroupId: selectedGroup.parentGroupId,
|
||||||
|
});
|
||||||
|
toast({ title: 'Group renamed', variant: 'success' });
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to rename group', variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedGroup) return;
|
||||||
|
try {
|
||||||
|
await deleteGroup.mutateAsync(selectedGroup.id);
|
||||||
|
toast({ title: 'Group deleted', variant: 'success' });
|
||||||
|
setSelectedGroupId(null);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to delete group', variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddMember = async () => {
|
||||||
|
if (!selectedGroup || !addMemberUserId) return;
|
||||||
|
try {
|
||||||
|
await addUserToGroup.mutateAsync({
|
||||||
|
userId: addMemberUserId,
|
||||||
|
groupId: selectedGroup.id,
|
||||||
|
});
|
||||||
|
toast({ title: 'Member added', variant: 'success' });
|
||||||
|
setAddMemberUserId('');
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to add member', variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMember = async (userId: string) => {
|
||||||
|
if (!selectedGroup) return;
|
||||||
|
try {
|
||||||
|
await removeUserFromGroup.mutateAsync({ userId, groupId: selectedGroup.id });
|
||||||
|
toast({ title: 'Member removed', variant: 'success' });
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to remove member', variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRole = async () => {
|
||||||
|
if (!selectedGroup || !addRoleId) return;
|
||||||
|
try {
|
||||||
|
await assignRoleToGroup.mutateAsync({
|
||||||
|
groupId: selectedGroup.id,
|
||||||
|
roleId: addRoleId,
|
||||||
|
});
|
||||||
|
toast({ title: 'Role assigned', variant: 'success' });
|
||||||
|
setAddRoleId('');
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to assign role', variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveRole = async (roleId: string) => {
|
||||||
|
if (!selectedGroup) return;
|
||||||
|
try {
|
||||||
|
await removeRoleFromGroup.mutateAsync({ groupId: selectedGroup.id, roleId });
|
||||||
|
toast({ title: 'Role removed', variant: 'success' });
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to remove role', variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
|
||||||
|
|
||||||
|
// Build sets for quick lookup of already-assigned items
|
||||||
|
const memberUserIds = new Set((selectedGroup?.members ?? []).map((m) => m.userId));
|
||||||
|
const assignedRoleIds = new Set((selectedGroup?.directRoles ?? []).map((r) => r.id));
|
||||||
|
|
||||||
|
const availableUsers = users.filter((u) => !memberUserIds.has(u.userId));
|
||||||
|
const availableRoles = roles.filter((r) => !assignedRoleIds.has(r.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.splitPane}>
|
||||||
|
{/* Left pane */}
|
||||||
|
<div className={styles.listPane}>
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
<Input
|
||||||
|
placeholder="Search groups..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onClear={() => setSearch('')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowCreate((v) => !v)}
|
||||||
|
>
|
||||||
|
+ Add Group
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className={styles.createForm}>
|
||||||
|
<Input
|
||||||
|
placeholder="Group name"
|
||||||
|
value={newGroupName}
|
||||||
|
onChange={(e) => setNewGroupName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Select
|
||||||
|
options={parentOptions}
|
||||||
|
value={newGroupParentId}
|
||||||
|
onChange={(e) => setNewGroupParentId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.createFormActions}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreate(false);
|
||||||
|
setNewGroupName('');
|
||||||
|
setNewGroupParentId('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
loading={createGroup.isPending}
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!newGroupName.trim()}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupsLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<div className={styles.entityList} role="listbox">
|
||||||
|
{filteredGroups.map((group) => {
|
||||||
|
const isSelected = group.id === selectedGroupId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
className={
|
||||||
|
styles.entityItem +
|
||||||
|
(isSelected ? ' ' + styles.entityItemSelected : '')
|
||||||
|
}
|
||||||
|
onClick={() => setSelectedGroupId(group.id)}
|
||||||
|
>
|
||||||
|
<Avatar name={group.name} size="sm" />
|
||||||
|
<div className={styles.entityInfo}>
|
||||||
|
<div className={styles.entityName}>{group.name}</div>
|
||||||
|
<div className={styles.entityMeta}>
|
||||||
|
{group.parentGroupId
|
||||||
|
? `Child of ${parentName(group.parentGroupId)}`
|
||||||
|
: 'Top-level'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right pane */}
|
||||||
|
<div className={styles.detailPane}>
|
||||||
|
{!selectedGroupId ? (
|
||||||
|
<div className={styles.emptyDetail}>Select a group to view details</div>
|
||||||
|
) : detailLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : selectedGroup ? (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<Avatar name={selectedGroup.name} size="md" />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<InlineEdit
|
||||||
|
value={selectedGroup.name}
|
||||||
|
onSave={handleRename}
|
||||||
|
disabled={isBuiltinAdmins}
|
||||||
|
/>
|
||||||
|
<div className={styles.entityMeta}>
|
||||||
|
{selectedGroup.parentGroupId
|
||||||
|
? `Child of ${parentName(selectedGroup.parentGroupId)}`
|
||||||
|
: 'Top-level'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
disabled={isBuiltinAdmins}
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>Group ID</span>
|
||||||
|
<MonoText size="xs">{selectedGroup.id}</MonoText>
|
||||||
|
<span className={styles.metaLabel}>Parent</span>
|
||||||
|
<span>{parentName(selectedGroup.parentGroupId)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members */}
|
||||||
|
<div className={styles.sectionTitle}>Members</div>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{(selectedGroup.members ?? []).map((member) => (
|
||||||
|
<Tag
|
||||||
|
key={member.userId}
|
||||||
|
label={member.displayName}
|
||||||
|
onRemove={() => handleRemoveMember(member.userId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{(selectedGroup.members ?? []).length === 0 && (
|
||||||
|
<span className={styles.inheritedNote}>No members</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Add member...' },
|
||||||
|
...availableUsers.map((u) => ({
|
||||||
|
value: u.userId,
|
||||||
|
label: u.displayName,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
value={addMemberUserId}
|
||||||
|
onChange={(e) => setAddMemberUserId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleAddMember}
|
||||||
|
disabled={!addMemberUserId || addUserToGroup.isPending}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assigned roles */}
|
||||||
|
<div className={styles.sectionTitle}>Assigned Roles</div>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{(selectedGroup.directRoles ?? []).map((role) => (
|
||||||
|
<Badge
|
||||||
|
key={role.id}
|
||||||
|
label={role.name}
|
||||||
|
variant="outlined"
|
||||||
|
onRemove={() => handleRemoveRole(role.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{(selectedGroup.directRoles ?? []).length === 0 && (
|
||||||
|
<span className={styles.inheritedNote}>No roles assigned</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(selectedGroup.effectiveRoles ?? []).length >
|
||||||
|
(selectedGroup.directRoles ?? []).length && (
|
||||||
|
<div className={styles.inheritedNote}>
|
||||||
|
+
|
||||||
|
{(selectedGroup.effectiveRoles ?? []).length -
|
||||||
|
(selectedGroup.directRoles ?? []).length}{' '}
|
||||||
|
inherited role(s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Assign role...' },
|
||||||
|
...availableRoles.map((r) => ({
|
||||||
|
value: r.id,
|
||||||
|
label: r.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
value={addRoleId}
|
||||||
|
onChange={(e) => setAddRoleId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleAddRole}
|
||||||
|
disabled={!addRoleId || assignRoleToGroup.isPending}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Delete Group"
|
||||||
|
message={`Delete group "${selectedGroup?.name}"? This action cannot be undone.`}
|
||||||
|
confirmText="DELETE"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteGroup.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
ui/src/pages/Admin/OidcConfigPage.module.css
Normal file
28
ui/src/pages/Admin/OidcConfigPage.module.css
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
.section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h3 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addRow input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader } from '@cameleer/design-system';
|
import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader, Tag, ConfirmDialog } from '@cameleer/design-system';
|
||||||
import { adminFetch } from '../../api/queries/admin/admin-api';
|
import { adminFetch } from '../../api/queries/admin/admin-api';
|
||||||
|
import styles from './OidcConfigPage.module.css';
|
||||||
|
|
||||||
interface OidcConfig {
|
interface OidcConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -18,6 +19,8 @@ export default function OidcConfigPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [newRole, setNewRole] = useState('');
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminFetch<OidcConfig>('/oidc')
|
adminFetch<OidcConfig>('/oidc')
|
||||||
@@ -64,15 +67,44 @@ export default function OidcConfigPage() {
|
|||||||
<FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField>
|
<FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField>
|
||||||
<Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" />
|
<Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" />
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3>Default Roles</h3>
|
||||||
|
<div className={styles.tagRow}>
|
||||||
|
{(config.defaultRoles || []).map(role => (
|
||||||
|
<Tag key={role} label={role} onRemove={() => {
|
||||||
|
setConfig(prev => ({ ...prev!, defaultRoles: prev!.defaultRoles.filter(r => r !== role) }));
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.addRow}>
|
||||||
|
<Input placeholder="Add role..." value={newRole} onChange={e => setNewRole(e.target.value)} />
|
||||||
|
<Button onClick={() => {
|
||||||
|
if (newRole.trim() && !config.defaultRoles?.includes(newRole.trim())) {
|
||||||
|
setConfig(prev => ({ ...prev!, defaultRoles: [...(prev!.defaultRoles || []), newRole.trim()] }));
|
||||||
|
setNewRole('');
|
||||||
|
}
|
||||||
|
}}>Add</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||||
<Button variant="danger" onClick={handleDelete}>Remove Config</Button>
|
<Button variant="danger" onClick={() => setDeleteOpen(true)}>Delete Configuration</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <Alert variant="error">{error}</Alert>}
|
{error && <Alert variant="error">{error}</Alert>}
|
||||||
{success && <Alert variant="success">Configuration saved</Alert>}
|
{success && <Alert variant="success">Configuration saved</Alert>}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Delete OIDC Configuration"
|
||||||
|
message="Delete OIDC configuration? All OIDC users will lose access."
|
||||||
|
confirmText="DELETE"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function OpenSearchAdminPage() {
|
|||||||
const indexColumns: Column<any>[] = [
|
const indexColumns: Column<any>[] = [
|
||||||
{ key: 'name', header: 'Index' },
|
{ key: 'name', header: 'Index' },
|
||||||
{ key: 'health', header: 'Health', render: (v) => <Badge label={String(v)} color={v === 'green' ? 'success' : v === 'yellow' ? 'warning' : 'error'} /> },
|
{ key: 'health', header: 'Health', render: (v) => <Badge label={String(v)} color={v === 'green' ? 'success' : v === 'yellow' ? 'warning' : 'error'} /> },
|
||||||
{ key: 'docCount', header: 'Documents', sortable: true },
|
{ key: 'docCount', header: 'Documents', sortable: true, render: (v) => Number(v).toLocaleString() },
|
||||||
{ key: 'size', header: 'Size' },
|
{ key: 'size', header: 'Size' },
|
||||||
{ key: 'primaryShards', header: 'Shards' },
|
{ key: 'primaryShards', header: 'Shards' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,178 +1,35 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import { StatCard, Tabs } from '@cameleer/design-system';
|
||||||
Tabs, DataTable, Badge, Avatar, Button, Input, Modal, FormField,
|
import { useRbacStats } from '../../api/queries/admin/rbac';
|
||||||
Select, AlertDialog, StatCard, Spinner,
|
import styles from './UserManagement.module.css';
|
||||||
} from '@cameleer/design-system';
|
import UsersTab from './UsersTab';
|
||||||
import type { Column } from '@cameleer/design-system';
|
import GroupsTab from './GroupsTab';
|
||||||
import {
|
import RolesTab from './RolesTab';
|
||||||
useUsers, useUser, useGroups, useGroup, useRoles, useRole, useRbacStats,
|
|
||||||
useCreateUser, useUpdateUser, useDeleteUser,
|
|
||||||
useAssignRoleToUser, useRemoveRoleFromUser,
|
|
||||||
useAddUserToGroup, useRemoveUserFromGroup,
|
|
||||||
useCreateGroup, useUpdateGroup, useDeleteGroup,
|
|
||||||
useCreateRole, useUpdateRole, useDeleteRole,
|
|
||||||
useAssignRoleToGroup, useRemoveRoleFromGroup,
|
|
||||||
} from '../../api/queries/admin/rbac';
|
|
||||||
|
|
||||||
export default function RbacPage() {
|
export default function RbacPage() {
|
||||||
const [tab, setTab] = useState('users');
|
|
||||||
const { data: stats } = useRbacStats();
|
const { data: stats } = useRbacStats();
|
||||||
|
const [tab, setTab] = useState('users');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginBottom: '1rem' }}>RBAC Management</h2>
|
<h2 style={{ margin: '0 0 16px' }}>User Management</h2>
|
||||||
|
<div className={styles.statStrip}>
|
||||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
|
||||||
<StatCard label="Users" value={stats?.userCount ?? 0} />
|
<StatCard label="Users" value={stats?.userCount ?? 0} />
|
||||||
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
|
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
|
||||||
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
|
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ label: 'Users', value: 'users', count: stats?.userCount },
|
{ label: 'Users', value: 'users' },
|
||||||
{ label: 'Groups', value: 'groups', count: stats?.groupCount },
|
{ label: 'Groups', value: 'groups' },
|
||||||
{ label: 'Roles', value: 'roles', count: stats?.roleCount },
|
{ label: 'Roles', value: 'roles' },
|
||||||
]}
|
]}
|
||||||
active={tab}
|
active={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
/>
|
/>
|
||||||
|
{tab === 'users' && <UsersTab />}
|
||||||
<div style={{ marginTop: '1rem' }}>
|
{tab === 'groups' && <GroupsTab />}
|
||||||
{tab === 'users' && <UsersTab />}
|
{tab === 'roles' && <RolesTab />}
|
||||||
{tab === 'groups' && <GroupsTab />}
|
|
||||||
{tab === 'roles' && <RolesTab />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsersTab() {
|
|
||||||
const { data: users, isLoading } = useUsers();
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
||||||
const [form, setForm] = useState({ username: '', displayName: '', email: '', password: '' });
|
|
||||||
const createUser = useCreateUser();
|
|
||||||
const deleteUser = useDeleteUser();
|
|
||||||
|
|
||||||
const columns: Column<any>[] = [
|
|
||||||
{ key: 'userId', header: 'Username', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
|
|
||||||
{ key: 'displayName', header: 'Display Name' },
|
|
||||||
{ key: 'email', header: 'Email' },
|
|
||||||
{ key: 'provider', header: 'Provider', render: (v) => <Badge label={String(v)} /> },
|
|
||||||
{
|
|
||||||
key: 'effectiveRoles', header: 'Roles',
|
|
||||||
render: (v) => (
|
|
||||||
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
|
||||||
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
|
||||||
|
|
||||||
const rows = (users || []).map((u: any) => ({ ...u, id: u.userId }));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
|
|
||||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create User</Button>
|
|
||||||
</div>
|
|
||||||
<DataTable columns={columns} data={rows} pageSize={20} />
|
|
||||||
|
|
||||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create User">
|
|
||||||
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
|
|
||||||
<FormField label="Username" required><Input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /></FormField>
|
|
||||||
<FormField label="Display Name"><Input value={form.displayName} onChange={(e) => setForm({ ...form, displayName: e.target.value })} /></FormField>
|
|
||||||
<FormField label="Email"><Input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></FormField>
|
|
||||||
<FormField label="Password"><Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} /></FormField>
|
|
||||||
<Button variant="primary" onClick={() => { createUser.mutate(form); setCreateOpen(false); setForm({ username: '', displayName: '', email: '', password: '' }); }}>Create</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={!!deleteId}
|
|
||||||
onClose={() => setDeleteId(null)}
|
|
||||||
onConfirm={() => { if (deleteId) deleteUser.mutate(deleteId); setDeleteId(null); }}
|
|
||||||
title="Delete User"
|
|
||||||
description={`Are you sure you want to delete user "${deleteId}"?`}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
variant="danger"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupsTab() {
|
|
||||||
const { data: groups, isLoading } = useGroups();
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [form, setForm] = useState({ name: '' });
|
|
||||||
const createGroup = useCreateGroup();
|
|
||||||
|
|
||||||
const columns: Column<any>[] = [
|
|
||||||
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
|
|
||||||
{ key: 'members', header: 'Members', render: (v) => String((v as any[])?.length ?? 0) },
|
|
||||||
{
|
|
||||||
key: 'effectiveRoles', header: 'Roles',
|
|
||||||
render: (v) => (
|
|
||||||
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
|
||||||
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
|
|
||||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Group</Button>
|
|
||||||
</div>
|
|
||||||
<DataTable columns={columns} data={groups || []} pageSize={20} />
|
|
||||||
|
|
||||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Group">
|
|
||||||
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
|
|
||||||
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
|
|
||||||
<Button variant="primary" onClick={() => { createGroup.mutate(form); setCreateOpen(false); setForm({ name: '' }); }}>Create</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RolesTab() {
|
|
||||||
const { data: roles, isLoading } = useRoles();
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [form, setForm] = useState({ name: '', description: '', scope: '' });
|
|
||||||
const createRole = useCreateRole();
|
|
||||||
|
|
||||||
const columns: Column<any>[] = [
|
|
||||||
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
|
|
||||||
{ key: 'description', header: 'Description' },
|
|
||||||
{ key: 'scope', header: 'Scope', render: (v) => v ? <Badge label={String(v)} /> : null },
|
|
||||||
{ key: 'system', header: 'System', render: (v) => v ? <Badge label="System" color="warning" /> : null },
|
|
||||||
{ key: 'effectivePrincipals', header: 'Users', render: (v) => String((v as any[])?.length ?? 0) },
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
|
|
||||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Role</Button>
|
|
||||||
</div>
|
|
||||||
<DataTable columns={columns} data={roles || []} pageSize={20} />
|
|
||||||
|
|
||||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Role">
|
|
||||||
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
|
|
||||||
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
|
|
||||||
<FormField label="Description"><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></FormField>
|
|
||||||
<FormField label="Scope"><Input value={form.scope} onChange={(e) => setForm({ ...form, scope: e.target.value })} /></FormField>
|
|
||||||
<Button variant="primary" onClick={() => { createRole.mutate(form); setCreateOpen(false); setForm({ name: '', description: '', scope: '' }); }}>Create</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
305
ui/src/pages/Admin/RolesTab.tsx
Normal file
305
ui/src/pages/Admin/RolesTab.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
Input,
|
||||||
|
MonoText,
|
||||||
|
Spinner,
|
||||||
|
Tag,
|
||||||
|
useToast,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import {
|
||||||
|
useRoles,
|
||||||
|
useRole,
|
||||||
|
useCreateRole,
|
||||||
|
useDeleteRole,
|
||||||
|
} from '../../api/queries/admin/rbac';
|
||||||
|
import type { RoleDetail } from '../../api/queries/admin/rbac';
|
||||||
|
import styles from './UserManagement.module.css';
|
||||||
|
|
||||||
|
export default function RolesTab() {
|
||||||
|
const { data: roles, isLoading } = useRoles();
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [newDescription, setNewDescription] = useState('');
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
const { data: detail, isLoading: detailLoading } = useRole(selectedId);
|
||||||
|
const createRole = useCreateRole();
|
||||||
|
const deleteRole = useDeleteRole();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const filtered = (roles ?? []).filter((r) =>
|
||||||
|
r.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
createRole.mutate(
|
||||||
|
{ name: newName.trim(), description: newDescription.trim() || undefined },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: 'Role created', variant: 'success' });
|
||||||
|
setShowCreate(false);
|
||||||
|
setNewName('');
|
||||||
|
setNewDescription('');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: 'Failed to create role', variant: 'error' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!selectedId) return;
|
||||||
|
deleteRole.mutate(selectedId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: 'Role deleted', variant: 'success' });
|
||||||
|
setSelectedId(null);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: 'Failed to delete role', variant: 'error' });
|
||||||
|
setConfirmDelete(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.splitPane}>
|
||||||
|
{/* Left pane — list */}
|
||||||
|
<div className={styles.listPane}>
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
<Input
|
||||||
|
placeholder="Search roles…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowCreate((v) => !v)}
|
||||||
|
>
|
||||||
|
+ Add Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className={styles.createForm}>
|
||||||
|
<Input
|
||||||
|
placeholder="Role name (e.g. EDITOR)"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value.toUpperCase())}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
value={newDescription}
|
||||||
|
onChange={(e) => setNewDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className={styles.createFormActions}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreate(false);
|
||||||
|
setNewName('');
|
||||||
|
setNewDescription('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={createRole.isPending}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<div className={styles.entityList} role="listbox">
|
||||||
|
{filtered.map((role) => {
|
||||||
|
const assignmentCount =
|
||||||
|
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={role.id}
|
||||||
|
className={
|
||||||
|
styles.entityItem +
|
||||||
|
(selectedId === role.id ? ' ' + styles.entityItemSelected : '')
|
||||||
|
}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedId === role.id}
|
||||||
|
onClick={() => setSelectedId(role.id)}
|
||||||
|
>
|
||||||
|
<Avatar name={role.name} size="sm" />
|
||||||
|
<div className={styles.entityInfo}>
|
||||||
|
<div className={styles.entityName}>
|
||||||
|
{role.name}
|
||||||
|
{role.system && <Badge label="system" variant="outlined" />}
|
||||||
|
</div>
|
||||||
|
<div className={styles.entityMeta}>
|
||||||
|
{role.description || '—'} · {assignmentCount} assignment
|
||||||
|
{assignmentCount !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
{((role.assignedGroups?.length ?? 0) > 0 ||
|
||||||
|
(role.directUsers?.length ?? 0) > 0) && (
|
||||||
|
<div className={styles.entityTags}>
|
||||||
|
{(role.assignedGroups ?? []).map((g) => (
|
||||||
|
<Tag key={g.id} label={g.name} color="success" />
|
||||||
|
))}
|
||||||
|
{(role.directUsers ?? []).map((u) => (
|
||||||
|
<Tag key={u.userId} label={u.displayName} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right pane — detail */}
|
||||||
|
<div className={styles.detailPane}>
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className={styles.emptyDetail}>Select a role to view details</div>
|
||||||
|
) : detailLoading || !detail ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<RoleDetailPanel
|
||||||
|
role={detail}
|
||||||
|
onDeleteRequest={() => setConfirmDelete(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detail && (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete}
|
||||||
|
onClose={() => setConfirmDelete(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Delete role"
|
||||||
|
message={`Delete role "${detail.name}"? This cannot be undone.`}
|
||||||
|
confirmText={detail.name}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteRole.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail panel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RoleDetailPanelProps {
|
||||||
|
role: RoleDetail;
|
||||||
|
onDeleteRequest: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) {
|
||||||
|
// Build a set of directly-assigned user IDs for distinguishing inherited principals
|
||||||
|
const directUserIds = new Set((role.directUsers ?? []).map((u) => u.userId));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<Avatar name={role.name} size="md" />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 16 }}>{role.name}</div>
|
||||||
|
{role.description && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||||
|
{role.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
disabled={role.system}
|
||||||
|
onClick={onDeleteRequest}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>ID</span>
|
||||||
|
<MonoText size="xs">{role.id}</MonoText>
|
||||||
|
|
||||||
|
<span className={styles.metaLabel}>Scope</span>
|
||||||
|
<span>{role.scope || '—'}</span>
|
||||||
|
|
||||||
|
<span className={styles.metaLabel}>Type</span>
|
||||||
|
<span>{role.system ? 'System role (read-only)' : 'Custom role'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assigned to groups */}
|
||||||
|
<div className={styles.sectionTitle}>Assigned to groups</div>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{(role.assignedGroups ?? []).length === 0 ? (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
||||||
|
) : (
|
||||||
|
(role.assignedGroups ?? []).map((g) => (
|
||||||
|
<Tag key={g.id} label={g.name} color="success" />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assigned to users (direct) */}
|
||||||
|
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{(role.directUsers ?? []).length === 0 ? (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
||||||
|
) : (
|
||||||
|
(role.directUsers ?? []).map((u) => (
|
||||||
|
<Tag key={u.userId} label={u.displayName} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Effective principals */}
|
||||||
|
<div className={styles.sectionTitle}>Effective principals</div>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{(role.effectivePrincipals ?? []).length === 0 ? (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
||||||
|
) : (
|
||||||
|
(role.effectivePrincipals ?? []).map((u) => {
|
||||||
|
const isDirect = directUserIds.has(u.userId);
|
||||||
|
return isDirect ? (
|
||||||
|
<Badge key={u.userId} label={u.displayName} variant="filled" />
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
key={u.userId}
|
||||||
|
label={`↑ ${u.displayName}`}
|
||||||
|
variant="dashed"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && (
|
||||||
|
<div className={styles.inheritedNote}>
|
||||||
|
Dashed entries inherit this role through group membership
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
ui/src/pages/Admin/UserManagement.module.css
Normal file
191
ui/src/pages/Admin/UserManagement.module.css
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
.statStrip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitPane {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52fr 48fr;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
min-height: 500px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listPane {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPane {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeader input { flex: 1; }
|
||||||
|
|
||||||
|
.entityList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItem:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItemSelected {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityName {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityMeta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityTags {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createForm {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 1fr;
|
||||||
|
gap: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaLabel {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTags {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inheritedNote {
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securitySection {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resetForm {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyDetail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptySearch {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerBadge {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
535
ui/src/pages/Admin/UsersTab.tsx
Normal file
535
ui/src/pages/Admin/UsersTab.tsx
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
MonoText,
|
||||||
|
Tag,
|
||||||
|
InfoCallout,
|
||||||
|
ConfirmDialog,
|
||||||
|
Select,
|
||||||
|
Spinner,
|
||||||
|
InlineEdit,
|
||||||
|
useToast,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import {
|
||||||
|
useUsers,
|
||||||
|
useCreateUser,
|
||||||
|
useDeleteUser,
|
||||||
|
useAssignRoleToUser,
|
||||||
|
useRemoveRoleFromUser,
|
||||||
|
useAddUserToGroup,
|
||||||
|
useRemoveUserFromGroup,
|
||||||
|
useSetPassword,
|
||||||
|
useGroups,
|
||||||
|
useRoles,
|
||||||
|
} from '../../api/queries/admin/rbac';
|
||||||
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
import styles from './UserManagement.module.css';
|
||||||
|
|
||||||
|
export default function UsersTab() {
|
||||||
|
const { data: users, isLoading } = useUsers();
|
||||||
|
const { data: allGroups } = useGroups();
|
||||||
|
const { data: allRoles } = useRoles();
|
||||||
|
const currentUsername = useAuthStore((s) => s.username);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
|
||||||
|
// Create form state
|
||||||
|
const [createUsername, setCreateUsername] = useState('');
|
||||||
|
const [createDisplayName, setCreateDisplayName] = useState('');
|
||||||
|
const [createEmail, setCreateEmail] = useState('');
|
||||||
|
const [createPassword, setCreatePassword] = useState('');
|
||||||
|
|
||||||
|
// Detail pane state
|
||||||
|
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [addGroupId, setAddGroupId] = useState('');
|
||||||
|
const [addRoleId, setAddRoleId] = useState('');
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createUser = useCreateUser();
|
||||||
|
const deleteUser = useDeleteUser();
|
||||||
|
const assignRole = useAssignRoleToUser();
|
||||||
|
const removeRole = useRemoveRoleFromUser();
|
||||||
|
const addToGroup = useAddUserToGroup();
|
||||||
|
const removeFromGroup = useRemoveUserFromGroup();
|
||||||
|
const setPassword = useSetPassword();
|
||||||
|
|
||||||
|
// Filtered user list
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
if (!users) return [];
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
if (!q) return users;
|
||||||
|
return users.filter(
|
||||||
|
(u) =>
|
||||||
|
u.displayName.toLowerCase().includes(q) ||
|
||||||
|
(u.email ?? '').toLowerCase().includes(q) ||
|
||||||
|
u.userId.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [users, search]);
|
||||||
|
|
||||||
|
const selectedUser = useMemo(
|
||||||
|
() => users?.find((u) => u.userId === selectedUserId) ?? null,
|
||||||
|
[users, selectedUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleCreateUser() {
|
||||||
|
if (!createUsername.trim() || !createPassword.trim()) return;
|
||||||
|
createUser.mutate(
|
||||||
|
{
|
||||||
|
username: createUsername.trim(),
|
||||||
|
displayName: createDisplayName.trim() || undefined,
|
||||||
|
email: createEmail.trim() || undefined,
|
||||||
|
password: createPassword,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: 'User created', variant: 'success' });
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setCreateUsername('');
|
||||||
|
setCreateDisplayName('');
|
||||||
|
setCreateEmail('');
|
||||||
|
setCreatePassword('');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: 'Failed to create user', variant: 'error' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResetPassword() {
|
||||||
|
if (!selectedUser || !newPassword.trim()) return;
|
||||||
|
setPassword.mutate(
|
||||||
|
{ userId: selectedUser.userId, password: newPassword },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: 'Password updated', variant: 'success' });
|
||||||
|
setShowPasswordForm(false);
|
||||||
|
setNewPassword('');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: 'Failed to update password', variant: 'error' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddGroup() {
|
||||||
|
if (!selectedUser || !addGroupId) return;
|
||||||
|
addToGroup.mutate(
|
||||||
|
{ userId: selectedUser.userId, groupId: addGroupId },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: 'Added to group', variant: 'success' });
|
||||||
|
setAddGroupId('');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: 'Failed to add group', variant: 'error' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddRole() {
|
||||||
|
if (!selectedUser || !addRoleId) return;
|
||||||
|
assignRole.mutate(
|
||||||
|
{ userId: selectedUser.userId, roleId: addRoleId },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: 'Role assigned', variant: 'success' });
|
||||||
|
setAddRoleId('');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: 'Failed to assign role', variant: 'error' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteUser() {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
deleteUser.mutate(selectedUser.userId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: 'User deleted', variant: 'success' });
|
||||||
|
setSelectedUserId(null);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: 'Failed to delete user', variant: 'error' });
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derived data for detail pane
|
||||||
|
const directGroupIds = new Set(selectedUser?.directGroups.map((g) => g.id) ?? []);
|
||||||
|
const directRoleIds = new Set(selectedUser?.directRoles.map((r) => r.id) ?? []);
|
||||||
|
|
||||||
|
const inheritedRoles = selectedUser?.effectiveRoles.filter((r) => !directRoleIds.has(r.id)) ?? [];
|
||||||
|
|
||||||
|
const availableGroups = (allGroups ?? [])
|
||||||
|
.filter((g) => !directGroupIds.has(g.id))
|
||||||
|
.map((g) => ({ value: g.id, label: g.name }));
|
||||||
|
|
||||||
|
const availableRoles = (allRoles ?? [])
|
||||||
|
.filter((r) => !directRoleIds.has(r.id))
|
||||||
|
.map((r) => ({ value: r.id, label: r.name }));
|
||||||
|
|
||||||
|
// Find group name for inherited role display
|
||||||
|
function findInheritingGroupName(roleId: string): string {
|
||||||
|
if (!selectedUser) return '';
|
||||||
|
for (const g of selectedUser.effectiveGroups) {
|
||||||
|
// We don't have group→roles in the summary, so just show "group"
|
||||||
|
void roleId;
|
||||||
|
return g.name;
|
||||||
|
}
|
||||||
|
return 'group';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelf =
|
||||||
|
currentUsername != null &&
|
||||||
|
selectedUser != null &&
|
||||||
|
selectedUser.displayName === currentUsername;
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.splitPane}>
|
||||||
|
{/* ── Left pane ── */}
|
||||||
|
<div className={styles.listPane}>
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
<Input
|
||||||
|
placeholder="Search users…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onClear={() => setSearch('')}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowCreateForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showCreateForm ? '✕ Cancel' : '+ Add User'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className={styles.createForm}>
|
||||||
|
<Input
|
||||||
|
placeholder="Username (required)"
|
||||||
|
value={createUsername}
|
||||||
|
onChange={(e) => setCreateUsername(e.target.value)}
|
||||||
|
style={{ marginBottom: 6 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Display Name"
|
||||||
|
value={createDisplayName}
|
||||||
|
onChange={(e) => setCreateDisplayName(e.target.value)}
|
||||||
|
style={{ marginBottom: 6 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Email"
|
||||||
|
type="email"
|
||||||
|
value={createEmail}
|
||||||
|
onChange={(e) => setCreateEmail(e.target.value)}
|
||||||
|
style={{ marginBottom: 6 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Password (required)"
|
||||||
|
type="password"
|
||||||
|
value={createPassword}
|
||||||
|
onChange={(e) => setCreatePassword(e.target.value)}
|
||||||
|
style={{ marginBottom: 6 }}
|
||||||
|
/>
|
||||||
|
<div className={styles.createFormActions}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setCreateUsername('');
|
||||||
|
setCreateDisplayName('');
|
||||||
|
setCreateEmail('');
|
||||||
|
setCreatePassword('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={createUser.isPending}
|
||||||
|
disabled={!createUsername.trim() || !createPassword.trim()}
|
||||||
|
onClick={handleCreateUser}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && <Spinner size="md" />}
|
||||||
|
|
||||||
|
<div className={styles.entityList} role="listbox">
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.userId}
|
||||||
|
className={
|
||||||
|
styles.entityItem +
|
||||||
|
(user.userId === selectedUserId ? ' ' + styles.entityItemSelected : '')
|
||||||
|
}
|
||||||
|
role="option"
|
||||||
|
aria-selected={user.userId === selectedUserId}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setSelectedUserId(user.userId)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedUserId(user.userId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar name={user.displayName} size="sm" />
|
||||||
|
<div className={styles.entityInfo}>
|
||||||
|
<div className={styles.entityName}>
|
||||||
|
{user.displayName}
|
||||||
|
{user.provider !== 'local' && (
|
||||||
|
<Badge label={user.provider} variant="outlined" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.entityMeta}>
|
||||||
|
{user.email || user.userId}
|
||||||
|
{user.directGroups.length > 0 && ` · ${user.directGroups.map((g) => g.name).join(', ')}`}
|
||||||
|
{user.directGroups.length === 0 && ' · no groups'}
|
||||||
|
</div>
|
||||||
|
{(user.directRoles.length > 0 || user.directGroups.length > 0) && (
|
||||||
|
<div className={styles.entityTags}>
|
||||||
|
{user.directRoles.map((r) => (
|
||||||
|
<Badge key={r.id} label={r.name} variant="filled" color="primary" />
|
||||||
|
))}
|
||||||
|
{user.directGroups.map((g) => (
|
||||||
|
<Badge key={g.id} label={g.name} variant="outlined" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right pane ── */}
|
||||||
|
<div className={styles.detailPane}>
|
||||||
|
{!selectedUser ? (
|
||||||
|
<div className={styles.emptyDetail}>Select a user to view details</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<Avatar name={selectedUser.displayName} size="lg" />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<InlineEdit
|
||||||
|
value={selectedUser.displayName}
|
||||||
|
onSave={(val) => {
|
||||||
|
// useUpdateUser not imported here to keep things clean;
|
||||||
|
// display only — wired via displayName update if desired
|
||||||
|
void val;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selectedUser.email && (
|
||||||
|
<div className={styles.entityMeta}>{selectedUser.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
disabled={isSelf}
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata grid */}
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>User ID</span>
|
||||||
|
<MonoText size="sm">{selectedUser.userId}</MonoText>
|
||||||
|
|
||||||
|
<span className={styles.metaLabel}>Created</span>
|
||||||
|
<span>{new Date(selectedUser.createdAt).toLocaleString()}</span>
|
||||||
|
|
||||||
|
<span className={styles.metaLabel}>Provider</span>
|
||||||
|
<span>{selectedUser.provider}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security section */}
|
||||||
|
<div className={styles.securitySection}>
|
||||||
|
<div className={styles.sectionTitle}>Security</div>
|
||||||
|
{selectedUser.provider === 'local' ? (
|
||||||
|
<>
|
||||||
|
{!showPasswordForm ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowPasswordForm(true)}
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className={styles.resetForm}>
|
||||||
|
<Input
|
||||||
|
placeholder="New password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPasswordForm(false);
|
||||||
|
setNewPassword('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={setPassword.isPending}
|
||||||
|
disabled={!newPassword.trim()}
|
||||||
|
onClick={handleResetPassword}
|
||||||
|
>
|
||||||
|
Set
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<InfoCallout variant="info">
|
||||||
|
Password managed by identity provider
|
||||||
|
</InfoCallout>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group membership */}
|
||||||
|
<div className={styles.sectionTitle}>Group Membership</div>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{selectedUser.directGroups.map((g) => (
|
||||||
|
<Tag
|
||||||
|
key={g.id}
|
||||||
|
label={g.name}
|
||||||
|
onRemove={() =>
|
||||||
|
removeFromGroup.mutate(
|
||||||
|
{ userId: selectedUser.userId, groupId: g.id },
|
||||||
|
{
|
||||||
|
onError: () =>
|
||||||
|
toast({ title: 'Failed to remove group', variant: 'error' }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{availableGroups.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
|
<Select
|
||||||
|
options={[{ value: '', label: 'Add to group…' }, ...availableGroups]}
|
||||||
|
value={addGroupId}
|
||||||
|
onChange={(e) => setAddGroupId(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={!addGroupId}
|
||||||
|
onClick={handleAddGroup}
|
||||||
|
loading={addToGroup.isPending}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Effective roles */}
|
||||||
|
<div className={styles.sectionTitle}>Roles</div>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{selectedUser.directRoles.map((r) => (
|
||||||
|
<Tag
|
||||||
|
key={r.id}
|
||||||
|
label={r.name}
|
||||||
|
color="warning"
|
||||||
|
onRemove={() =>
|
||||||
|
removeRole.mutate(
|
||||||
|
{ userId: selectedUser.userId, roleId: r.id },
|
||||||
|
{
|
||||||
|
onError: () =>
|
||||||
|
toast({ title: 'Failed to remove role', variant: 'error' }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{inheritedRoles.map((r) => (
|
||||||
|
<span key={r.id} style={{ opacity: 0.65 }}>
|
||||||
|
<Badge
|
||||||
|
label={`↑ ${findInheritingGroupName(r.id)} / ${r.name}`}
|
||||||
|
variant="dashed"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{inheritedRoles.length > 0 && (
|
||||||
|
<div className={styles.inheritedNote}>
|
||||||
|
Roles with ↑ are inherited through group membership
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{availableRoles.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
|
<Select
|
||||||
|
options={[{ value: '', label: 'Assign role…' }, ...availableRoles]}
|
||||||
|
value={addRoleId}
|
||||||
|
onChange={(e) => setAddRoleId(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={!addRoleId}
|
||||||
|
onClick={handleAddRole}
|
||||||
|
loading={assignRole.isPending}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showDeleteDialog}
|
||||||
|
onClose={() => setShowDeleteDialog(false)}
|
||||||
|
onConfirm={handleDeleteUser}
|
||||||
|
title="Delete user"
|
||||||
|
message={`This will permanently delete the user "${selectedUser.displayName}". Type their username to confirm.`}
|
||||||
|
confirmText={selectedUser.displayName}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteUser.isPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
.statStrip {
|
.statStrip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scopeTrail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.groupGrid {
|
.groupGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -12,15 +19,54 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceRow {
|
/* GroupCard meta strip */
|
||||||
|
.groupMeta {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
font-size: 12px;
|
||||||
padding: 8px 12px;
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupMeta strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Instance table */
|
||||||
|
.instanceTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceTable thead tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceTable thead th {
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thStatus {
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdStatus {
|
||||||
|
width: 24px;
|
||||||
|
padding: 0 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceRow {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceRow:last-child {
|
.instanceRow:last-child {
|
||||||
@@ -31,16 +77,54 @@
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.instanceRow td {
|
||||||
|
padding: 7px 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceRowActive {
|
||||||
|
background: var(--bg-selected, var(--bg-hover));
|
||||||
|
}
|
||||||
|
|
||||||
.instanceName {
|
.instanceName {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceTps {
|
.instanceMeta {
|
||||||
margin-left: auto;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceError {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--error);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceHeartbeatDead {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--error);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceHeartbeatStale {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--warning);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceLink {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceLink:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.eventCard {
|
.eventCard {
|
||||||
@@ -64,3 +148,132 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DetailPanel: Overview tab */
|
||||||
|
|
||||||
|
.overviewContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overviewRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailRow:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailRow dt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailRow dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricsSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DetailPanel: Performance tab */
|
||||||
|
|
||||||
|
.performanceContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyChart {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 80px;
|
||||||
|
background: var(--bg-surface-raised);
|
||||||
|
border: 1px dashed var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status breakdown in stat card */
|
||||||
|
.statusBreakdown {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusLive { color: var(--success); }
|
||||||
|
.statusStale { color: var(--warning); }
|
||||||
|
.statusDead { color: var(--error); }
|
||||||
|
|
||||||
|
/* Scope trail */
|
||||||
|
.scopeLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DetailPanel override */
|
||||||
|
.detailPanelOverride {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelDivider {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user