Compare commits
17 Commits
4ff01681d4
...
752d7ec0e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
752d7ec0e7 | ||
|
|
9ab38dfc59 | ||
|
|
907bcd5017 | ||
|
|
83caf4be5b | ||
|
|
1533bea2a6 | ||
|
|
94d1e81852 | ||
|
|
8e27f45a2b | ||
|
|
a86f56f588 | ||
|
|
651cf9de6e | ||
|
|
63d8078688 | ||
|
|
ee69dbedfc | ||
|
|
313d871948 | ||
|
|
f4d2693561 | ||
|
|
2051572ee2 | ||
|
|
cc433b4215 | ||
|
|
31b60c4e24 | ||
|
|
017a0c218e |
@@ -33,7 +33,8 @@ public class OpenApiConfig {
|
||||
"SearchResultExecutionSummary", "UserInfo",
|
||||
"ProcessorNode",
|
||||
"AppCatalogEntry", "RouteSummary", "AgentSummary",
|
||||
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse"
|
||||
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse",
|
||||
"ProcessorMetrics", "AgentMetricsResponse", "MetricBucket"
|
||||
);
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cameleer3.server.app.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record MetricBucket(
|
||||
@NotNull Instant time,
|
||||
double value
|
||||
) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cameleer3.server.app.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record ProcessorMetrics(
|
||||
@NotNull String processorId,
|
||||
@NotNull String processorType,
|
||||
@NotNull String routeId,
|
||||
@NotNull String appId,
|
||||
long totalCount,
|
||||
long failedCount,
|
||||
double avgDurationMs,
|
||||
double p99DurationMs,
|
||||
double errorRate
|
||||
) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cameleer3.server.app.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record SetPasswordRequest(
|
||||
@NotBlank String password
|
||||
) {}
|
||||
@@ -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")
|
||||
|
||||
@@ -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');
|
||||
1625
docs/superpowers/plans/2026-03-23-ui-mock-alignment.md
Normal file
1625
docs/superpowers/plans/2026-03-23-ui-mock-alignment.md
Normal file
File diff suppressed because it is too large
Load Diff
576
docs/superpowers/specs/2026-03-23-ui-mock-alignment-design.md
Normal file
576
docs/superpowers/specs/2026-03-23-ui-mock-alignment-design.md
Normal file
@@ -0,0 +1,576 @@
|
||||
# UI Mock Alignment Design
|
||||
|
||||
**Date:** 2026-03-23
|
||||
**Status:** Reviewed
|
||||
**Scope:** Close all gaps between `@cameleer/design-system` mocks and the cameleer3-server UI
|
||||
|
||||
## Context
|
||||
|
||||
The `@cameleer/design-system` package (v0.0.2) contains fully realized mock pages demonstrating the target UX for the Cameleer3 monitoring platform. The current server UI was built as a first pass and has significant deviations from these mocks across every page. This spec defines the work to align them.
|
||||
|
||||
**Out of scope:**
|
||||
- Business context columns (Order ID, Customer) — not applicable to current data model
|
||||
- Application log streaming — agent does not send logs; placeholder only
|
||||
|
||||
## 1. Backend — New Endpoints
|
||||
|
||||
### 1a. Processor Stats Endpoint
|
||||
|
||||
**`GET /api/v1/routes/metrics/processors`**
|
||||
|
||||
Exposes per-processor statistics. The current `stats_1m_processor` continuous aggregate groups by `(bucket, group_name, route_id, processor_type)` and lacks `processor_id`. TimescaleDB continuous aggregates cannot be ALTERed to add GROUP BY columns.
|
||||
|
||||
**Migration:** Add `V7__processor_stats_by_id.sql` creating a new continuous aggregate `stats_1m_processor_detail`:
|
||||
```sql
|
||||
CREATE MATERIALIZED VIEW stats_1m_processor_detail
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
group_name,
|
||||
route_id,
|
||||
processor_id,
|
||||
processor_type,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
SUM(duration_ms) AS duration_sum,
|
||||
MAX(duration_ms) AS duration_max,
|
||||
approx_percentile(0.99, percentile_agg(duration_ms)) AS p99_duration
|
||||
FROM processor_executions
|
||||
GROUP BY bucket, group_name, route_id, processor_id, processor_type;
|
||||
```
|
||||
Leave the original `stats_1m_processor` intact (used elsewhere).
|
||||
|
||||
**Controller:** Add new method in existing `RouteMetricsController.java` (shares `/api/v1/routes/metrics` base path) rather than a separate controller.
|
||||
|
||||
**Query params:**
|
||||
- `routeId` (required) — filter by route
|
||||
- `appId` (optional) — filter by application
|
||||
- `from` / `to` (optional) — time window, defaults to last 24h
|
||||
|
||||
**Response:** `List<ProcessorMetrics>`
|
||||
```java
|
||||
record ProcessorMetrics(
|
||||
String processorId, // unique processor ID within the route
|
||||
String processorType, // e.g. "to", "process", "choice"
|
||||
String routeId,
|
||||
String appId,
|
||||
long totalCount,
|
||||
long failedCount,
|
||||
double avgDurationMs,
|
||||
double p99DurationMs,
|
||||
double errorRate // failedCount / totalCount
|
||||
)
|
||||
```
|
||||
|
||||
**Security:** VIEWER+ role. Already covered by existing `GET /api/v1/routes/**` wildcard in `SecurityConfig`.
|
||||
|
||||
### 1b. Agent Metrics Query Endpoint
|
||||
|
||||
**`GET /api/v1/agents/{agentId}/metrics`**
|
||||
|
||||
Queries the `agent_metrics` hypertable and returns time-bucketed series.
|
||||
|
||||
**Query params:**
|
||||
- `names` (required) — comma-separated metric names (e.g. `jvm.cpu.process,jvm.memory.heap.used`)
|
||||
- `from` / `to` (optional) — time window, defaults to last 1h
|
||||
- `buckets` (optional, default 60) — number of time buckets
|
||||
|
||||
**Response:** `AgentMetricsResponse`
|
||||
```java
|
||||
record AgentMetricsResponse(
|
||||
Map<String, List<MetricBucket>> metrics
|
||||
)
|
||||
|
||||
record MetricBucket(
|
||||
Instant time,
|
||||
double value // avg within bucket
|
||||
)
|
||||
```
|
||||
|
||||
**Implementation:** Use `time_bucket()` on `agent_metrics.collected_at`, grouped by `metric_name`, averaged by `metric_value`. Filter by `agent_id` and optional `tags` if needed.
|
||||
|
||||
**Security:** VIEWER+ role. Requires new `SecurityConfig` rule: `GET /api/v1/agents/*/metrics` (existing `/api/v1/agents` rule is exact-match only, does not cover sub-paths).
|
||||
|
||||
### 1c. Enrich AgentInstanceResponse
|
||||
|
||||
Add fields to existing `AgentInstanceResponse`:
|
||||
```java
|
||||
// existing fields...
|
||||
String version, // from AgentInfo.version in registry
|
||||
Map<String, Object> capabilities // from AgentInfo.capabilities
|
||||
```
|
||||
|
||||
These values are already stored in the `AgentRegistry`'s `AgentInfo` objects. The `AgentRegistrationController.listAgents()` method just needs to map them into the response DTO.
|
||||
|
||||
### 1d. Password Reset Endpoint
|
||||
|
||||
**`POST /api/v1/admin/users/{userId}/password`**
|
||||
|
||||
The current `UpdateUserRequest` has no password field. Add a dedicated endpoint for admin password reset.
|
||||
|
||||
**Request body:** `SetPasswordRequest`
|
||||
```java
|
||||
record SetPasswordRequest(
|
||||
@NotBlank String password
|
||||
)
|
||||
```
|
||||
|
||||
**Response:** 204 No Content
|
||||
|
||||
**Implementation:** Hash password with same BCrypt encoder used in `createUser`, update `users.password_hash` column.
|
||||
|
||||
**Security:** ADMIN role required (same as other user management endpoints).
|
||||
|
||||
**New files:** `cameleer3-server-app/.../dto/SetPasswordRequest.java`; new method in `UserAdminController`.
|
||||
|
||||
## 2. Dashboard Enhancements
|
||||
|
||||
### 2a. DetailPanel — Errors Section
|
||||
|
||||
When the selected execution has a non-null `errorMessage`:
|
||||
- Insert an "Errors" section between Overview and Processors in the DetailPanel
|
||||
- Display:
|
||||
- Error class: parsed from `errorMessage` (text before `:` or first line)
|
||||
- Error message: remainder of `errorMessage`
|
||||
- Stack trace: `errorStackTrace` in a collapsible `CodeBlock` (content prop)
|
||||
- Use `Alert` variant="error" for the error class/message, `Collapsible` + `CodeBlock` for the stack trace
|
||||
|
||||
### 2b. DetailPanel — Route Flow Tab
|
||||
|
||||
Add a third tab to the DetailPanel tabs: **Overview | Processors | Route Flow**
|
||||
|
||||
- Fetch diagram via `useDiagramByRoute(execution.groupName, execution.routeId)`
|
||||
- Render `RouteFlow` component from design system
|
||||
- Overlay execution data: map each `ProcessorNode` status onto diagram nodes using `diagramNodeId`
|
||||
- Color nodes by status: success (green), failed (red), running (blue)
|
||||
- Show duration labels on nodes
|
||||
- **RouteFlow overlay API:** The `RouteFlow` component accepts execution data to color nodes. During implementation, read the `RouteFlow.tsx` source in the design system to confirm the exact props interface (likely an `overlays` or `nodeStates` prop mapping node IDs to status/duration). Map `ProcessorNode.diagramNodeId` → `PositionedNode.id` to connect execution data to diagram nodes.
|
||||
|
||||
### 2c. Stat Card Alignment
|
||||
|
||||
Change the 5 stat cards to match mock semantics:
|
||||
|
||||
| Position | Label | Value | Source |
|
||||
|----------|-------|-------|--------|
|
||||
| 1 | Throughput | exchanges/s | `totalCount / timeWindowSeconds` from stats |
|
||||
| 2 | Error Rate | % | `failedCount / totalCount * 100` from stats |
|
||||
| 3 | Avg Latency | ms | `avgDurationMs` from stats |
|
||||
| 4 | P99 Latency | ms | `p99LatencyMs` from stats |
|
||||
| 5 | In-Flight | count | `activeCount` from stats |
|
||||
|
||||
Each card includes:
|
||||
- `Sparkline` from timeseries buckets (existing)
|
||||
- Trend arrow: compare current vs `prev*` fields, show up/down indicator
|
||||
|
||||
## 3. Exchange Detail Enhancements
|
||||
|
||||
### 3a. Correlation Chain
|
||||
|
||||
Below the exchange header card, add a "Correlation Chain" section:
|
||||
|
||||
- **Data source:** `POST /search/executions` with filter `{ correlationId: execution.correlationId }`
|
||||
- **Rendering:** Horizontal chain of small cards connected by arrows
|
||||
- Each card: route name, `StatusDot`, duration, relative timestamp
|
||||
- Current exchange highlighted
|
||||
- Click navigates to that exchange (`/exchanges/:id`)
|
||||
- **Conditional:** Only show section when correlationId is present and search returns > 1 result
|
||||
- **Limit:** Request with `limit: 20` to prevent excessive results. If more exist, show "+N more" link
|
||||
- **Hook:** `useCorrelationChain(correlationId)` — new query hook wrapping the search call
|
||||
|
||||
### 3b. Timeline / Flow Toggle
|
||||
|
||||
Above the processor timeline section:
|
||||
|
||||
- Add `SegmentedTabs` with options: **Timeline** | **Flow**
|
||||
- **Timeline** (default): existing `ProcessorTimeline` component (Gantt view)
|
||||
- **Flow**: `RouteFlow` component with execution overlay
|
||||
- Fetch diagram via `useDiagramByRoute(execution.groupName, execution.routeId)` (same as 2b)
|
||||
- Color nodes by processor status, show duration labels
|
||||
- Clicking a processor node in either view selects it and loads its snapshot
|
||||
|
||||
### 3c. Header Enrichment
|
||||
|
||||
Add to the exchange header:
|
||||
- Processor count: `execution.processors.length` (or recursive count for nested trees)
|
||||
- Display as a stat in the header's right section alongside duration
|
||||
|
||||
## 4. Route Detail Page (NEW)
|
||||
|
||||
**New page** at `/routes/:appId/:routeId`. Currently this path renders a filtered `RoutesMetrics`; replace with a dedicated route detail page. The filtered table/chart view from `RoutesMetrics` is not lost — it is subsumed by the Performance and Recent Executions tabs in the new page, which provide the same data in a richer context alongside the route diagram.
|
||||
|
||||
Update `router.tsx`: the `/routes/:appId/:routeId` route imports a new `RouteDetail` component instead of `RoutesMetrics`. The `/routes` and `/routes/:appId` routes remain unchanged (continue to render `RoutesMetrics`).
|
||||
|
||||
### 4a. Route Header Card
|
||||
|
||||
Card displaying:
|
||||
- Route name (`routeId`) and application name (`appId`)
|
||||
- Health status from route catalog (`useRouteCatalog()` filtered)
|
||||
- Exchange count (last 24h)
|
||||
- Last seen timestamp
|
||||
- Back link to `/routes/:appId`
|
||||
|
||||
### 4b. Route Diagram + Processor Stats (Side-by-Side)
|
||||
|
||||
Two-column grid:
|
||||
- **Left:** `RouteFlow` component rendering the route diagram
|
||||
- Data from `useDiagramByRoute(appId, routeId)` or `useDiagramLayout(contentHash)`
|
||||
- **Right:** Processor stats table from new endpoint (1a)
|
||||
- `DataTable` columns: Processor ID, Type, Executions, Avg Duration, P99 Duration, Error Rate
|
||||
- Data from `useProcessorMetrics(routeId, appId)`
|
||||
|
||||
### 4c. Tabbed Section
|
||||
|
||||
`Tabs` component with three tabs:
|
||||
|
||||
**Performance tab:**
|
||||
- 2x2 chart grid (same pattern as RoutesMetrics) filtered to this specific route
|
||||
- Data from `useStatsTimeseries(from, to, routeId, appId)`
|
||||
|
||||
**Recent Executions tab:**
|
||||
- `DataTable` showing recent executions for this route
|
||||
- Data from `useSearchExecutions({ routeId, group: appId, limit: 20, sortField: 'startTime', sortDir: 'desc' })`
|
||||
- Columns: Status, Execution ID, Duration, Start Time, Error Message
|
||||
- Row click navigates to `/exchanges/:id`
|
||||
|
||||
**Error Patterns tab:**
|
||||
- Group failed executions by `errorMessage`
|
||||
- Display: error message (truncated), count, last occurrence timestamp, link to sample execution
|
||||
- Data from `useSearchExecutions({ routeId, group: appId, status: 'FAILED', limit: 100 })` — client-side grouping by `errorMessage`
|
||||
|
||||
### 4d. CSS Module
|
||||
|
||||
`RouteDetail.module.css` with classes:
|
||||
- `.headerCard` — surface card, padding, margin-bottom
|
||||
- `.diagramStatsGrid` — 2-column grid
|
||||
- `.diagramPane` / `.statsPane` — surface cards
|
||||
- `.tabSection` — margin-top
|
||||
- `.chartGrid` — 2x2 grid (reuse pattern from RoutesMetrics)
|
||||
|
||||
## 5. Agent Health Enhancements
|
||||
|
||||
### 5a. DetailPanel (Slide-In)
|
||||
|
||||
Add a `DetailPanel` from the design system, triggered by clicking an instance row in a GroupCard.
|
||||
|
||||
**Overview tab:**
|
||||
- Status with `StatusDot` and `Badge`
|
||||
- Application name, version (from enriched AgentInstanceResponse, section 1c)
|
||||
- Uptime (formatted), last heartbeat (relative time)
|
||||
- TPS, error rate
|
||||
- Active routes / total routes
|
||||
- Memory usage: `ProgressBar` — data from `GET /agents/{id}/metrics?names=jvm.memory.heap.used,jvm.memory.heap.max&buckets=1` (single latest bucket gives the most recent averaged value)
|
||||
- CPU usage: `ProgressBar` — data from `GET /agents/{id}/metrics?names=jvm.cpu.process&buckets=1` (single latest bucket)
|
||||
|
||||
**Performance tab:**
|
||||
- Two `LineChart` components:
|
||||
- Throughput over time (from timeseries stats filtered by agentId, or from agent metrics)
|
||||
- Error rate over time
|
||||
|
||||
### 5b. Instance Table Enrichment
|
||||
|
||||
Add columns to the instance rows within each `GroupCard`:
|
||||
|
||||
| Column | Source |
|
||||
|--------|--------|
|
||||
| Status dot | `agent.status` (existing) |
|
||||
| Instance name | `agent.name` (existing) |
|
||||
| State badge | `agent.status` (existing) |
|
||||
| Uptime | `agent.uptimeSeconds` → formatted "2d 4h" / "15m" |
|
||||
| TPS | `agent.tps` (existing) |
|
||||
| Error rate | `agent.errorRate` → percentage |
|
||||
| Last heartbeat | `agent.lastHeartbeat` → relative "2m ago" |
|
||||
| Link | Icon button → `/agents/:appId/:instanceId` |
|
||||
|
||||
### 5c. Alert Banners
|
||||
|
||||
When a `GroupCard` contains instances with status `DEAD`:
|
||||
- Show `Alert` variant="error" at the top of the card body
|
||||
- Message: `"N instance(s) unreachable"` where N is the count of DEAD instances
|
||||
|
||||
### 5d. Stat Card Alignment
|
||||
|
||||
Replace current 4 cards (Total, Live, Stale, Dead) with 5 cards matching mock:
|
||||
|
||||
| Label | Value | Accent |
|
||||
|-------|-------|--------|
|
||||
| Total Agents | count (subtitle: "N live / N stale / N dead") | default |
|
||||
| Applications | count of unique appIds | default |
|
||||
| Active Routes | sum of activeRoutes across live agents | default |
|
||||
| Total TPS | sum of tps across live agents | default |
|
||||
| Dead | count of dead agents | error |
|
||||
|
||||
### 5e. Scope Trail
|
||||
|
||||
Add breadcrumb below stat cards:
|
||||
- All agents view: `Agents` with live `Badge` showing "N live"
|
||||
- Filtered by app: `Agents` > `{appName}` with health `Badge` (live/stale/dead color)
|
||||
|
||||
## 6. Agent Instance Enhancements
|
||||
|
||||
### 6a. JVM Metrics Charts (3x2 Grid)
|
||||
|
||||
Replace current 2-column chart grid with 3x2 grid. All data from new endpoint (1b).
|
||||
|
||||
| Chart | Type | Metric Name(s) |
|
||||
|-------|------|----------------|
|
||||
| CPU Usage | AreaChart | `jvm.cpu.process` (0-1 scale, display as %) |
|
||||
| Memory (Heap) | AreaChart | `jvm.memory.heap.used` + `jvm.memory.heap.max` (two series) |
|
||||
| Throughput | AreaChart | from `useStatsTimeseries` filtered by agent (existing) |
|
||||
| Error Rate | LineChart | from `useStatsTimeseries` filtered by agent (existing) |
|
||||
| Thread Count | LineChart | `jvm.threads.count` |
|
||||
| GC Pauses | BarChart | `jvm.gc.time` |
|
||||
|
||||
**Hook:** `useAgentMetrics(agentId, metricNames[], from, to, buckets)` — wraps endpoint 1b.
|
||||
|
||||
### 6b. Process Information Card
|
||||
|
||||
Card with key-value pairs:
|
||||
|
||||
| Key | Source |
|
||||
|-----|--------|
|
||||
| JVM Version | `agent.capabilities.jvmVersion` or parse from registration |
|
||||
| Camel Version | `agent.capabilities.camelVersion` |
|
||||
| Spring Boot | `agent.capabilities.springBootVersion` |
|
||||
| Started | `agent.registeredAt` formatted |
|
||||
| Capabilities | render as tags: tracing, metrics, diagrams, replay |
|
||||
|
||||
Data from enriched `AgentInstanceResponse` (section 1c). If version details aren't in current capabilities, they can be added to agent registration in a future iteration — show what's available.
|
||||
|
||||
### 6c. Stat Card Alignment
|
||||
|
||||
Replace current 4 cards with 5 cards matching mock:
|
||||
|
||||
| Label | Value | Source |
|
||||
|-------|-------|--------|
|
||||
| CPU | % | latest `jvm.cpu.process` from agent metrics |
|
||||
| Memory | % | latest `heap.used / heap.max * 100` |
|
||||
| Throughput | req/s | `agent.tps` |
|
||||
| Errors | % | `agent.errorRate` |
|
||||
| Uptime | formatted | `agent.uptimeSeconds` |
|
||||
|
||||
CPU and Memory require a small fetch from endpoint 1b (latest single value).
|
||||
|
||||
### 6d. Application Log Placeholder
|
||||
|
||||
Below the EventFeed card, add an `EmptyState` component:
|
||||
- Title: "Application Logs"
|
||||
- Description: "Application log streaming is not yet available"
|
||||
- No action button
|
||||
|
||||
### 6e. Version Badge in Scope Trail
|
||||
|
||||
Breadcrumb: `Agents` > `{appName}` > `{instanceName}`
|
||||
- Add `Badge` next to instance name showing version (from enriched response)
|
||||
- Add `StatusDot` + status `Badge` for visual state
|
||||
|
||||
## 7. Admin & Miscellaneous
|
||||
|
||||
### 7a. OIDC Config — Default Roles
|
||||
|
||||
Add a "Default Roles" section to the OIDC config page:
|
||||
- Display current default roles as `Tag` components (removable, click X to remove)
|
||||
- `Input` + "Add" `Button` to add a role
|
||||
- Validate against existing roles from `useRoles()` query
|
||||
- Persist via existing OIDC config save endpoint
|
||||
|
||||
### 7b. OIDC Config — ConfirmDialog on Delete
|
||||
|
||||
Replace direct delete button with `ConfirmDialog`:
|
||||
- Message: "Delete OIDC configuration? All OIDC users will lose access."
|
||||
- Require typing "DELETE" to confirm
|
||||
|
||||
### 7c. Design System Update
|
||||
|
||||
Update `@cameleer/design-system` from `^0.0.1` to `^0.0.2` in `ui/package.json`.
|
||||
|
||||
**TopBar `onLogout` prop:** Replace the custom `Dropdown` + `Avatar` logout hack in `LayoutShell.tsx` with the TopBar's new `onLogout` prop:
|
||||
```tsx
|
||||
<TopBar
|
||||
breadcrumb={breadcrumb}
|
||||
user={{ name: username }}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
```
|
||||
|
||||
Remove the manual Avatar/Dropdown logout code.
|
||||
|
||||
**Verification needed during implementation:** Confirm that the TopBar v0.0.2 renders a user avatar/menu internally when `user` + `onLogout` are provided. If it only renders a bare logout button without the "Signed in as" display, keep the custom Avatar/Dropdown and just wire up the TopBar's `onLogout` as an additional trigger.
|
||||
|
||||
### 7d. Regenerate schema.d.ts
|
||||
|
||||
After backend endpoints are added, regenerate types from the running server:
|
||||
```bash
|
||||
npm run generate-api:live
|
||||
```
|
||||
|
||||
This ensures all new DTOs (`ProcessorMetrics`, `AgentMetricsResponse`, `MetricBucket`, enriched `AgentInstanceResponse`) are accurately typed.
|
||||
|
||||
## 8. RBAC / User Management Overhaul
|
||||
|
||||
The current RBAC page is a basic DataTable + Modal CRUD interface. The design system mock implements a split-pane detail-oriented admin panel with rich interactions. This section describes the full rebuild.
|
||||
|
||||
### 8a. Layout — Split-Pane Replaces DataTable
|
||||
|
||||
All three tabs (Users, Groups, Roles) adopt the same layout pattern:
|
||||
|
||||
```
|
||||
┌─────────────────────┬────────────────────┐
|
||||
│ List Pane (52%) │ Detail Pane (48%) │
|
||||
│ │ │
|
||||
│ [Search input] │ [Selected entity │
|
||||
│ [+ Create button] │ detail view] │
|
||||
│ │ │
|
||||
│ [Inline create form]│ │
|
||||
│ │ │
|
||||
│ [Scrollable entity │ │
|
||||
│ list with avatars, │ │
|
||||
│ badges, tags] │ │
|
||||
│ │ │
|
||||
└─────────────────────┴────────────────────┘
|
||||
```
|
||||
|
||||
**New file:** `ui/src/pages/Admin/UserManagement.module.css`
|
||||
- `.splitPane` — CSS grid `52fr 48fr`, full height
|
||||
- `.listPane` — scrollable, border-right
|
||||
- `.detailPane` — scrollable, padding
|
||||
- `.entityItem` / `.entityItemSelected` — list items with hover/selected states
|
||||
- `.entityInfo`, `.entityName`, `.entityMeta`, `.entityTags` — list item layout
|
||||
- `.createForm`, `.createFormActions` — inline form styling
|
||||
- `.metaGrid` — key-value metadata layout
|
||||
- `.sectionTags` — tag group with wrap
|
||||
- `.inheritedNote` — small italic annotation text
|
||||
- `.securitySection` / `.resetForm` — password management styling
|
||||
|
||||
**Keep existing stat cards** above tabs — these are a useful addition not present in the mock.
|
||||
|
||||
### 8b. Users Tab
|
||||
|
||||
**List pane:**
|
||||
- **Search:** `Input` with search icon, filters across username, displayName, email (client-side)
|
||||
- **Create button:** "+ Add User" opens inline form (not modal)
|
||||
- **Inline create form:**
|
||||
- `Input`: Username (required), Display Name, Email
|
||||
- `Input`: Password (required)
|
||||
- Client-side validation: duplicate username check, required fields
|
||||
- Cancel + Create buttons
|
||||
- **Note:** Admin-created users are always local. OIDC users are auto-provisioned on first login (no admin creation needed). The create form does not include a provider selector.
|
||||
- **Entity list:** `role="listbox"`, each item `role="option"` with `tabIndex={0}`
|
||||
- `Avatar` (initials, size sm)
|
||||
- Display name + provider `Badge` (if not local)
|
||||
- Email + group path in meta line
|
||||
- Direct roles and groups as small `Badge` tags
|
||||
- Click or Enter/Space to select → populates detail pane
|
||||
|
||||
**Detail pane (when user selected):**
|
||||
- **Header:** `Avatar` (lg) + Display name (`InlineEdit` for rename) + Email + Delete button
|
||||
- **Status:** "Active" `Tag`
|
||||
- **Metadata grid:** User ID (`MonoText`), Created (formatted date+time), Provider
|
||||
- **Security section:**
|
||||
- Local users: masked password display + "Reset password" button → toggles inline form (new password `Input` + Cancel/Set)
|
||||
- OIDC users: `InfoCallout` "Password managed by identity provider"
|
||||
- **Group membership:**
|
||||
- Current groups as removable `Tag` components
|
||||
- `MultiSelect` dropdown to add groups
|
||||
- Warning on removal if inherited roles would be revoked
|
||||
- **Effective roles:**
|
||||
- Direct roles: removable `Tag` (warning color)
|
||||
- Inherited roles: dashed `Badge` with "↑ groupName" source notation (opacity 0.65, non-removable)
|
||||
- `MultiSelect` to add direct roles
|
||||
- Note: "Roles with ↑ are inherited through group membership"
|
||||
- **Delete:** `ConfirmDialog` requiring username to be typed. Self-delete guard (can't delete own account).
|
||||
|
||||
**API hooks used:** `useUsers`, `useUser`, `useCreateUser`, `useUpdateUser`, `useDeleteUser`, `useAssignRoleToUser`, `useRemoveRoleFromUser`, `useAddUserToGroup`, `useRemoveUserFromGroup`, `useGroups`, `useRoles`
|
||||
|
||||
### 8c. Groups Tab
|
||||
|
||||
**List pane:**
|
||||
- **Search:** filter by group name
|
||||
- **Create form:** inline with name + parent group `Select` dropdown (options: "Top-level" + all existing groups)
|
||||
- **Entity list:**
|
||||
- `Avatar` + group name
|
||||
- Meta: "Child of {parent}" or "Top-level" + child count + member count
|
||||
- Role tags
|
||||
|
||||
**Detail pane:**
|
||||
- **Header:** Group name (`InlineEdit` for non-built-in) + parent info + Delete button (disabled for built-in Admins group)
|
||||
- **Metadata:** Group ID (`MonoText`)
|
||||
- **Parent group:** display current parent
|
||||
- **Members:** removable `Tag` list + `MultiSelect` to add users. Note: "+ all members of child groups" if applicable
|
||||
- **Child groups:** removable `Tag` list + `MultiSelect` to add existing groups as children. Circular reference prevention (can't add ancestor as child)
|
||||
- **Assigned roles:** removable `Tag` list + `MultiSelect` to add roles. Warning on removal: "Removing {role} from {group} will affect N member(s). Continue?"
|
||||
- **Delete:** `ConfirmDialog`. Guard: built-in Admins group cannot be deleted.
|
||||
|
||||
**API hooks used:** `useGroups`, `useGroup`, `useCreateGroup`, `useUpdateGroup`, `useDeleteGroup`, `useAssignRoleToGroup`, `useRemoveRoleFromGroup`
|
||||
|
||||
### 8d. Roles Tab
|
||||
|
||||
**List pane:**
|
||||
- **Search:** filter by role name
|
||||
- **Create form:** inline with name (auto-uppercase) + description
|
||||
- **Entity list:**
|
||||
- `Avatar` + role name + "system" `Badge` (if system role)
|
||||
- Meta: description + assignment count
|
||||
- Tags: assigned groups (success color) + direct users
|
||||
|
||||
**Detail pane:**
|
||||
- **Header:** Role name + description + Delete button (disabled for system roles)
|
||||
- **Metadata:** Role ID (`MonoText`), scope, type (system/custom — "System role (read-only)")
|
||||
- **Assigned to groups:** view-only `Tag` list (shows which groups have this role)
|
||||
- **Assigned to users (direct):** view-only `Tag` list
|
||||
- **Effective principals:** filled `Badge` (direct assignment) + dashed `Badge` (inherited via group). Note: "Dashed entries inherit this role through group membership"
|
||||
|
||||
**API hooks used:** `useRoles`, `useRole`, `useCreateRole`, `useUpdateRole`, `useDeleteRole`
|
||||
|
||||
### 8e. Shared Patterns
|
||||
|
||||
- **Toast notifications** for all mutations (create, update, delete, assign, remove) — use `useToast` from design system
|
||||
- **Cascade warnings** when actions affect other entities (removing role from group, removing user from group with roles)
|
||||
- **Keyboard accessibility:** Enter/Space to select, ARIA roles (`listbox`, `option`), `aria-selected`
|
||||
- **Mutation button states:** disable while in-flight, show spinner
|
||||
- **ToastProvider:** Add `ToastProvider` from design system to `LayoutShell.tsx` (or app root in `main.tsx`) to enable `useToast()` hook across admin pages
|
||||
- **Graceful empty states:** When agent metrics are unavailable (agent not sending a particular metric), show per-chart empty state rather than crashing. Check metric name existence in response before rendering.
|
||||
|
||||
## File Impact Summary
|
||||
|
||||
### New files:
|
||||
- `ui/src/pages/Routes/RouteDetail.tsx` + `RouteDetail.module.css`
|
||||
- `ui/src/pages/Admin/UserManagement.module.css`
|
||||
- `ui/src/pages/Admin/UsersTab.tsx`
|
||||
- `ui/src/pages/Admin/GroupsTab.tsx`
|
||||
- `ui/src/pages/Admin/RolesTab.tsx`
|
||||
- `ui/src/api/queries/agent-metrics.ts` (useAgentMetrics hook)
|
||||
- `ui/src/api/queries/processor-metrics.ts` (useProcessorMetrics hook)
|
||||
- `ui/src/api/queries/correlation.ts` (useCorrelationChain hook)
|
||||
- `cameleer3-server-app/.../controller/AgentMetricsController.java`
|
||||
- `cameleer3-server-app/.../dto/ProcessorMetrics.java`
|
||||
- `cameleer3-server-app/.../dto/AgentMetricsResponse.java`
|
||||
- `cameleer3-server-app/.../dto/MetricBucket.java`
|
||||
- `cameleer3-server-app/.../dto/SetPasswordRequest.java`
|
||||
- `cameleer3-server-app/src/main/resources/db/migration/V7__processor_stats_by_id.sql`
|
||||
|
||||
### Modified files:
|
||||
- `ui/package.json` — design system `^0.0.2`
|
||||
- `ui/src/router.tsx` — add RouteDetail route
|
||||
- `ui/src/components/LayoutShell.tsx` — TopBar `onLogout` prop, remove Dropdown/Avatar
|
||||
- `ui/src/pages/Dashboard/Dashboard.tsx` — error section, RouteFlow tab, stat card changes
|
||||
- `ui/src/pages/Dashboard/Dashboard.module.css` — new classes
|
||||
- `ui/src/pages/ExchangeDetail/ExchangeDetail.tsx` — correlation chain, flow toggle, processor count
|
||||
- `ui/src/pages/ExchangeDetail/ExchangeDetail.module.css` — new classes
|
||||
- `ui/src/pages/Routes/RoutesMetrics.tsx` — stat card adjustments
|
||||
- `ui/src/pages/AgentHealth/AgentHealth.tsx` — DetailPanel, table enrichment, alert banners, stat cards, scope trail
|
||||
- `ui/src/pages/AgentHealth/AgentHealth.module.css` — new classes
|
||||
- `ui/src/pages/AgentInstance/AgentInstance.tsx` — 3x2 charts, process info, stat cards, log placeholder, version badge
|
||||
- `ui/src/pages/AgentInstance/AgentInstance.module.css` — new classes
|
||||
- `ui/src/pages/Admin/RbacPage.tsx` — restructured to container with split-pane tabs
|
||||
- `ui/src/pages/Admin/OidcConfigPage.tsx` — default roles, ConfirmDialog
|
||||
- `ui/src/api/schema.d.ts` — regenerated with new types
|
||||
- `cameleer3-server-app/.../dto/AgentInstanceResponse.java` — add version, capabilities
|
||||
- `cameleer3-server-app/.../controller/AgentRegistrationController.java` — map version/capabilities
|
||||
- `cameleer3-server-app/.../controller/RouteMetricsController.java` — add processor stats method
|
||||
- `cameleer3-server-app/.../controller/UserAdminController.java` — add password reset method
|
||||
- `cameleer3-server-app/.../SecurityConfig.java` — add rule for `GET /api/v1/agents/*/metrics`
|
||||
- `ui/src/main.tsx` or `ui/src/components/LayoutShell.tsx` — add `ToastProvider`
|
||||
- `cameleer3-server-app/.../OpenApiConfig.java` — register new DTOs
|
||||
|
||||
### Backend migration:
|
||||
- `V7__processor_stats_by_id.sql` — new `stats_1m_processor_detail` continuous aggregate with `processor_id` grouping
|
||||
72
ui/package-lock.json
generated
72
ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
4515
ui/src/api/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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({
|
||||
|
||||
26
ui/src/api/queries/agent-metrics.ts
Normal file
26
ui/src/api/queries/agent-metrics.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { config } from '../../config';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
|
||||
export function useAgentMetrics(agentId: string | null, names: string[], buckets = 60) {
|
||||
return useQuery({
|
||||
queryKey: ['agent-metrics', agentId, names.join(','), buckets],
|
||||
queryFn: async () => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const params = new URLSearchParams({
|
||||
names: names.join(','),
|
||||
buckets: String(buckets),
|
||||
});
|
||||
const res = await fetch(`${config.apiBaseUrl}/agents/${agentId}/metrics?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Cameleer-Protocol-Version': '1',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json() as Promise<{ metrics: Record<string, Array<{ time: string; value: number }>> }>;
|
||||
},
|
||||
enabled: !!agentId && names.length > 0,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
20
ui/src/api/queries/correlation.ts
Normal file
20
ui/src/api/queries/correlation.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../client';
|
||||
|
||||
export function useCorrelationChain(correlationId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['correlation-chain', correlationId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.POST('/search/executions', {
|
||||
body: {
|
||||
correlationId: correlationId!,
|
||||
limit: 20,
|
||||
sortField: 'startTime',
|
||||
sortDir: 'asc',
|
||||
},
|
||||
});
|
||||
return data;
|
||||
},
|
||||
enabled: !!correlationId,
|
||||
});
|
||||
}
|
||||
25
ui/src/api/queries/processor-metrics.ts
Normal file
25
ui/src/api/queries/processor-metrics.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { config } from '../../config';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
|
||||
export function useProcessorMetrics(routeId: string | null, appId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['processor-metrics', routeId, appId],
|
||||
queryFn: async () => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const params = new URLSearchParams();
|
||||
if (routeId) params.set('routeId', routeId);
|
||||
if (appId) params.set('appId', appId);
|
||||
const res = await fetch(`${config.apiBaseUrl}/routes/metrics/processors?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Cameleer-Protocol-Version': '1',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!routeId,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
3769
ui/src/api/schema.d.ts
vendored
3769
ui/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
|
||||
402
ui/src/pages/Admin/GroupsTab.tsx
Normal file
402
ui/src/pages/Admin/GroupsTab.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
MonoText,
|
||||
Tag,
|
||||
Select,
|
||||
ConfirmDialog,
|
||||
Spinner,
|
||||
InlineEdit,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import {
|
||||
useGroups,
|
||||
useGroup,
|
||||
useCreateGroup,
|
||||
useUpdateGroup,
|
||||
useDeleteGroup,
|
||||
useAssignRoleToGroup,
|
||||
useRemoveRoleFromGroup,
|
||||
useAddUserToGroup,
|
||||
useRemoveUserFromGroup,
|
||||
useUsers,
|
||||
useRoles,
|
||||
} from '../../api/queries/admin/rbac';
|
||||
import styles from './UserManagement.module.css';
|
||||
|
||||
const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010';
|
||||
|
||||
export default function GroupsTab() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [newGroupParentId, setNewGroupParentId] = useState<string>('');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [addMemberUserId, setAddMemberUserId] = useState<string>('');
|
||||
const [addRoleId, setAddRoleId] = useState<string>('');
|
||||
|
||||
const { toast } = useToast();
|
||||
const { data: groups = [], isLoading: groupsLoading } = useGroups();
|
||||
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedGroupId);
|
||||
const { data: users = [] } = useUsers();
|
||||
const { data: roles = [] } = useRoles();
|
||||
|
||||
const createGroup = useCreateGroup();
|
||||
const updateGroup = useUpdateGroup();
|
||||
const deleteGroup = useDeleteGroup();
|
||||
const assignRoleToGroup = useAssignRoleToGroup();
|
||||
const removeRoleFromGroup = useRemoveRoleFromGroup();
|
||||
const addUserToGroup = useAddUserToGroup();
|
||||
const removeUserFromGroup = useRemoveUserFromGroup();
|
||||
|
||||
const filteredGroups = groups.filter((g) =>
|
||||
g.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const parentOptions = [
|
||||
{ value: '', label: 'Top-level' },
|
||||
...groups.map((g) => ({ value: g.id, label: g.name })),
|
||||
];
|
||||
|
||||
const parentName = (parentGroupId: string | null) => {
|
||||
if (!parentGroupId) return 'Top-level';
|
||||
const parent = groups.find((g) => g.id === parentGroupId);
|
||||
return parent ? parent.name : parentGroupId;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await createGroup.mutateAsync({
|
||||
name,
|
||||
parentGroupId: newGroupParentId || null,
|
||||
});
|
||||
toast({ title: 'Group created', variant: 'success' });
|
||||
setNewGroupName('');
|
||||
setNewGroupParentId('');
|
||||
setShowCreate(false);
|
||||
} catch {
|
||||
toast({ title: 'Failed to create group', variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (newName: string) => {
|
||||
if (!selectedGroup) return;
|
||||
try {
|
||||
await updateGroup.mutateAsync({
|
||||
id: selectedGroup.id,
|
||||
name: newName,
|
||||
parentGroupId: selectedGroup.parentGroupId,
|
||||
});
|
||||
toast({ title: 'Group renamed', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to rename group', variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedGroup) return;
|
||||
try {
|
||||
await deleteGroup.mutateAsync(selectedGroup.id);
|
||||
toast({ title: 'Group deleted', variant: 'success' });
|
||||
setSelectedGroupId(null);
|
||||
setDeleteOpen(false);
|
||||
} catch {
|
||||
toast({ title: 'Failed to delete group', variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!selectedGroup || !addMemberUserId) return;
|
||||
try {
|
||||
await addUserToGroup.mutateAsync({
|
||||
userId: addMemberUserId,
|
||||
groupId: selectedGroup.id,
|
||||
});
|
||||
toast({ title: 'Member added', variant: 'success' });
|
||||
setAddMemberUserId('');
|
||||
} catch {
|
||||
toast({ title: 'Failed to add member', variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
if (!selectedGroup) return;
|
||||
try {
|
||||
await removeUserFromGroup.mutateAsync({ userId, groupId: selectedGroup.id });
|
||||
toast({ title: 'Member removed', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to remove member', variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRole = async () => {
|
||||
if (!selectedGroup || !addRoleId) return;
|
||||
try {
|
||||
await assignRoleToGroup.mutateAsync({
|
||||
groupId: selectedGroup.id,
|
||||
roleId: addRoleId,
|
||||
});
|
||||
toast({ title: 'Role assigned', variant: 'success' });
|
||||
setAddRoleId('');
|
||||
} catch {
|
||||
toast({ title: 'Failed to assign role', variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRole = async (roleId: string) => {
|
||||
if (!selectedGroup) return;
|
||||
try {
|
||||
await removeRoleFromGroup.mutateAsync({ groupId: selectedGroup.id, roleId });
|
||||
toast({ title: 'Role removed', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to remove role', variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
|
||||
|
||||
// Build sets for quick lookup of already-assigned items
|
||||
const memberUserIds = new Set((selectedGroup?.members ?? []).map((m) => m.userId));
|
||||
const assignedRoleIds = new Set((selectedGroup?.directRoles ?? []).map((r) => r.id));
|
||||
|
||||
const availableUsers = users.filter((u) => !memberUserIds.has(u.userId));
|
||||
const availableRoles = roles.filter((r) => !assignedRoleIds.has(r.id));
|
||||
|
||||
return (
|
||||
<div className={styles.splitPane}>
|
||||
{/* Left pane */}
|
||||
<div className={styles.listPane}>
|
||||
<div className={styles.listHeader}>
|
||||
<Input
|
||||
placeholder="Search groups..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch('')}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setShowCreate((v) => !v)}
|
||||
>
|
||||
+ Add Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className={styles.createForm}>
|
||||
<Input
|
||||
placeholder="Group name"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Select
|
||||
options={parentOptions}
|
||||
value={newGroupParentId}
|
||||
onChange={(e) => setNewGroupParentId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.createFormActions}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowCreate(false);
|
||||
setNewGroupName('');
|
||||
setNewGroupParentId('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
loading={createGroup.isPending}
|
||||
onClick={handleCreate}
|
||||
disabled={!newGroupName.trim()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupsLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className={styles.entityList} role="listbox">
|
||||
{filteredGroups.map((group) => {
|
||||
const isSelected = group.id === selectedGroupId;
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={
|
||||
styles.entityItem +
|
||||
(isSelected ? ' ' + styles.entityItemSelected : '')
|
||||
}
|
||||
onClick={() => setSelectedGroupId(group.id)}
|
||||
>
|
||||
<Avatar name={group.name} size="sm" />
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>{group.name}</div>
|
||||
<div className={styles.entityMeta}>
|
||||
{group.parentGroupId
|
||||
? `Child of ${parentName(group.parentGroupId)}`
|
||||
: 'Top-level'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right pane */}
|
||||
<div className={styles.detailPane}>
|
||||
{!selectedGroupId ? (
|
||||
<div className={styles.emptyDetail}>Select a group to view details</div>
|
||||
) : detailLoading ? (
|
||||
<Spinner />
|
||||
) : selectedGroup ? (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className={styles.detailHeader}>
|
||||
<Avatar name={selectedGroup.name} size="md" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<InlineEdit
|
||||
value={selectedGroup.name}
|
||||
onSave={handleRename}
|
||||
disabled={isBuiltinAdmins}
|
||||
/>
|
||||
<div className={styles.entityMeta}>
|
||||
{selectedGroup.parentGroupId
|
||||
? `Child of ${parentName(selectedGroup.parentGroupId)}`
|
||||
: 'Top-level'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={isBuiltinAdmins}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className={styles.metaGrid}>
|
||||
<span className={styles.metaLabel}>Group ID</span>
|
||||
<MonoText size="xs">{selectedGroup.id}</MonoText>
|
||||
<span className={styles.metaLabel}>Parent</span>
|
||||
<span>{parentName(selectedGroup.parentGroupId)}</span>
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
<div className={styles.sectionTitle}>Members</div>
|
||||
<div className={styles.sectionTags}>
|
||||
{(selectedGroup.members ?? []).map((member) => (
|
||||
<Tag
|
||||
key={member.userId}
|
||||
label={member.displayName}
|
||||
onRemove={() => handleRemoveMember(member.userId)}
|
||||
/>
|
||||
))}
|
||||
{(selectedGroup.members ?? []).length === 0 && (
|
||||
<span className={styles.inheritedNote}>No members</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Add member...' },
|
||||
...availableUsers.map((u) => ({
|
||||
value: u.userId,
|
||||
label: u.displayName,
|
||||
})),
|
||||
]}
|
||||
value={addMemberUserId}
|
||||
onChange={(e) => setAddMemberUserId(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleAddMember}
|
||||
disabled={!addMemberUserId || addUserToGroup.isPending}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Assigned roles */}
|
||||
<div className={styles.sectionTitle}>Assigned Roles</div>
|
||||
<div className={styles.sectionTags}>
|
||||
{(selectedGroup.directRoles ?? []).map((role) => (
|
||||
<Badge
|
||||
key={role.id}
|
||||
label={role.name}
|
||||
variant="outlined"
|
||||
onRemove={() => handleRemoveRole(role.id)}
|
||||
/>
|
||||
))}
|
||||
{(selectedGroup.directRoles ?? []).length === 0 && (
|
||||
<span className={styles.inheritedNote}>No roles assigned</span>
|
||||
)}
|
||||
</div>
|
||||
{(selectedGroup.effectiveRoles ?? []).length >
|
||||
(selectedGroup.directRoles ?? []).length && (
|
||||
<div className={styles.inheritedNote}>
|
||||
+
|
||||
{(selectedGroup.effectiveRoles ?? []).length -
|
||||
(selectedGroup.directRoles ?? []).length}{' '}
|
||||
inherited role(s)
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Assign role...' },
|
||||
...availableRoles.map((r) => ({
|
||||
value: r.id,
|
||||
label: r.name,
|
||||
})),
|
||||
]}
|
||||
value={addRoleId}
|
||||
onChange={(e) => setAddRoleId(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleAddRole}
|
||||
disabled={!addRoleId || assignRoleToGroup.isPending}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Group"
|
||||
message={`Delete group "${selectedGroup?.name}"? This action cannot be undone.`}
|
||||
confirmText="DELETE"
|
||||
variant="danger"
|
||||
loading={deleteGroup.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
ui/src/pages/Admin/OidcConfigPage.module.css
Normal file
28
ui/src/pages/Admin/OidcConfigPage.module.css
Normal file
@@ -0,0 +1,28 @@
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tagRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
min-height: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.addRow {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.addRow input {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
305
ui/src/pages/Admin/RolesTab.tsx
Normal file
305
ui/src/pages/Admin/RolesTab.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Input,
|
||||
MonoText,
|
||||
Spinner,
|
||||
Tag,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import {
|
||||
useRoles,
|
||||
useRole,
|
||||
useCreateRole,
|
||||
useDeleteRole,
|
||||
} from '../../api/queries/admin/rbac';
|
||||
import type { RoleDetail } from '../../api/queries/admin/rbac';
|
||||
import styles from './UserManagement.module.css';
|
||||
|
||||
export default function RolesTab() {
|
||||
const { data: roles, isLoading } = useRoles();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDescription, setNewDescription] = useState('');
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const { data: detail, isLoading: detailLoading } = useRole(selectedId);
|
||||
const createRole = useCreateRole();
|
||||
const deleteRole = useDeleteRole();
|
||||
const { toast } = useToast();
|
||||
|
||||
const filtered = (roles ?? []).filter((r) =>
|
||||
r.name.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
createRole.mutate(
|
||||
{ name: newName.trim(), description: newDescription.trim() || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Role created', variant: 'success' });
|
||||
setShowCreate(false);
|
||||
setNewName('');
|
||||
setNewDescription('');
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to create role', variant: 'error' });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!selectedId) return;
|
||||
deleteRole.mutate(selectedId, {
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Role deleted', variant: 'success' });
|
||||
setSelectedId(null);
|
||||
setConfirmDelete(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to delete role', variant: 'error' });
|
||||
setConfirmDelete(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.splitPane}>
|
||||
{/* Left pane — list */}
|
||||
<div className={styles.listPane}>
|
||||
<div className={styles.listHeader}>
|
||||
<Input
|
||||
placeholder="Search roles…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowCreate((v) => !v)}
|
||||
>
|
||||
+ Add Role
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className={styles.createForm}>
|
||||
<Input
|
||||
placeholder="Role name (e.g. EDITOR)"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value.toUpperCase())}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Description (optional)"
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
/>
|
||||
<div className={styles.createFormActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowCreate(false);
|
||||
setNewName('');
|
||||
setNewDescription('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={createRole.isPending}
|
||||
disabled={!newName.trim()}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className={styles.entityList} role="listbox">
|
||||
{filtered.map((role) => {
|
||||
const assignmentCount =
|
||||
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0);
|
||||
return (
|
||||
<div
|
||||
key={role.id}
|
||||
className={
|
||||
styles.entityItem +
|
||||
(selectedId === role.id ? ' ' + styles.entityItemSelected : '')
|
||||
}
|
||||
role="option"
|
||||
aria-selected={selectedId === role.id}
|
||||
onClick={() => setSelectedId(role.id)}
|
||||
>
|
||||
<Avatar name={role.name} size="sm" />
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>
|
||||
{role.name}
|
||||
{role.system && <Badge label="system" variant="outlined" />}
|
||||
</div>
|
||||
<div className={styles.entityMeta}>
|
||||
{role.description || '—'} · {assignmentCount} assignment
|
||||
{assignmentCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{((role.assignedGroups?.length ?? 0) > 0 ||
|
||||
(role.directUsers?.length ?? 0) > 0) && (
|
||||
<div className={styles.entityTags}>
|
||||
{(role.assignedGroups ?? []).map((g) => (
|
||||
<Tag key={g.id} label={g.name} color="success" />
|
||||
))}
|
||||
{(role.directUsers ?? []).map((u) => (
|
||||
<Tag key={u.userId} label={u.displayName} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right pane — detail */}
|
||||
<div className={styles.detailPane}>
|
||||
{!selectedId ? (
|
||||
<div className={styles.emptyDetail}>Select a role to view details</div>
|
||||
) : detailLoading || !detail ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<RoleDetailPanel
|
||||
role={detail}
|
||||
onDeleteRequest={() => setConfirmDelete(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail && (
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete role"
|
||||
message={`Delete role "${detail.name}"? This cannot be undone.`}
|
||||
confirmText={detail.name}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
loading={deleteRole.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Detail panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface RoleDetailPanelProps {
|
||||
role: RoleDetail;
|
||||
onDeleteRequest: () => void;
|
||||
}
|
||||
|
||||
function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) {
|
||||
// Build a set of directly-assigned user IDs for distinguishing inherited principals
|
||||
const directUserIds = new Set((role.directUsers ?? []).map((u) => u.userId));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className={styles.detailHeader}>
|
||||
<Avatar name={role.name} size="md" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>{role.name}</div>
|
||||
{role.description && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{role.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={role.system}
|
||||
onClick={onDeleteRequest}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className={styles.metaGrid}>
|
||||
<span className={styles.metaLabel}>ID</span>
|
||||
<MonoText size="xs">{role.id}</MonoText>
|
||||
|
||||
<span className={styles.metaLabel}>Scope</span>
|
||||
<span>{role.scope || '—'}</span>
|
||||
|
||||
<span className={styles.metaLabel}>Type</span>
|
||||
<span>{role.system ? 'System role (read-only)' : 'Custom role'}</span>
|
||||
</div>
|
||||
|
||||
{/* Assigned to groups */}
|
||||
<div className={styles.sectionTitle}>Assigned to groups</div>
|
||||
<div className={styles.sectionTags}>
|
||||
{(role.assignedGroups ?? []).length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
||||
) : (
|
||||
(role.assignedGroups ?? []).map((g) => (
|
||||
<Tag key={g.id} label={g.name} color="success" />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assigned to users (direct) */}
|
||||
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
|
||||
<div className={styles.sectionTags}>
|
||||
{(role.directUsers ?? []).length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
||||
) : (
|
||||
(role.directUsers ?? []).map((u) => (
|
||||
<Tag key={u.userId} label={u.displayName} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Effective principals */}
|
||||
<div className={styles.sectionTitle}>Effective principals</div>
|
||||
<div className={styles.sectionTags}>
|
||||
{(role.effectivePrincipals ?? []).length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
||||
) : (
|
||||
(role.effectivePrincipals ?? []).map((u) => {
|
||||
const isDirect = directUserIds.has(u.userId);
|
||||
return isDirect ? (
|
||||
<Badge key={u.userId} label={u.displayName} variant="filled" />
|
||||
) : (
|
||||
<Badge
|
||||
key={u.userId}
|
||||
label={`↑ ${u.displayName}`}
|
||||
variant="dashed"
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && (
|
||||
<div className={styles.inheritedNote}>
|
||||
Dashed entries inherit this role through group membership
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
ui/src/pages/Admin/UserManagement.module.css
Normal file
48
ui/src/pages/Admin/UserManagement.module.css
Normal 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;
|
||||
}
|
||||
531
ui/src/pages/Admin/UsersTab.tsx
Normal file
531
ui/src/pages/Admin/UsersTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
ui/src/pages/Routes/RouteDetail.module.css
Normal file
39
ui/src/pages/Routes/RouteDetail.module.css
Normal 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); }
|
||||
301
ui/src/pages/Routes/RouteDetail.tsx
Normal file
301
ui/src/pages/Routes/RouteDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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> },
|
||||
|
||||
Reference in New Issue
Block a user