17 Commits

Author SHA1 Message Date
hsiegeln
752d7ec0e7 feat: add Users tab with split-pane layout, inline create, detail panel
Some checks failed
CI / build (push) Failing after 39s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:32:45 +01:00
hsiegeln
9ab38dfc59 feat: add Groups tab with hierarchy management and member/role assignment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:32:18 +01:00
hsiegeln
907bcd5017 feat: add Roles tab with system role protection and principal display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:32:07 +01:00
hsiegeln
83caf4be5b feat: align Agent Instance with mock — JVM charts, process info, stat cards, log placeholder
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:29:25 +01:00
hsiegeln
1533bea2a6 refactor: restructure RBAC page to container + tab components, add CSS module
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:28:52 +01:00
hsiegeln
94d1e81852 feat: add Route Detail page with diagram, processor stats, and tabbed sections
Replaces the filtered RoutesMetrics view at /routes/:appId/:routeId with a
dedicated RouteDetail page showing route diagram, processor stats table,
performance charts, recent executions, and client-side grouped error patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:25:58 +01:00
hsiegeln
8e27f45a2b feat: add default roles and ConfirmDialog to OIDC config
Adds a Default Roles section with Tag components for viewing/removing roles and an Input+Button for adding new ones. Replaces the plain delete button with a ConfirmDialog requiring typed confirmation. Introduces OidcConfigPage.module.css for CSS module layout classes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:25:14 +01:00
hsiegeln
a86f56f588 feat: add Timeline/Flow toggle to Exchange Detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:22:45 +01:00
hsiegeln
651cf9de6e feat: add correlation chain and processor count to Exchange Detail
Adds a recursive processor count stat to the exchange header, and a
Correlation Chain section that visualises related executions sharing
the same correlationId, with the current exchange highlighted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:19:50 +01:00
hsiegeln
63d8078688 feat: align Dashboard stat cards with mock, add errors section to DetailPanel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:19:33 +01:00
hsiegeln
ee69dbedfc feat: use TopBar onLogout prop, add ToastProvider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:17:38 +01:00
hsiegeln
313d871948 chore: update design system to v0.0.2, regenerate schema.d.ts
Bumped @cameleer/design-system from ^0.0.1 to ^0.0.2 (adds onLogout prop to TopBar).
Fetched openapi.json from remote backend, stripped /api/v1 prefix, patched
ExecutionDetail with groupName and children fields to match UI expectations,
then regenerated schema.d.ts via openapi-typescript. TypeScript compiles clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:16:15 +01:00
hsiegeln
f4d2693561 feat: enrich AgentInstanceResponse with version/capabilities, add password reset endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:13:37 +01:00
hsiegeln
2051572ee2 feat: add GET /agents/{id}/metrics endpoint for JVM metrics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:11:22 +01:00
hsiegeln
cc433b4215 feat: add GET /routes/metrics/processors endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:10:54 +01:00
hsiegeln
31b60c4e24 feat: add V7 migration for per-processor-id continuous aggregate 2026-03-23 18:09:24 +01:00
hsiegeln
017a0c218e docs: add UI mock alignment design spec and implementation plan
Comprehensive spec and 20-task plan to close all gaps between
@cameleer/design-system v0.0.2 mocks and the current server UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:06:26 +01:00
39 changed files with 13079 additions and 552 deletions

View File

@@ -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

View File

@@ -0,0 +1,65 @@
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.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, from, to, namesArray);
return new AgentMetricsResponse(result);
}
}

View File

@@ -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;
@@ -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, group_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 group_name = ?");
params.add(appId);
}
sql.append(" GROUP BY processor_id, processor_type, route_id, group_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("group_name"),
totalCount,
failedCount,
rs.getDouble("avg_duration_ms"),
rs.getDouble("p99_duration_ms"),
errorRate);
}, params.toArray());
return ResponseEntity.ok(metrics);
}
} }

View File

@@ -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) {}
} }

View File

@@ -7,6 +7,7 @@ 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(
@@ -17,6 +18,8 @@ public record AgentInstanceResponse(
@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,
@@ -29,6 +32,7 @@ public record AgentInstanceResponse(
info.id(), info.name(), info.group(), info.id(), info.name(), info.group(),
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
@@ -38,6 +42,7 @@ 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, group, status, routeIds, registeredAt, lastHeartbeat,
version, capabilities,
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
); );
} }

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -0,0 +1,7 @@
package com.cameleer3.server.app.dto;
import jakarta.validation.constraints.NotBlank;
public record SetPasswordRequest(
@NotBlank String password
) {}

View File

@@ -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")

View File

@@ -0,0 +1,21 @@
-- V7: Per-processor-id continuous aggregate for route detail page
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;
SELECT add_continuous_aggregate_policy('stats_1m_processor_detail',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');

File diff suppressed because it is too large Load Diff

View 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

72
ui/package-lock.json generated
View File

@@ -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.2",
"@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,6 +19,7 @@
}, },
"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",
@@ -274,9 +275,9 @@
} }
}, },
"node_modules/@cameleer/design-system": { "node_modules/@cameleer/design-system": {
"version": "0.0.1", "version": "0.0.2",
"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.2/design-system-0.0.2.tgz",
"integrity": "sha512-8rMAp7JhZBlAw4jcTnSBLuZe8cd94lPAgL96KDtVIk2QpXKdsJLoVfk7CuPG635/h6pu4YKplfBhJmKpsS8A8g==", "integrity": "sha512-6PbqtrW4E1yVE+ou2BCYVdHItvN88kNStS2pIKHuJhcerY3vCctLNU4pZSORkLUfvB181I+QIkBIEFa1CKSG8Q==",
"dependencies": { "dependencies": {
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -608,6 +609,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",
@@ -2763,6 +2780,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",

View File

@@ -12,7 +12,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.2",
"@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,6 +23,7 @@
}, },
"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",

4515
ui/src/api/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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({

View 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,
});
}

View 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,
});
}

View 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,
});
}

3627
ui/src/api/schema.d.ts vendored

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<ToastProvider>
<CommandPaletteProvider> <CommandPaletteProvider>
<GlobalFilterProvider> <GlobalFilterProvider>
<LayoutContent /> <LayoutContent />
</GlobalFilterProvider> </GlobalFilterProvider>
</CommandPaletteProvider> </CommandPaletteProvider>
</ToastProvider>
); );
} }

View 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>
);
}

View 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;
}

View File

@@ -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>
); );
} }

View File

@@ -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}
/> />
<div style={{ marginTop: '1rem' }}>
{tab === 'users' && <UsersTab />} {tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab />} {tab === 'groups' && <GroupsTab />}
{tab === 'roles' && <RolesTab />} {tab === 'roles' && <RolesTab />}
</div> </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>
); );
} }

View 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>
);
}

View File

@@ -0,0 +1,48 @@
.statStrip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 16px; }
.splitPane { display: grid; grid-template-columns: 52fr 48fr; height: calc(100vh - 280px); }
.listPane { overflow-y: auto; border-right: 1px solid var(--border-subtle); padding-right: 16px; }
.detailPane { overflow-y: auto; padding-left: 16px; }
.listHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.entityList { display: flex; flex-direction: column; gap: 2px; }
.entityItem {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
cursor: pointer; border-radius: 6px; transition: background 0.1s;
}
.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; }
.entityMeta { font-size: 11px; color: var(--text-muted); }
.entityTags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 2px; }
.createForm {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); padding: 12px; margin-bottom: 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);
}
.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;
}
.sectionTitle {
font-size: 13px; font-weight: 700; color: var(--text-primary);
margin-bottom: 8px; margin-top: 16px;
}

View File

@@ -0,0 +1,531 @@
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>
{user.email && <div className={styles.entityMeta}>{user.email}</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>
);
}

View File

@@ -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;
@@ -36,11 +43,22 @@
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);
}
.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 +82,99 @@
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);
}

View File

@@ -1,12 +1,167 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { import {
StatCard, StatusDot, Badge, MonoText, StatCard, StatusDot, Badge, MonoText,
GroupCard, EventFeed, GroupCard, EventFeed, Breadcrumb, Alert,
DetailPanel, ProgressBar, LineChart,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import styles from './AgentHealth.module.css'; import styles from './AgentHealth.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useRouteCatalog } from '../../api/queries/catalog'; import { useRouteCatalog } from '../../api/queries/catalog';
import { useAgentMetrics } from '../../api/queries/agent-metrics';
function formatUptime(seconds?: number): string {
if (!seconds) return '—';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
function formatRelativeTime(iso?: string): string {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function AgentOverviewContent({ agent }: { agent: any }) {
const { data: memMetrics } = useAgentMetrics(
agent.id,
['jvm.memory.heap.used', 'jvm.memory.heap.max'],
1,
);
const { data: cpuMetrics } = useAgentMetrics(agent.id, ['jvm.cpu.process'], 1);
const cpuValue = cpuMetrics?.metrics?.['jvm.cpu.process']?.[0]?.value;
const heapUsed = memMetrics?.metrics?.['jvm.memory.heap.used']?.[0]?.value;
const heapMax = memMetrics?.metrics?.['jvm.memory.heap.max']?.[0]?.value;
const heapPercent = heapUsed != null && heapMax != null && heapMax > 0
? Math.round((heapUsed / heapMax) * 100)
: undefined;
const cpuPercent = cpuValue != null ? Math.round(cpuValue * 100) : undefined;
const statusVariant: 'live' | 'stale' | 'dead' =
agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead';
const statusColor: 'success' | 'warning' | 'error' =
agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error';
return (
<div className={styles.overviewContent}>
<div className={styles.overviewRow}>
<StatusDot variant={statusVariant} />
<Badge label={agent.status} color={statusColor} />
</div>
<dl className={styles.detailList}>
<div className={styles.detailRow}>
<dt>Application</dt>
<dd><MonoText>{agent.group ?? '—'}</MonoText></dd>
</div>
<div className={styles.detailRow}>
<dt>Version</dt>
<dd><MonoText>{agent.version ?? '—'}</MonoText></dd>
</div>
<div className={styles.detailRow}>
<dt>Uptime</dt>
<dd>{formatUptime(agent.uptimeSeconds)}</dd>
</div>
<div className={styles.detailRow}>
<dt>Last Heartbeat</dt>
<dd>{formatRelativeTime(agent.lastHeartbeat)}</dd>
</div>
<div className={styles.detailRow}>
<dt>TPS</dt>
<dd>{agent.tps != null ? (agent.tps as number).toFixed(2) : '—'}</dd>
</div>
<div className={styles.detailRow}>
<dt>Error Rate</dt>
<dd>{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}</dd>
</div>
<div className={styles.detailRow}>
<dt>Routes</dt>
<dd>{agent.activeRoutes ?? '—'} active / {agent.totalRoutes ?? '—'} total</dd>
</div>
</dl>
<div className={styles.metricsSection}>
<div className={styles.metricLabel}>
Heap Memory{heapUsed != null && heapMax != null
? `${Math.round(heapUsed / 1024 / 1024)}MB / ${Math.round(heapMax / 1024 / 1024)}MB`
: ''}
</div>
<ProgressBar
value={heapPercent}
variant={heapPercent == null ? 'primary' : heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
indeterminate={heapPercent == null}
size="sm"
/>
</div>
<div className={styles.metricsSection}>
<div className={styles.metricLabel}>
CPU Usage{cpuPercent != null ? `${cpuPercent}%` : ''}
</div>
<ProgressBar
value={cpuPercent}
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
indeterminate={cpuPercent == null}
size="sm"
/>
</div>
</div>
);
}
function AgentPerformanceContent({ agent }: { agent: any }) {
const { data: tpsMetrics } = useAgentMetrics(agent.id, ['cameleer.tps'], 60);
const { data: errMetrics } = useAgentMetrics(agent.id, ['cameleer.error.rate'], 60);
const tpsSeries = useMemo(() => {
const raw = tpsMetrics?.metrics?.['cameleer.tps'] ?? [];
return [{
label: 'TPS',
data: raw.map((p) => ({ x: new Date(p.time), y: p.value })),
}];
}, [tpsMetrics]);
const errSeries = useMemo(() => {
const raw = errMetrics?.metrics?.['cameleer.error.rate'] ?? [];
return [{
label: 'Error Rate',
data: raw.map((p) => ({ x: new Date(p.time), y: p.value * 100 })),
}];
}, [errMetrics]);
return (
<div className={styles.performanceContent}>
<div className={styles.chartSection}>
<div className={styles.chartLabel}>Throughput (TPS)</div>
{tpsSeries[0].data.length > 0 ? (
<LineChart series={tpsSeries} yLabel="req/s" height={160} />
) : (
<div className={styles.emptyChart}>No data available</div>
)}
</div>
<div className={styles.chartSection}>
<div className={styles.chartLabel}>Error Rate (%)</div>
{errSeries[0].data.length > 0 ? (
<LineChart series={errSeries} yLabel="%" height={160} />
) : (
<div className={styles.emptyChart}>No data available</div>
)}
</div>
</div>
);
}
export default function AgentHealth() { export default function AgentHealth() {
const { appId } = useParams(); const { appId } = useParams();
@@ -15,6 +170,8 @@ export default function AgentHealth() {
const { data: catalog } = useRouteCatalog(); const { data: catalog } = useRouteCatalog();
const { data: events } = useAgentEvents(appId); const { data: events } = useAgentEvents(appId);
const [selectedAgent, setSelectedAgent] = useState<any>(null);
const agentsByApp = useMemo(() => { const agentsByApp = useMemo(() => {
const map: Record<string, any[]> = {}; const map: Record<string, any[]> = {};
(agents || []).forEach((a: any) => { (agents || []).forEach((a: any) => {
@@ -25,10 +182,30 @@ export default function AgentHealth() {
return map; return map;
}, [agents]); }, [agents]);
const totalAgents = agents?.length ?? 0;
const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length; const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length;
const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').length; const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').length;
const deadCount = (agents || []).filter((a: any) => a.status === 'DEAD').length; const deadCount = (agents || []).filter((a: any) => a.status === 'DEAD').length;
const uniqueApps = new Set((agents || []).map((a: any) => a.group)).size;
const activeRoutes = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.activeRoutes || 0), 0);
const totalTps = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.tps || 0), 0);
const groupHealth: 'live' | 'stale' | 'dead' = useMemo(() => {
if (!appId) return 'live';
const groupAgents = agentsByApp[appId] || [];
if (groupAgents.some((a: any) => a.status === 'DEAD')) return 'dead';
if (groupAgents.some((a: any) => a.status === 'STALE')) return 'stale';
return 'live';
}, [appId, agentsByApp]);
const scopeItems = useMemo(() => {
const items: { label: string; href?: string }[] = [
{ label: 'Agent Health', href: '/agents' },
];
if (appId) {
items.push({ label: appId });
}
return items;
}, [appId]);
const feedEvents = useMemo(() => const feedEvents = useMemo(() =>
(events || []).map((e: any) => ({ (events || []).map((e: any) => ({
@@ -48,14 +225,28 @@ export default function AgentHealth() {
return ( return (
<div> <div>
<div className={styles.statStrip}> <div className={styles.statStrip}>
<StatCard label="Total Agents" value={totalAgents} /> <StatCard label="Total Agents" value={(agents || []).length} detail={`${liveCount} live / ${staleCount} stale / ${deadCount} dead`} />
<StatCard label="Live" value={liveCount} accent="success" /> <StatCard label="Applications" value={uniqueApps} />
<StatCard label="Stale" value={staleCount} accent="warning" /> <StatCard label="Active Routes" value={activeRoutes} />
<StatCard label="Dead" value={deadCount} accent="error" /> <StatCard label="Total TPS" value={totalTps.toFixed(1)} />
<StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} />
</div>
<div className={styles.scopeTrail}>
<Breadcrumb items={scopeItems} />
{!appId && <Badge label={`${liveCount} live`} variant="outlined" />}
{appId && (
<Badge
label={groupHealth}
color={groupHealth === 'live' ? 'success' : groupHealth === 'stale' ? 'warning' : 'error'}
/>
)}
</div> </div>
<div className={styles.groupGrid}> <div className={styles.groupGrid}>
{Object.entries(apps).map(([group, groupAgents]) => ( {Object.entries(apps).map(([group, groupAgents]) => {
const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD');
return (
<GroupCard <GroupCard
key={group} key={group}
title={group} title={group}
@@ -67,20 +258,34 @@ export default function AgentHealth() {
} }
onClick={() => navigate(`/agents/${group}`)} onClick={() => navigate(`/agents/${group}`)}
> >
{deadInGroup.length > 0 && (
<Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
)}
{(groupAgents || []).map((agent: any) => ( {(groupAgents || []).map((agent: any) => (
<div <div
key={agent.id} key={agent.id}
className={styles.instanceRow} className={styles.instanceRow}
onClick={(e) => { e.stopPropagation(); navigate(`/agents/${group}/${agent.id}`); }} onClick={(e) => {
e.stopPropagation();
setSelectedAgent(agent);
navigate(`/agents/${group}/${agent.id}`);
}}
> >
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} /> <StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
<span className={styles.instanceName}>{agent.name}</span> <span className={styles.instanceName}>{agent.name}</span>
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} /> <Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
{agent.tps > 0 && <span className={styles.instanceTps}>{agent.tps.toFixed(1)} tps</span>} <span className={styles.instanceMeta}>{formatUptime(agent.uptimeSeconds)}</span>
{agent.tps != null && <span className={styles.instanceMeta}>{(agent.tps || 0).toFixed(1)} tps</span>}
{agent.errorRate != null && (
<span className={styles.instanceMeta}>{(agent.errorRate * 100).toFixed(1)}% err</span>
)}
<span className={styles.instanceMeta}>{formatRelativeTime(agent.lastHeartbeat)}</span>
<span className={styles.instanceLink} aria-label="View instance"></span>
</div> </div>
))} ))}
</GroupCard> </GroupCard>
))} );
})}
</div> </div>
{feedEvents.length > 0 && ( {feedEvents.length > 0 && (
@@ -89,6 +294,26 @@ export default function AgentHealth() {
<EventFeed events={feedEvents} maxItems={100} /> <EventFeed events={feedEvents} maxItems={100} />
</div> </div>
)} )}
{selectedAgent && (
<DetailPanel
open={!!selectedAgent}
title={selectedAgent.name ?? selectedAgent.id}
onClose={() => setSelectedAgent(null)}
tabs={[
{
label: 'Overview',
value: 'overview',
content: <AgentOverviewContent agent={selectedAgent} />,
},
{
label: 'Performance',
value: 'performance',
content: <AgentPerformanceContent agent={selectedAgent} />,
},
]}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
.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;
} }
@@ -26,7 +26,7 @@
.chartsGrid { .chartsGrid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(3, 1fr);
gap: 14px; gap: 14px;
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -84,9 +84,35 @@
} }
.infoCard { .infoCard {
background: var(--bg-surface); margin-bottom: 20px;
border: 1px solid var(--border-subtle); }
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card); .infoGrid {
padding: 16px; display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
font-size: 13px;
}
.infoLabel {
font-weight: 700;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
display: block;
margin-bottom: 2px;
}
.capTags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.paneTitle {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 12px;
} }

View File

@@ -1,13 +1,13 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { import {
StatCard, StatusDot, Badge, StatCard, StatusDot, Badge, Card,
LineChart, AreaChart, EventFeed, Breadcrumb, Spinner, LineChart, AreaChart, BarChart, EventFeed, Breadcrumb, Spinner, EmptyState,
CodeBlock,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import styles from './AgentInstance.module.css'; import styles from './AgentInstance.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useStatsTimeseries } from '../../api/queries/executions'; import { useStatsTimeseries } from '../../api/queries/executions';
import { useAgentMetrics } from '../../api/queries/agent-metrics';
import { useGlobalFilters } from '@cameleer/design-system'; import { useGlobalFilters } from '@cameleer/design-system';
export default function AgentInstance() { export default function AgentInstance() {
@@ -21,10 +21,28 @@ export default function AgentInstance() {
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId); const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
const agent = useMemo(() => const agent = useMemo(() =>
(agents || []).find((a: any) => a.id === instanceId), (agents || []).find((a: any) => a.id === instanceId) as any,
[agents, instanceId], [agents, instanceId],
); );
// Stat card metrics (latest 1 bucket)
const { data: latestMetrics } = useAgentMetrics(
agent?.id || null,
['jvm.cpu.process', 'jvm.memory.heap.used', 'jvm.memory.heap.max'],
1,
);
const cpuPct = latestMetrics?.metrics?.['jvm.cpu.process']?.[0]?.value;
const heapUsed = latestMetrics?.metrics?.['jvm.memory.heap.used']?.[0]?.value;
const heapMax = latestMetrics?.metrics?.['jvm.memory.heap.max']?.[0]?.value;
const memPct = heapMax ? (heapUsed! / heapMax) * 100 : undefined;
// Chart metrics (60 buckets)
const { data: jvmMetrics } = useAgentMetrics(
agent?.id || null,
['jvm.cpu.process', 'jvm.memory.heap.used', 'jvm.memory.heap.max', 'jvm.threads.count', 'jvm.gc.time'],
60,
);
const chartData = useMemo(() => const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => ({ (timeseries?.buckets || []).map((b: any) => ({
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
@@ -48,6 +66,41 @@ export default function AgentInstance() {
[events, instanceId], [events, instanceId],
); );
// JVM chart series helpers
const cpuSeries = useMemo(() => {
const pts = jvmMetrics?.metrics?.['jvm.cpu.process'];
if (!pts?.length) return null;
return [{ label: 'CPU %', data: pts.map((p: any, i: number) => ({ x: i, y: p.value * 100 })) }];
}, [jvmMetrics]);
const heapSeries = useMemo(() => {
const pts = jvmMetrics?.metrics?.['jvm.memory.heap.used'];
if (!pts?.length) return null;
return [{ label: 'Heap MB', data: pts.map((p: any, i: number) => ({ x: i, y: p.value / (1024 * 1024) })) }];
}, [jvmMetrics]);
const threadSeries = useMemo(() => {
const pts = jvmMetrics?.metrics?.['jvm.threads.count'];
if (!pts?.length) return null;
return [{ label: 'Threads', data: pts.map((p: any, i: number) => ({ x: i, y: p.value })) }];
}, [jvmMetrics]);
const gcSeries = useMemo(() => {
const pts = jvmMetrics?.metrics?.['jvm.gc.time'];
if (!pts?.length) return null;
return [{ label: 'GC ms', data: pts.map((p: any, i: number) => ({ x: String(i), y: p.value })) }];
}, [jvmMetrics]);
const throughputSeries = useMemo(() =>
chartData.length ? [{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }] : null,
[chartData],
);
const errorSeries = useMemo(() =>
chartData.length ? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }] : null,
[chartData],
);
if (isLoading) return <Spinner size="lg" />; if (isLoading) return <Spinner size="lg" />;
return ( return (
@@ -64,15 +117,55 @@ export default function AgentInstance() {
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} /> <StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
<h2>{agent.name}</h2> <h2>{agent.name}</h2>
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} /> <Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
{agent.version && <Badge label={agent.version} variant="outlined" />}
</div> </div>
<div className={styles.statStrip}> <div className={styles.statStrip}>
<StatCard label="TPS" value={agent.tps?.toFixed(1) ?? '0'} /> <StatCard label="CPU" value={cpuPct != null ? `${(cpuPct * 100).toFixed(0)}%` : ''} />
<StatCard label="Error Rate" value={agent.errorRate ? `${(agent.errorRate * 100).toFixed(1)}%` : '0%'} accent={agent.errorRate > 0.05 ? 'error' : undefined} /> <StatCard label="Memory" value={memPct != null ? `${memPct.toFixed(0)}%` : '—'} />
<StatCard label="Active Routes" value={`${agent.activeRoutes ?? 0}/${agent.totalRoutes ?? 0}`} /> <StatCard label="Throughput" value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '—'} />
<StatCard label="Uptime" value={formatUptime(agent.uptimeSeconds ?? 0)} /> <StatCard label="Errors" value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '—'} accent={agent?.errorRate > 0 ? 'error' : undefined} />
<StatCard label="Uptime" value={formatUptime(agent?.uptimeSeconds)} />
</div> </div>
<Card className={styles.infoCard}>
<div className={styles.paneTitle}>Process Information</div>
<div className={styles.infoGrid}>
{agent?.capabilities?.jvmVersion && (
<div>
<span className={styles.infoLabel}>JVM</span>
<span>{agent.capabilities.jvmVersion}</span>
</div>
)}
{agent?.capabilities?.camelVersion && (
<div>
<span className={styles.infoLabel}>Camel</span>
<span>{agent.capabilities.camelVersion}</span>
</div>
)}
{agent?.capabilities?.springBootVersion && (
<div>
<span className={styles.infoLabel}>Spring Boot</span>
<span>{agent.capabilities.springBootVersion}</span>
</div>
)}
<div>
<span className={styles.infoLabel}>Started</span>
<span>{agent?.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '—'}</span>
</div>
<div>
<span className={styles.infoLabel}>Capabilities</span>
<span className={styles.capTags}>
{Object.entries(agent?.capabilities || {})
.filter(([, v]) => typeof v === 'boolean' && v)
.map(([k]) => (
<Badge key={k} label={k} variant="outlined" />
))}
</span>
</div>
</div>
</Card>
<div className={styles.sectionTitle}>Routes</div> <div className={styles.sectionTitle}>Routes</div>
<div className={styles.routeBadges}> <div className={styles.routeBadges}>
{(agent.routeIds || []).map((r: string) => ( {(agent.routeIds || []).map((r: string) => (
@@ -82,21 +175,45 @@ export default function AgentInstance() {
</> </>
)} )}
{chartData.length > 0 && (
<>
<div className={styles.sectionTitle}>Performance</div> <div className={styles.sectionTitle}>Performance</div>
<div className={styles.chartsGrid}> <div className={styles.chartsGrid}>
<div className={styles.chartCard}> <div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>Throughput</div></div> <div className={styles.chartHeader}><div className={styles.chartTitle}>CPU Usage</div></div>
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} height={200} /> {cpuSeries
? <AreaChart series={cpuSeries} yLabel="%" height={200} />
: <EmptyState title="No data" description="No CPU metrics available" />}
</div> </div>
<div className={styles.chartCard}> <div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>Latency</div></div> <div className={styles.chartHeader}><div className={styles.chartTitle}>Memory Heap</div></div>
<LineChart series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]} height={200} /> {heapSeries
? <AreaChart series={heapSeries} yLabel="MB" height={200} />
: <EmptyState title="No data" description="No heap metrics available" />}
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>Throughput</div></div>
{throughputSeries
? <AreaChart series={throughputSeries} height={200} />
: <EmptyState title="No data" description="No throughput data in range" />}
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>Error Rate</div></div>
{errorSeries
? <LineChart series={errorSeries} height={200} />
: <EmptyState title="No data" description="No error data in range" />}
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>Thread Count</div></div>
{threadSeries
? <LineChart series={threadSeries} height={200} />
: <EmptyState title="No data" description="No thread metrics available" />}
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>GC Pauses</div></div>
{gcSeries
? <BarChart series={gcSeries} yLabel="ms" height={200} />
: <EmptyState title="No data" description="No GC metrics available" />}
</div> </div>
</div> </div>
</>
)}
{feedEvents.length > 0 && ( {feedEvents.length > 0 && (
<div className={styles.eventCard}> <div className={styles.eventCard}>
@@ -105,28 +222,17 @@ export default function AgentInstance() {
</div> </div>
)} )}
{agent && ( <EmptyState title="Application Logs" description="Application log streaming is not yet available" />
<>
<div className={styles.sectionTitle}>Agent Info</div>
<div className={styles.infoCard}>
<CodeBlock content={JSON.stringify({
id: agent.id,
name: agent.name,
group: agent.group,
registeredAt: agent.registeredAt,
lastHeartbeat: agent.lastHeartbeat,
routeIds: agent.routeIds,
}, null, 2)} />
</div>
</>
)}
</div> </div>
); );
} }
function formatUptime(seconds: number): string { function formatUptime(seconds?: number): string {
if (seconds < 60) return `${seconds}s`; if (!seconds) return '—';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; const days = Math.floor(seconds / 86400);
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; const hours = Math.floor((seconds % 86400) / 3600);
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`; const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
} }

View File

@@ -3,6 +3,7 @@ import { useParams } from 'react-router';
import { import {
StatCard, StatusDot, Badge, MonoText, Sparkline, StatCard, StatusDot, Badge, MonoText, Sparkline,
DataTable, DetailPanel, ProcessorTimeline, RouteFlow, DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
Alert, Collapsible, CodeBlock,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'; import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
@@ -22,6 +23,8 @@ export default function Dashboard() {
const [detailTab, setDetailTab] = useState('overview'); const [detailTab, setDetailTab] = useState('overview');
const [processorIdx, setProcessorIdx] = useState<number | null>(null); const [processorIdx, setProcessorIdx] = useState<number | null>(null);
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId); const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId); const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: searchResult } = useSearchExecutions({ const { data: searchResult } = useSearchExecutions({
@@ -62,6 +65,7 @@ export default function Dashboard() {
{ {
label: 'Overview', value: 'overview', label: 'Overview', value: 'overview',
content: ( content: (
<>
<div className={styles.panelSection}> <div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Details</div> <div className={styles.panelSectionTitle}>Details</div>
<div className={styles.overviewGrid}> <div className={styles.overviewGrid}>
@@ -81,37 +85,68 @@ export default function Dashboard() {
<span className={styles.overviewLabel}>Duration</span> <span className={styles.overviewLabel}>Duration</span>
<span>{detail.durationMs}ms</span> <span>{detail.durationMs}ms</span>
</div> </div>
{detail.errorMessage && (
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Error</span>
<span>{detail.errorMessage}</span>
</div> </div>
</div>
{detail.errorMessage && (
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Errors</div>
<Alert variant="error">
<strong>{detail.errorMessage.split(':')[0]}</strong>
<div>{detail.errorMessage.includes(':') ? detail.errorMessage.substring(detail.errorMessage.indexOf(':') + 1).trim() : ''}</div>
</Alert>
{detail.errorStackTrace && (
<Collapsible title="Stack Trace">
<CodeBlock content={detail.errorStackTrace} />
</Collapsible>
)} )}
</div> </div>
</div> )}
</>
), ),
}, },
{ {
label: 'Processors', value: 'processors', label: 'Processors', value: 'processors',
content: detail.children ? ( content: (() => {
const procList = detail.processors?.length ? detail.processors : (detail.children ?? []);
return procList.length ? (
<ProcessorTimeline <ProcessorTimeline
processors={flattenProcessors(detail.children)} processors={flattenProcessors(procList)}
totalMs={detail.durationMs} totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setProcessorIdx(i)} onProcessorClick={(_p, i) => setProcessorIdx(i)}
selectedIndex={processorIdx ?? undefined} selectedIndex={processorIdx ?? undefined}
/> />
) : <div style={{ padding: '1rem' }}>No processor data</div>, ) : <div style={{ padding: '1rem' }}>No processor data</div>;
})(),
}, },
] : []; ] : [];
return ( return (
<div> <div>
<div className={styles.healthStrip}> <div className={styles.healthStrip}>
<StatCard label="Total Exchanges" value={stats?.totalCount ?? 0} sparkline={sparklineData} /> <StatCard
<StatCard label="Failed" value={stats?.failedCount ?? 0} accent="error" /> label="Throughput"
<StatCard label="Avg Duration" value={`${stats?.avgDurationMs ?? 0}ms`} /> value={timeWindowSeconds > 0 ? `${((stats?.totalCount ?? 0) / timeWindowSeconds).toFixed(2)} ex/s` : '0.00 ex/s'}
<StatCard label="P99 Duration" value={`${stats?.p99LatencyMs ?? 0}ms`} accent="warning" /> sparkline={sparklineData}
<StatCard label="Active" value={stats?.activeCount ?? 0} accent="running" /> />
<StatCard
label="Error Rate"
value={(stats?.totalCount ?? 0) > 0 ? `${((stats?.failedCount ?? 0) / stats!.totalCount * 100).toFixed(1)}%` : '0.0%'}
accent="error"
/>
<StatCard
label="Avg Latency"
value={`${stats?.avgDurationMs ?? 0}ms`}
/>
<StatCard
label="P99 Latency"
value={`${stats?.p99LatencyMs ?? 0}ms`}
accent="warning"
/>
<StatCard
label="In-Flight"
value={stats?.activeCount ?? 0}
accent="running"
/>
</div> </div>
<div className={styles.tableSection}> <div className={styles.tableSection}>

View File

@@ -141,3 +141,43 @@
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 6px; margin-bottom: 6px;
} }
.correlationChain { margin-bottom: 16px; }
.chainRow {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding: 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
}
.chainArrow { color: var(--text-muted); font-size: 16px; flex-shrink: 0; }
.chainCard {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--bg-raised);
border: 1px solid var(--border-subtle);
border-radius: 6px;
font-size: 12px;
text-decoration: none;
color: var(--text-primary);
flex-shrink: 0;
cursor: pointer;
}
.chainCard:hover { background: var(--bg-hover); }
.chainCardActive { border-color: var(--accent); background: var(--bg-hover); }
.chainRoute { font-weight: 600; }
.chainDuration { color: var(--text-muted); font-family: var(--font-mono); font-size: 11px; }
.chainMore { color: var(--text-muted); font-size: 11px; font-style: italic; }

View File

@@ -1,18 +1,28 @@
import { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { import {
Badge, StatusDot, MonoText, CodeBlock, InfoCallout, Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
ProcessorTimeline, Breadcrumb, Spinner, ProcessorTimeline, Breadcrumb, Spinner, SegmentedTabs, RouteFlow,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'; import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
import { useCorrelationChain } from '../../api/queries/correlation';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
import styles from './ExchangeDetail.module.css'; import styles from './ExchangeDetail.module.css';
function countProcessors(nodes: any[]): number {
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0);
}
export default function ExchangeDetail() { export default function ExchangeDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: detail, isLoading } = useExecutionDetail(id ?? null); const { data: detail, isLoading } = useExecutionDetail(id ?? null);
const [selectedProcessor, setSelectedProcessor] = useState<number | null>(null); const [selectedProcessor, setSelectedProcessor] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<'timeline' | 'flow'>('timeline');
const { data: snapshot } = useProcessorSnapshot(id ?? null, selectedProcessor); const { data: snapshot } = useProcessorSnapshot(id ?? null, selectedProcessor);
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null);
const { data: diagram } = useDiagramByRoute(detail?.groupName, detail?.routeId);
const processors = useMemo(() => { const processors = useMemo(() => {
if (!detail?.children) return []; if (!detail?.children) return [];
@@ -58,6 +68,10 @@ export default function ExchangeDetail() {
<div className={styles.headerStatLabel}>Duration</div> <div className={styles.headerStatLabel}>Duration</div>
<div className={styles.headerStatValue}>{detail.durationMs}ms</div> <div className={styles.headerStatValue}>{detail.durationMs}ms</div>
</div> </div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Processors</div>
<div className={styles.headerStatValue}>{countProcessors(detail.processors || detail.children || [])}</div>
</div>
<div className={styles.headerStat}> <div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Route</div> <div className={styles.headerStatLabel}>Route</div>
<div className={styles.headerStatValue}>{detail.routeId}</div> <div className={styles.headerStatValue}>{detail.routeId}</div>
@@ -70,6 +84,33 @@ export default function ExchangeDetail() {
</div> </div>
</div> </div>
{correlationData?.data && correlationData.data.length > 1 && (
<div className={styles.correlationChain}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>Correlation Chain</span>
</div>
<div className={styles.chainRow}>
{correlationData.data.map((exec, i) => (
<React.Fragment key={exec.executionId}>
{i > 0 && <span className={styles.chainArrow}></span>}
<a
href={`/exchanges/${exec.executionId}`}
className={`${styles.chainCard} ${exec.executionId === id ? styles.chainCardActive : ''}`}
onClick={(e) => { e.preventDefault(); navigate(`/exchanges/${exec.executionId}`); }}
>
<StatusDot variant={exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'warning'} />
<span className={styles.chainRoute}>{exec.routeId}</span>
<span className={styles.chainDuration}>{exec.durationMs}ms</span>
</a>
</React.Fragment>
))}
{correlationData.total > 20 && (
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
)}
</div>
</div>
)}
{detail.errorMessage && ( {detail.errorMessage && (
<InfoCallout variant="error"> <InfoCallout variant="error">
{detail.errorMessage} {detail.errorMessage}
@@ -78,10 +119,19 @@ export default function ExchangeDetail() {
<div className={styles.timelineSection}> <div className={styles.timelineSection}>
<div className={styles.timelineHeader}> <div className={styles.timelineHeader}>
<span className={styles.timelineTitle}>Processor Timeline</span> <span className={styles.timelineTitle}>Processors</span>
<SegmentedTabs
tabs={[
{ label: 'Timeline', value: 'timeline' },
{ label: 'Flow', value: 'flow' },
]}
active={viewMode}
onChange={(v) => setViewMode(v as 'timeline' | 'flow')}
/>
</div> </div>
<div className={styles.timelineBody}> <div className={styles.timelineBody}>
{processors.length > 0 ? ( {viewMode === 'timeline' ? (
processors.length > 0 ? (
<ProcessorTimeline <ProcessorTimeline
processors={processors} processors={processors}
totalMs={detail.durationMs} totalMs={detail.durationMs}
@@ -90,6 +140,17 @@ export default function ExchangeDetail() {
/> />
) : ( ) : (
<InfoCallout>No processor data available</InfoCallout> <InfoCallout>No processor data available</InfoCallout>
)
) : (
diagram ? (
<RouteFlow
nodes={mapDiagramToRouteNodes(diagram.nodes || [], detail.processors || detail.children || [])}
onNodeClick={(_node, i) => setSelectedProcessor(i)}
selectedIndex={selectedProcessor ?? undefined}
/>
) : (
<Spinner />
)
)} )}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,39 @@
.headerCard {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card);
padding: 16px; margin-bottom: 16px;
}
.headerRow { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.headerLeft { display: flex; align-items: center; gap: 12px; }
.headerRight { display: flex; gap: 20px; }
.headerStat { text-align: center; }
.headerStatLabel { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 2px; }
.headerStatValue { font-size: 14px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
.diagramStatsGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
.diagramPane, .statsPane {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
}
.paneTitle { font-size: 13px; font-weight: 700; color: var(--text-primary); margin-bottom: 12px; }
.tabSection { margin-top: 20px; }
.chartGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.chartCard {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
}
.chartTitle { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 12px; }
.executionsTable {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden;
}
.errorPatterns { display: flex; flex-direction: column; gap: 8px; }
.errorRow {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 12px; background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); font-size: 12px;
}
.errorMessage { flex: 1; font-family: var(--font-mono); color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 400px; }
.errorCount { font-weight: 700; color: var(--error); margin: 0 12px; }
.errorTime { color: var(--text-muted); font-size: 11px; }
.backLink { font-size: 13px; color: var(--text-muted); text-decoration: none; margin-bottom: 12px; display: inline-block; }
.backLink:hover { color: var(--text-primary); }

View File

@@ -0,0 +1,301 @@
import { useState, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router';
import {
Badge, StatusDot, DataTable, Tabs,
AreaChart, LineChart, BarChart, RouteFlow, Spinner,
MonoText,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useRouteCatalog } from '../../api/queries/catalog';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
import { useStatsTimeseries, useSearchExecutions } from '../../api/queries/executions';
import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
import styles from './RouteDetail.module.css';
interface ExchangeRow extends ExecutionSummary {
id: string;
}
interface ProcessorRow {
id: string;
processorId: string;
callCount: number;
avgDurationMs: number;
p99DurationMs: number;
errorCount: number;
}
interface ErrorPattern {
message: string;
count: number;
lastSeen: string;
}
export default function RouteDetail() {
const { appId, routeId } = useParams();
const navigate = useNavigate();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const [activeTab, setActiveTab] = useState('performance');
const { data: catalog } = useRouteCatalog();
const { data: diagram } = useDiagramByRoute(appId, routeId);
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
timeFrom,
timeTo,
routeId: routeId || undefined,
group: appId || undefined,
offset: 0,
limit: 50,
});
const { data: errorResult } = useSearchExecutions({
timeFrom,
timeTo,
routeId: routeId || undefined,
group: appId || undefined,
status: 'FAILED',
offset: 0,
limit: 200,
});
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
[catalog, appId],
);
const routeSummary: RouteSummary | undefined = useMemo(() =>
appEntry?.routes?.find((r: RouteSummary) => r.routeId === routeId),
[appEntry, routeId],
);
const health = appEntry?.health ?? 'unknown';
const exchangeCount = routeSummary?.exchangeCount ?? 0;
const lastSeen = routeSummary?.lastSeen
? new Date(routeSummary.lastSeen).toLocaleString()
: '—';
const healthVariant = useMemo((): 'success' | 'warning' | 'error' | 'dead' => {
const h = health.toLowerCase();
if (h === 'healthy') return 'success';
if (h === 'degraded') return 'warning';
if (h === 'unhealthy') return 'error';
return 'dead';
}, [health]);
const diagramNodes = useMemo(() => {
if (!diagram?.nodes) return [];
return mapDiagramToRouteNodes(diagram.nodes, []);
}, [diagram]);
const processorRows: ProcessorRow[] = useMemo(() =>
(processorMetrics || []).map((p: any) => ({
id: p.processorId,
processorId: p.processorId,
callCount: p.callCount ?? 0,
avgDurationMs: p.avgDurationMs ?? 0,
p99DurationMs: p.p99DurationMs ?? 0,
errorCount: p.errorCount ?? 0,
})),
[processorMetrics],
);
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => ({
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
throughput: b.totalCount,
latency: b.avgDurationMs,
errors: b.failedCount,
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
})),
[timeseries],
);
const exchangeRows: ExchangeRow[] = useMemo(() =>
(recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[recentResult],
);
const errorPatterns: ErrorPattern[] = useMemo(() => {
const failed = (errorResult?.data || []) as ExecutionSummary[];
const grouped = new Map<string, { count: number; lastSeen: string }>();
for (const ex of failed) {
const msg = ex.errorMessage || 'Unknown error';
const existing = grouped.get(msg);
if (!existing) {
grouped.set(msg, { count: 1, lastSeen: ex.startTime ?? '' });
} else {
existing.count += 1;
if ((ex.startTime ?? '') > existing.lastSeen) {
existing.lastSeen = ex.startTime ?? '';
}
}
}
return Array.from(grouped.entries())
.map(([message, { count, lastSeen: ls }]) => ({
message,
count,
lastSeen: ls ? new Date(ls).toLocaleString() : '—',
}))
.sort((a, b) => b.count - a.count);
}, [errorResult]);
const processorColumns: Column<ProcessorRow>[] = [
{ key: 'processorId', header: 'Processor', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'callCount', header: 'Calls', sortable: true },
{ key: 'avgDurationMs', header: 'Avg', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
{ key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
{ key: 'errorCount', header: 'Errors', sortable: true, render: (v) => {
const n = v as number;
return n > 0 ? <span style={{ color: 'var(--error)', fontWeight: 700 }}>{n}</span> : <span>0</span>;
}},
];
const exchangeColumns: Column<ExchangeRow>[] = [
{
key: 'status', header: 'Status', width: '80px',
render: (v) => <StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />,
},
{ key: 'executionId', header: 'Exchange ID', render: (v) => <MonoText size="xs">{String(v).slice(0, 12)}</MonoText> },
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => new Date(v as string).toLocaleTimeString() },
{ key: 'durationMs', header: 'Duration', sortable: true, render: (v) => `${v}ms` },
];
const tabs = [
{ label: 'Performance', value: 'performance' },
{ label: 'Recent Executions', value: 'executions', count: exchangeRows.length },
{ label: 'Error Patterns', value: 'errors', count: errorPatterns.length },
];
return (
<div>
<Link to={`/routes/${appId}`} className={styles.backLink}>
{appId} routes
</Link>
<div className={styles.headerCard}>
<div className={styles.headerRow}>
<div className={styles.headerLeft}>
<StatusDot variant={healthVariant} />
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700 }}>{routeId}</h2>
<Badge label={appId ?? ''} color="auto" />
</div>
<div className={styles.headerRight}>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Exchanges</div>
<div className={styles.headerStatValue}>{exchangeCount.toLocaleString()}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Last Seen</div>
<div className={styles.headerStatValue}>{lastSeen}</div>
</div>
</div>
</div>
</div>
<div className={styles.diagramStatsGrid}>
<div className={styles.diagramPane}>
<div className={styles.paneTitle}>Route Diagram</div>
{diagramNodes.length > 0 ? (
<RouteFlow nodes={diagramNodes} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
No diagram available for this route.
</div>
)}
</div>
<div className={styles.statsPane}>
<div className={styles.paneTitle}>Processor Stats</div>
{processorLoading ? (
<Spinner size="sm" />
) : processorRows.length > 0 ? (
<DataTable columns={processorColumns} data={processorRows} sortable pageSize={10} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
No processor data available.
</div>
)}
</div>
</div>
<div className={styles.tabSection}>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
{activeTab === 'performance' && (
<div className={styles.chartGrid} style={{ marginTop: 16 }}>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Throughput</div>
<AreaChart
series={[{ label: 'Throughput', data: chartData.map((d, i) => ({ x: i, y: d.throughput })) }]}
height={200}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Latency</div>
<LineChart
series={[{ label: 'Latency', data: chartData.map((d, i) => ({ x: i, y: d.latency })) }]}
height={200}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Errors</div>
<BarChart
series={[{ label: 'Errors', data: chartData.map((d) => ({ x: d.time, y: d.errors })) }]}
height={200}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Success Rate</div>
<AreaChart
series={[{ label: 'Success Rate', data: chartData.map((d, i) => ({ x: i, y: d.successRate })) }]}
height={200}
/>
</div>
</div>
)}
{activeTab === 'executions' && (
<div className={styles.executionsTable} style={{ marginTop: 16 }}>
{recentLoading ? (
<div style={{ padding: 24, display: 'flex', justifyContent: 'center' }}>
<Spinner size="sm" />
</div>
) : (
<DataTable
columns={exchangeColumns}
data={exchangeRows}
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
sortable
pageSize={20}
/>
)}
</div>
)}
{activeTab === 'errors' && (
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
{errorPatterns.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
No error patterns found in the selected time range.
</div>
) : (
errorPatterns.map((ep, i) => (
<div key={i} className={styles.errorRow}>
<span className={styles.errorMessage} title={ep.message}>{ep.message}</span>
<span className={styles.errorCount}>{ep.count}x</span>
<span className={styles.errorTime}>{ep.lastSeen}</span>
</div>
))
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { Spinner } from '@cameleer/design-system';
const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard')); const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard'));
const ExchangeDetail = lazy(() => import('./pages/ExchangeDetail/ExchangeDetail')); const ExchangeDetail = lazy(() => import('./pages/ExchangeDetail/ExchangeDetail'));
const RoutesMetrics = lazy(() => import('./pages/Routes/RoutesMetrics')); const RoutesMetrics = lazy(() => import('./pages/Routes/RoutesMetrics'));
const RouteDetail = lazy(() => import('./pages/Routes/RouteDetail'));
const AgentHealth = lazy(() => import('./pages/AgentHealth/AgentHealth')); const AgentHealth = lazy(() => import('./pages/AgentHealth/AgentHealth'));
const AgentInstance = lazy(() => import('./pages/AgentInstance/AgentInstance')); const AgentInstance = lazy(() => import('./pages/AgentInstance/AgentInstance'));
const RbacPage = lazy(() => import('./pages/Admin/RbacPage')); const RbacPage = lazy(() => import('./pages/Admin/RbacPage'));
@@ -42,7 +43,7 @@ export const router = createBrowserRouter([
{ path: 'exchanges/:id', element: <SuspenseWrapper><ExchangeDetail /></SuspenseWrapper> }, { path: 'exchanges/:id', element: <SuspenseWrapper><ExchangeDetail /></SuspenseWrapper> },
{ path: 'routes', element: <SuspenseWrapper><RoutesMetrics /></SuspenseWrapper> }, { path: 'routes', element: <SuspenseWrapper><RoutesMetrics /></SuspenseWrapper> },
{ path: 'routes/:appId', element: <SuspenseWrapper><RoutesMetrics /></SuspenseWrapper> }, { path: 'routes/:appId', element: <SuspenseWrapper><RoutesMetrics /></SuspenseWrapper> },
{ path: 'routes/:appId/:routeId', element: <SuspenseWrapper><RoutesMetrics /></SuspenseWrapper> }, { path: 'routes/:appId/:routeId', element: <SuspenseWrapper><RouteDetail /></SuspenseWrapper> },
{ path: 'agents', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> }, { path: 'agents', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> },
{ path: 'agents/:appId', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> }, { path: 'agents/:appId', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> },
{ path: 'agents/:appId/:instanceId', element: <SuspenseWrapper><AgentInstance /></SuspenseWrapper> }, { path: 'agents/:appId/:instanceId', element: <SuspenseWrapper><AgentInstance /></SuspenseWrapper> },