Compare commits

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",
"ProcessorNode",
"AppCatalogEntry", "RouteSummary", "AgentSummary",
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse"
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse",
"ProcessorMetrics", "AgentMetricsResponse", "MetricBucket"
);
@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;
import com.cameleer3.server.app.dto.ProcessorMetrics;
import com.cameleer3.server.app.dto.RouteMetrics;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -108,4 +109,56 @@ public class RouteMetricsController {
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;
import com.cameleer3.server.app.dto.SetPasswordRequest;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult;
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.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -172,6 +174,18 @@ public class UserAdminController {
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 UpdateUserRequest(String displayName, String email) {}
}

View File

@@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotNull;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Schema(description = "Agent instance summary with runtime metrics")
public record AgentInstanceResponse(
@@ -17,6 +18,8 @@ public record AgentInstanceResponse(
@NotNull List<String> routeIds,
@NotNull Instant registeredAt,
@NotNull Instant lastHeartbeat,
String version,
Map<String, Object> capabilities,
double tps,
double errorRate,
int activeRoutes,
@@ -29,6 +32,7 @@ public record AgentInstanceResponse(
info.id(), info.name(), info.group(),
info.state().name(), info.routeIds(),
info.registeredAt(), info.lastHeartbeat(),
info.version(), info.capabilities(),
0.0, 0.0,
0, info.routeIds() != null ? info.routeIds().size() : 0,
uptime
@@ -38,6 +42,7 @@ public record AgentInstanceResponse(
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
return new AgentInstanceResponse(
id, name, group, status, routeIds, registeredAt, lastHeartbeat,
version, capabilities,
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+
.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/agents/*/metrics").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/agents/events-log").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")

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",
"version": "0.0.0",
"dependencies": {
"@cameleer/design-system": "^0.0.1",
"@cameleer/design-system": "^0.0.2",
"@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0",
"react": "^19.2.4",
@@ -19,6 +19,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -274,9 +275,9 @@
}
},
"node_modules/@cameleer/design-system": {
"version": "0.0.1",
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.1/design-system-0.0.1.tgz",
"integrity": "sha512-8rMAp7JhZBlAw4jcTnSBLuZe8cd94lPAgL96KDtVIk2QpXKdsJLoVfk7CuPG635/h6pu4YKplfBhJmKpsS8A8g==",
"version": "0.0.2",
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.2/design-system-0.0.2.tgz",
"integrity": "sha512-6PbqtrW4E1yVE+ou2BCYVdHItvN88kNStS2pIKHuJhcerY3vCctLNU4pZSORkLUfvB181I+QIkBIEFa1CKSG8Q==",
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -608,6 +609,22 @@
"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": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
@@ -2763,6 +2780,53 @@
"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": {
"version": "8.0.0",
"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"
},
"dependencies": {
"@cameleer/design-system": "^0.0.1",
"@cameleer/design-system": "^0.0.2",
"@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0",
"react": "^19.2.4",
@@ -23,6 +23,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@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() {
const qc = useQueryClient();
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,
});
}

3769
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 { 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 { useAuthStore } from '../auth/auth-store';
import { useMemo, useCallback } from 'react';
@@ -41,6 +41,11 @@ function LayoutContent() {
}));
}, [location.pathname]);
const handleLogout = useCallback(() => {
logout();
navigate('/login');
}, [logout, navigate]);
const handlePaletteSelect = useCallback((result: any) => {
if (result.path) navigate(result.path);
setPaletteOpen(false);
@@ -56,22 +61,11 @@ function LayoutContent() {
/>
}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<TopBar
breadcrumb={breadcrumb}
user={username ? { name: username } : undefined}
/>
{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>
<TopBar
breadcrumb={breadcrumb}
user={username ? { name: username } : undefined}
onLogout={handleLogout}
/>
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
@@ -87,10 +81,12 @@ function LayoutContent() {
export function LayoutShell() {
return (
<CommandPaletteProvider>
<GlobalFilterProvider>
<LayoutContent />
</GlobalFilterProvider>
</CommandPaletteProvider>
<ToastProvider>
<CommandPaletteProvider>
<GlobalFilterProvider>
<LayoutContent />
</GlobalFilterProvider>
</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 { 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 styles from './OidcConfigPage.module.css';
interface OidcConfig {
enabled: boolean;
@@ -18,6 +19,8 @@ export default function OidcConfigPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [newRole, setNewRole] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => {
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>
<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' }}>
<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>
{error && <Alert variant="error">{error}</Alert>}
{success && <Alert variant="success">Configuration saved</Alert>}
</div>
</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>
);
}

View File

@@ -1,178 +1,35 @@
import { useState, useMemo } from 'react';
import {
Tabs, DataTable, Badge, Avatar, Button, Input, Modal, FormField,
Select, AlertDialog, StatCard, Spinner,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import {
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';
import { useState } from 'react';
import { StatCard, Tabs } from '@cameleer/design-system';
import { useRbacStats } from '../../api/queries/admin/rbac';
import styles from './UserManagement.module.css';
import UsersTab from './UsersTab';
import GroupsTab from './GroupsTab';
import RolesTab from './RolesTab';
export default function RbacPage() {
const [tab, setTab] = useState('users');
const { data: stats } = useRbacStats();
const [tab, setTab] = useState('users');
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>RBAC Management</h2>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<h2 style={{ margin: '0 0 16px' }}>User Management</h2>
<div className={styles.statStrip}>
<StatCard label="Users" value={stats?.userCount ?? 0} />
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
</div>
<Tabs
tabs={[
{ label: 'Users', value: 'users', count: stats?.userCount },
{ label: 'Groups', value: 'groups', count: stats?.groupCount },
{ label: 'Roles', value: 'roles', count: stats?.roleCount },
{ label: 'Users', value: 'users' },
{ label: 'Groups', value: 'groups' },
{ label: 'Roles', value: 'roles' },
]}
active={tab}
onChange={setTab}
/>
<div style={{ marginTop: '1rem' }}>
{tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab />}
{tab === 'roles' && <RolesTab />}
</div>
</div>
);
}
function UsersTab() {
const { data: users, isLoading } = useUsers();
const [createOpen, setCreateOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [form, setForm] = useState({ username: '', displayName: '', email: '', password: '' });
const createUser = useCreateUser();
const deleteUser = useDeleteUser();
const columns: Column<any>[] = [
{ key: 'userId', header: 'Username', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
{ key: 'displayName', header: 'Display Name' },
{ key: 'email', header: 'Email' },
{ key: 'provider', header: 'Provider', render: (v) => <Badge label={String(v)} /> },
{
key: 'effectiveRoles', header: 'Roles',
render: (v) => (
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
</div>
),
},
];
if (isLoading) return <Spinner />;
const rows = (users || []).map((u: any) => ({ ...u, id: u.userId }));
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create User</Button>
</div>
<DataTable columns={columns} data={rows} pageSize={20} />
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create User">
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
<FormField label="Username" required><Input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /></FormField>
<FormField label="Display Name"><Input value={form.displayName} onChange={(e) => setForm({ ...form, displayName: e.target.value })} /></FormField>
<FormField label="Email"><Input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></FormField>
<FormField label="Password"><Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} /></FormField>
<Button variant="primary" onClick={() => { createUser.mutate(form); setCreateOpen(false); setForm({ username: '', displayName: '', email: '', password: '' }); }}>Create</Button>
</div>
</Modal>
<AlertDialog
open={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={() => { if (deleteId) deleteUser.mutate(deleteId); setDeleteId(null); }}
title="Delete User"
description={`Are you sure you want to delete user "${deleteId}"?`}
confirmLabel="Delete"
variant="danger"
/>
</div>
);
}
function GroupsTab() {
const { data: groups, isLoading } = useGroups();
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: '' });
const createGroup = useCreateGroup();
const columns: Column<any>[] = [
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
{ key: 'members', header: 'Members', render: (v) => String((v as any[])?.length ?? 0) },
{
key: 'effectiveRoles', header: 'Roles',
render: (v) => (
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
</div>
),
},
];
if (isLoading) return <Spinner />;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Group</Button>
</div>
<DataTable columns={columns} data={groups || []} pageSize={20} />
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Group">
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
<Button variant="primary" onClick={() => { createGroup.mutate(form); setCreateOpen(false); setForm({ name: '' }); }}>Create</Button>
</div>
</Modal>
</div>
);
}
function RolesTab() {
const { data: roles, isLoading } = useRoles();
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: '', description: '', scope: '' });
const createRole = useCreateRole();
const columns: Column<any>[] = [
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
{ key: 'description', header: 'Description' },
{ key: 'scope', header: 'Scope', render: (v) => v ? <Badge label={String(v)} /> : null },
{ key: 'system', header: 'System', render: (v) => v ? <Badge label="System" color="warning" /> : null },
{ key: 'effectivePrincipals', header: 'Users', render: (v) => String((v as any[])?.length ?? 0) },
];
if (isLoading) return <Spinner />;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Role</Button>
</div>
<DataTable columns={columns} data={roles || []} pageSize={20} />
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Role">
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
<FormField label="Description"><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></FormField>
<FormField label="Scope"><Input value={form.scope} onChange={(e) => setForm({ ...form, scope: e.target.value })} /></FormField>
<Button variant="primary" onClick={() => { createRole.mutate(form); setCreateOpen(false); setForm({ name: '', description: '', scope: '' }); }}>Create</Button>
</div>
</Modal>
{tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab />}
{tab === 'roles' && <RolesTab />}
</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 {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, 1fr);
gap: 10px;
margin-bottom: 16px;
}
.scopeTrail {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.groupGrid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -36,11 +43,22 @@
color: var(--text-primary);
}
.instanceTps {
margin-left: auto;
.instanceMeta {
font-size: 11px;
font-family: var(--font-mono);
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 {
@@ -64,3 +82,99 @@
font-weight: 600;
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 {
StatCard, StatusDot, Badge, MonoText,
GroupCard, EventFeed,
GroupCard, EventFeed, Breadcrumb, Alert,
DetailPanel, ProgressBar, LineChart,
} from '@cameleer/design-system';
import styles from './AgentHealth.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents';
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() {
const { appId } = useParams();
@@ -15,6 +170,8 @@ export default function AgentHealth() {
const { data: catalog } = useRouteCatalog();
const { data: events } = useAgentEvents(appId);
const [selectedAgent, setSelectedAgent] = useState<any>(null);
const agentsByApp = useMemo(() => {
const map: Record<string, any[]> = {};
(agents || []).forEach((a: any) => {
@@ -25,10 +182,30 @@ export default function AgentHealth() {
return map;
}, [agents]);
const totalAgents = agents?.length ?? 0;
const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length;
const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').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(() =>
(events || []).map((e: any) => ({
@@ -48,39 +225,67 @@ export default function AgentHealth() {
return (
<div>
<div className={styles.statStrip}>
<StatCard label="Total Agents" value={totalAgents} />
<StatCard label="Live" value={liveCount} accent="success" />
<StatCard label="Stale" value={staleCount} accent="warning" />
<StatCard label="Dead" value={deadCount} accent="error" />
<StatCard label="Total Agents" value={(agents || []).length} detail={`${liveCount} live / ${staleCount} stale / ${deadCount} dead`} />
<StatCard label="Applications" value={uniqueApps} />
<StatCard label="Active Routes" value={activeRoutes} />
<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 className={styles.groupGrid}>
{Object.entries(apps).map(([group, groupAgents]) => (
<GroupCard
key={group}
title={group}
headerRight={<Badge label={`${groupAgents?.length ?? 0} instances`} />}
accent={
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
: 'success'
}
onClick={() => navigate(`/agents/${group}`)}
>
{(groupAgents || []).map((agent: any) => (
<div
key={agent.id}
className={styles.instanceRow}
onClick={(e) => { e.stopPropagation(); navigate(`/agents/${group}/${agent.id}`); }}
>
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
<span className={styles.instanceName}>{agent.name}</span>
<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>}
</div>
))}
</GroupCard>
))}
{Object.entries(apps).map(([group, groupAgents]) => {
const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD');
return (
<GroupCard
key={group}
title={group}
headerRight={<Badge label={`${groupAgents?.length ?? 0} instances`} />}
accent={
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
: 'success'
}
onClick={() => navigate(`/agents/${group}`)}
>
{deadInGroup.length > 0 && (
<Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
)}
{(groupAgents || []).map((agent: any) => (
<div
key={agent.id}
className={styles.instanceRow}
onClick={(e) => {
e.stopPropagation();
setSelectedAgent(agent);
navigate(`/agents/${group}/${agent.id}`);
}}
>
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
<span className={styles.instanceName}>{agent.name}</span>
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
<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>
))}
</GroupCard>
);
})}
</div>
{feedEvents.length > 0 && (
@@ -89,6 +294,26 @@ export default function AgentHealth() {
<EventFeed events={feedEvents} maxItems={100} />
</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>
);
}

View File

@@ -1,6 +1,6 @@
.statStrip {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, 1fr);
gap: 10px;
margin-bottom: 16px;
}
@@ -26,7 +26,7 @@
.chartsGrid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-bottom: 20px;
}
@@ -84,9 +84,35 @@
}
.infoCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
margin-bottom: 20px;
}
.infoGrid {
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 { useParams } from 'react-router';
import {
StatCard, StatusDot, Badge,
LineChart, AreaChart, EventFeed, Breadcrumb, Spinner,
CodeBlock,
StatCard, StatusDot, Badge, Card,
LineChart, AreaChart, BarChart, EventFeed, Breadcrumb, Spinner, EmptyState,
} from '@cameleer/design-system';
import styles from './AgentInstance.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useStatsTimeseries } from '../../api/queries/executions';
import { useAgentMetrics } from '../../api/queries/agent-metrics';
import { useGlobalFilters } from '@cameleer/design-system';
export default function AgentInstance() {
@@ -21,10 +21,28 @@ export default function AgentInstance() {
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
const agent = useMemo(() =>
(agents || []).find((a: any) => a.id === instanceId),
(agents || []).find((a: any) => a.id === instanceId) as any,
[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(() =>
(timeseries?.buckets || []).map((b: any) => ({
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
@@ -48,6 +66,41 @@ export default function AgentInstance() {
[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" />;
return (
@@ -64,15 +117,55 @@ export default function AgentInstance() {
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
<h2>{agent.name}</h2>
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
{agent.version && <Badge label={agent.version} variant="outlined" />}
</div>
<div className={styles.statStrip}>
<StatCard label="TPS" value={agent.tps?.toFixed(1) ?? '0'} />
<StatCard label="Error Rate" value={agent.errorRate ? `${(agent.errorRate * 100).toFixed(1)}%` : '0%'} accent={agent.errorRate > 0.05 ? 'error' : undefined} />
<StatCard label="Active Routes" value={`${agent.activeRoutes ?? 0}/${agent.totalRoutes ?? 0}`} />
<StatCard label="Uptime" value={formatUptime(agent.uptimeSeconds ?? 0)} />
<StatCard label="CPU" value={cpuPct != null ? `${(cpuPct * 100).toFixed(0)}%` : ''} />
<StatCard label="Memory" value={memPct != null ? `${memPct.toFixed(0)}%` : '—'} />
<StatCard label="Throughput" value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '—'} />
<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>
<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.routeBadges}>
{(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.chartsGrid}>
<div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>Throughput</div></div>
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} height={200} />
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>Latency</div></div>
<LineChart series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]} height={200} />
</div>
</div>
</>
)}
<div className={styles.sectionTitle}>Performance</div>
<div className={styles.chartsGrid}>
<div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>CPU Usage</div></div>
{cpuSeries
? <AreaChart series={cpuSeries} yLabel="%" height={200} />
: <EmptyState title="No data" description="No CPU metrics available" />}
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>Memory Heap</div></div>
{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>
{feedEvents.length > 0 && (
<div className={styles.eventCard}>
@@ -105,28 +222,17 @@ export default function AgentInstance() {
</div>
)}
{agent && (
<>
<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>
</>
)}
<EmptyState title="Application Logs" description="Application log streaming is not yet available" />
</div>
);
}
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
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`;
}

View File

@@ -3,6 +3,7 @@ import { useParams } from 'react-router';
import {
StatCard, StatusDot, Badge, MonoText, Sparkline,
DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
Alert, Collapsible, CodeBlock,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
@@ -22,6 +23,8 @@ export default function Dashboard() {
const [detailTab, setDetailTab] = useState('overview');
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: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: searchResult } = useSearchExecutions({
@@ -62,56 +65,88 @@ export default function Dashboard() {
{
label: 'Overview', value: 'overview',
content: (
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Details</div>
<div className={styles.overviewGrid}>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Exchange ID</span>
<MonoText size="sm">{detail.executionId}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Status</span>
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} />
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Route</span>
<span>{detail.routeId}</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Duration</span>
<span>{detail.durationMs}ms</span>
</div>
{detail.errorMessage && (
<>
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Details</div>
<div className={styles.overviewGrid}>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Error</span>
<span>{detail.errorMessage}</span>
<span className={styles.overviewLabel}>Exchange ID</span>
<MonoText size="sm">{detail.executionId}</MonoText>
</div>
)}
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Status</span>
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} />
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Route</span>
<span>{detail.routeId}</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Duration</span>
<span>{detail.durationMs}ms</span>
</div>
</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>
)}
</>
),
},
{
label: 'Processors', value: 'processors',
content: detail.children ? (
<ProcessorTimeline
processors={flattenProcessors(detail.children)}
totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setProcessorIdx(i)}
selectedIndex={processorIdx ?? undefined}
/>
) : <div style={{ padding: '1rem' }}>No processor data</div>,
content: (() => {
const procList = detail.processors?.length ? detail.processors : (detail.children ?? []);
return procList.length ? (
<ProcessorTimeline
processors={flattenProcessors(procList)}
totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setProcessorIdx(i)}
selectedIndex={processorIdx ?? undefined}
/>
) : <div style={{ padding: '1rem' }}>No processor data</div>;
})(),
},
] : [];
return (
<div>
<div className={styles.healthStrip}>
<StatCard label="Total Exchanges" value={stats?.totalCount ?? 0} sparkline={sparklineData} />
<StatCard label="Failed" value={stats?.failedCount ?? 0} accent="error" />
<StatCard label="Avg Duration" value={`${stats?.avgDurationMs ?? 0}ms`} />
<StatCard label="P99 Duration" value={`${stats?.p99LatencyMs ?? 0}ms`} accent="warning" />
<StatCard label="Active" value={stats?.activeCount ?? 0} accent="running" />
<StatCard
label="Throughput"
value={timeWindowSeconds > 0 ? `${((stats?.totalCount ?? 0) / timeWindowSeconds).toFixed(2)} ex/s` : '0.00 ex/s'}
sparkline={sparklineData}
/>
<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 className={styles.tableSection}>

View File

@@ -141,3 +141,43 @@
color: var(--text-muted);
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 {
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
ProcessorTimeline, Breadcrumb, Spinner,
ProcessorTimeline, Breadcrumb, Spinner, SegmentedTabs, RouteFlow,
} from '@cameleer/design-system';
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';
function countProcessors(nodes: any[]): number {
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0);
}
export default function ExchangeDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { data: detail, isLoading } = useExecutionDetail(id ?? null);
const [selectedProcessor, setSelectedProcessor] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<'timeline' | 'flow'>('timeline');
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(() => {
if (!detail?.children) return [];
@@ -58,6 +68,10 @@ export default function ExchangeDetail() {
<div className={styles.headerStatLabel}>Duration</div>
<div className={styles.headerStatValue}>{detail.durationMs}ms</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.headerStatLabel}>Route</div>
<div className={styles.headerStatValue}>{detail.routeId}</div>
@@ -70,6 +84,33 @@ export default function ExchangeDetail() {
</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 && (
<InfoCallout variant="error">
{detail.errorMessage}
@@ -78,18 +119,38 @@ export default function ExchangeDetail() {
<div className={styles.timelineSection}>
<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 className={styles.timelineBody}>
{processors.length > 0 ? (
<ProcessorTimeline
processors={processors}
totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setSelectedProcessor(i)}
selectedIndex={selectedProcessor ?? undefined}
/>
{viewMode === 'timeline' ? (
processors.length > 0 ? (
<ProcessorTimeline
processors={processors}
totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setSelectedProcessor(i)}
selectedIndex={selectedProcessor ?? undefined}
/>
) : (
<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>

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 ExchangeDetail = lazy(() => import('./pages/ExchangeDetail/ExchangeDetail'));
const RoutesMetrics = lazy(() => import('./pages/Routes/RoutesMetrics'));
const RouteDetail = lazy(() => import('./pages/Routes/RouteDetail'));
const AgentHealth = lazy(() => import('./pages/AgentHealth/AgentHealth'));
const AgentInstance = lazy(() => import('./pages/AgentInstance/AgentInstance'));
const RbacPage = lazy(() => import('./pages/Admin/RbacPage'));
@@ -42,7 +43,7 @@ export const router = createBrowserRouter([
{ path: 'exchanges/:id', element: <SuspenseWrapper><ExchangeDetail /></SuspenseWrapper> },
{ path: 'routes', 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/:appId', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> },
{ path: 'agents/:appId/:instanceId', element: <SuspenseWrapper><AgentInstance /></SuspenseWrapper> },