Compare commits
2 Commits
v0.0.1
...
e54d20bcb7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e54d20bcb7 | ||
|
|
81f85aa82d |
85
ui/src/auth/LoginPage.module.css
Normal file
85
ui/src/auth/LoginPage.module.css
Normal file
@@ -0,0 +1,85 @@
|
||||
.page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.loginForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-family: var(--font-body);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.error {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.socialSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dividerLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.dividerText {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ssoButton {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Navigate } from 'react-router';
|
||||
import { useAuthStore } from './auth-store';
|
||||
import { api } from '../api/client';
|
||||
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||
import styles from './LoginPage.module.css';
|
||||
|
||||
interface OidcInfo {
|
||||
clientId: string;
|
||||
@@ -50,53 +51,75 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'var(--surface-ground)' }}>
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} style={{ padding: '2rem', minWidth: 360 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>cameleer3</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem', fontSize: '0.875rem' }}>
|
||||
Sign in to access the observability dashboard
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.page}>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.loginForm}>
|
||||
<div className={styles.logo}>cameleer3</div>
|
||||
<p className={styles.subtitle}>Sign in to access the observability dashboard</p>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error}>
|
||||
<Alert variant="error">{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oidc && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleOidcLogin} disabled={oidcLoading} style={{ width: '100%', marginBottom: '1rem' }}>
|
||||
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
|
||||
</Button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', margin: '1rem 0' }}>
|
||||
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--border)' }} />
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>or</span>
|
||||
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--border)' }} />
|
||||
<div className={styles.socialSection}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={styles.ssoButton}
|
||||
onClick={handleOidcLogin}
|
||||
disabled={oidcLoading}
|
||||
type="button"
|
||||
>
|
||||
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.divider}>
|
||||
<div className={styles.dividerLine} />
|
||||
<span className={styles.dividerText}>or</span>
|
||||
<div className={styles.dividerLine} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField label="Username">
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
</FormField>
|
||||
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
||||
<FormField label="Username" htmlFor="login-username">
|
||||
<Input
|
||||
id="login-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password">
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Password" htmlFor="login-password">
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button variant="primary" disabled={loading || !username || !password} style={{ width: '100%', marginTop: '0.5rem' }}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
|
||||
{error && <div style={{ marginTop: '1rem' }}><Alert variant="error">{error}</Alert></div>}
|
||||
</form>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || !username || !password}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,72 @@
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, useCommandPalette } from '@cameleer/design-system';
|
||||
import type { SidebarApp, SearchResult } from '@cameleer/design-system';
|
||||
import { useRouteCatalog } from '../api/queries/catalog';
|
||||
import { useAgents } from '../api/queries/agents';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import type { SidebarApp } from '@cameleer/design-system';
|
||||
|
||||
function healthToColor(health: string): string {
|
||||
switch (health) {
|
||||
case 'live': return 'success';
|
||||
case 'stale': return 'warning';
|
||||
case 'dead': return 'error';
|
||||
default: return 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
function buildSearchData(
|
||||
catalog: any[] | undefined,
|
||||
agents: any[] | undefined,
|
||||
): SearchResult[] {
|
||||
if (!catalog) return [];
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
for (const app of catalog) {
|
||||
const liveAgents = (app.agents || []).filter((a: any) => a.status === 'live').length;
|
||||
results.push({
|
||||
id: app.appId,
|
||||
category: 'application',
|
||||
title: app.appId,
|
||||
badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToColor(app.health) }],
|
||||
meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`,
|
||||
path: `/apps/${app.appId}`,
|
||||
});
|
||||
|
||||
for (const route of (app.routes || [])) {
|
||||
results.push({
|
||||
id: route.routeId,
|
||||
category: 'route',
|
||||
title: route.routeId,
|
||||
badges: [{ label: app.appId }],
|
||||
meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`,
|
||||
path: `/apps/${app.appId}/${route.routeId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (agents) {
|
||||
for (const agent of agents) {
|
||||
results.push({
|
||||
id: agent.id,
|
||||
category: 'agent',
|
||||
title: agent.name,
|
||||
badges: [{ label: (agent.state || 'unknown').toUpperCase(), color: healthToColor((agent.state || '').toLowerCase()) }],
|
||||
meta: `${agent.application} · ${agent.version || ''}${agent.agentTps != null ? ` · ${agent.agentTps.toFixed(1)} msg/s` : ''}`,
|
||||
path: `/agents/${agent.application}/${agent.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function LayoutContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { data: catalog } = useRouteCatalog();
|
||||
const { username, roles, logout } = useAuthStore();
|
||||
const { data: agents } = useAgents();
|
||||
const { username, logout } = useAuthStore();
|
||||
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
||||
|
||||
const sidebarApps: SidebarApp[] = useMemo(() => {
|
||||
@@ -33,6 +90,11 @@ function LayoutContent() {
|
||||
}));
|
||||
}, [catalog]);
|
||||
|
||||
const searchData = useMemo(
|
||||
() => buildSearchData(catalog, agents as any[]),
|
||||
[catalog, agents],
|
||||
);
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
const parts = location.pathname.split('/').filter(Boolean);
|
||||
return parts.map((part, i) => ({
|
||||
@@ -47,12 +109,12 @@ function LayoutContent() {
|
||||
}, [logout, navigate]);
|
||||
|
||||
const handlePaletteSelect = useCallback((result: any) => {
|
||||
if (result.path) navigate(result.path);
|
||||
if (result.path) {
|
||||
navigate(result.path, { state: result.path ? { sidebarReveal: result.path } : undefined });
|
||||
}
|
||||
setPaletteOpen(false);
|
||||
}, [navigate, setPaletteOpen]);
|
||||
|
||||
const isAdmin = roles.includes('ADMIN');
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
@@ -70,7 +132,7 @@ function LayoutContent() {
|
||||
open={paletteOpen}
|
||||
onClose={() => setPaletteOpen(false)}
|
||||
onSelect={handlePaletteSelect}
|
||||
data={[]}
|
||||
data={searchData}
|
||||
/>
|
||||
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
||||
<Outlet />
|
||||
|
||||
@@ -20,7 +20,9 @@ export default function AdminLayout() {
|
||||
active={location.pathname}
|
||||
onChange={(path) => navigate(path)}
|
||||
/>
|
||||
<Outlet />
|
||||
<div style={{ padding: '20px 24px 40px' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
86
ui/src/pages/Admin/AuditLogPage.module.css
Normal file
86
ui/src/pages/Admin/AuditLogPage.module.css
Normal file
@@ -0,0 +1,86 @@
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.tableTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tableRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tableMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.target {
|
||||
display: inline-block;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.expandedDetail {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.detailGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detailField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -1,59 +1,148 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { DataTable, Badge, Input, Select, MonoText, CodeBlock } from '@cameleer/design-system';
|
||||
import {
|
||||
Badge, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useAuditLog } from '../../api/queries/admin/audit';
|
||||
import { useAuditLog, type AuditEvent } from '../../api/queries/admin/audit';
|
||||
import styles from './AuditLogPage.module.css';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: '', label: 'All categories' },
|
||||
{ value: 'INFRA', label: 'INFRA' },
|
||||
{ value: 'AUTH', label: 'AUTH' },
|
||||
{ value: 'USER_MGMT', label: 'USER_MGMT' },
|
||||
{ value: 'CONFIG', label: 'CONFIG' },
|
||||
{ value: 'RBAC', label: 'RBAC' },
|
||||
];
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString('en-GB', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
type AuditRow = Omit<AuditEvent, 'id'> & { id: string };
|
||||
|
||||
const COLUMNS: Column<AuditRow>[] = [
|
||||
{
|
||||
key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true,
|
||||
render: (_, row) => <MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'username', header: 'User', sortable: true,
|
||||
render: (_, row) => <span style={{ fontWeight: 500 }}>{row.username}</span>,
|
||||
},
|
||||
{
|
||||
key: 'category', header: 'Category', width: '110px', sortable: true,
|
||||
render: (_, row) => <Badge label={row.category} color="auto" />,
|
||||
},
|
||||
{ key: 'action', header: 'Action' },
|
||||
{
|
||||
key: 'target', header: 'Target',
|
||||
render: (_, row) => <span className={styles.target}>{row.target}</span>,
|
||||
},
|
||||
{
|
||||
key: 'result', header: 'Result', width: '90px', sortable: true,
|
||||
render: (_, row) => (
|
||||
<Badge label={row.result} color={row.result === 'SUCCESS' ? 'success' : 'error'} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [dateRange, setDateRange] = useState({
|
||||
start: new Date(Date.now() - 7 * 24 * 3600_000),
|
||||
end: new Date(),
|
||||
});
|
||||
const [userFilter, setUserFilter] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const { data, isLoading } = useAuditLog({ search, category: category || undefined, page, size: 25 });
|
||||
const { data } = useAuditLog({
|
||||
username: userFilter || undefined,
|
||||
category: categoryFilter || undefined,
|
||||
search: searchFilter || undefined,
|
||||
from: dateRange.start.toISOString(),
|
||||
to: dateRange.end.toISOString(),
|
||||
page,
|
||||
size: 25,
|
||||
});
|
||||
|
||||
const columns: Column<any>[] = [
|
||||
{ key: 'timestamp', header: 'Time', sortable: true, render: (v) => new Date(v as string).toLocaleString() },
|
||||
{ key: 'username', header: 'User', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
||||
{ key: 'action', header: 'Action' },
|
||||
{ key: 'category', header: 'Category', render: (v) => <Badge label={String(v)} color="auto" /> },
|
||||
{ key: 'target', header: 'Target', render: (v) => v ? <MonoText size="sm">{String(v)}</MonoText> : null },
|
||||
{ key: 'result', header: 'Result', render: (v) => <Badge label={String(v)} color={v === 'SUCCESS' ? 'success' : 'error'} /> },
|
||||
];
|
||||
|
||||
const rows = useMemo(() =>
|
||||
(data?.items || []).map((item: any) => ({ ...item, id: String(item.id) })),
|
||||
const rows: AuditRow[] = useMemo(
|
||||
() => (data?.items || []).map((item) => ({ ...item, id: String(item.id) })),
|
||||
[data],
|
||||
);
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '1rem' }}>Audit Log</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<div className={styles.filters}>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={(range) => { setDateRange(range); setPage(0); }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Filter by user..."
|
||||
value={userFilter}
|
||||
onChange={(e) => { setUserFilter(e.target.value); setPage(0); }}
|
||||
onClear={() => { setUserFilter(''); setPage(0); }}
|
||||
className={styles.filterInput}
|
||||
/>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'All Categories' },
|
||||
{ value: 'AUTH', label: 'Auth' },
|
||||
{ value: 'CONFIG', label: 'Config' },
|
||||
{ value: 'RBAC', label: 'RBAC' },
|
||||
{ value: 'INFRA', label: 'Infra' },
|
||||
]}
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
options={CATEGORIES}
|
||||
value={categoryFilter}
|
||||
onChange={(e) => { setCategoryFilter(e.target.value); setPage(0); }}
|
||||
className={styles.filterSelect}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search action or target..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => { setSearchFilter(e.target.value); setPage(0); }}
|
||||
onClear={() => { setSearchFilter(''); setPage(0); }}
|
||||
className={styles.filterInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
sortable
|
||||
pageSize={25}
|
||||
expandedContent={(row) => (
|
||||
<div style={{ padding: '0.75rem' }}>
|
||||
<CodeBlock content={JSON.stringify(row.detail, null, 2)} />
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Audit Log</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>
|
||||
{totalCount} events
|
||||
</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
data={rows}
|
||||
sortable
|
||||
flush
|
||||
pageSize={25}
|
||||
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
|
||||
expandedContent={(row) => (
|
||||
<div className={styles.expandedDetail}>
|
||||
<div className={styles.detailGrid}>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>IP Address</span>
|
||||
<MonoText size="xs">{row.ipAddress}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>User Agent</span>
|
||||
<span className={styles.detailValue}>{row.userAgent}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>Detail</span>
|
||||
<CodeBlock content={JSON.stringify(row.detail, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
MonoText,
|
||||
Tag,
|
||||
Select,
|
||||
ConfirmDialog,
|
||||
Spinner,
|
||||
MonoText,
|
||||
SectionHeader,
|
||||
Tag,
|
||||
InlineEdit,
|
||||
MultiSelect,
|
||||
ConfirmDialog,
|
||||
AlertDialog,
|
||||
SplitPane,
|
||||
EntityList,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import {
|
||||
@@ -25,26 +30,31 @@ import {
|
||||
useUsers,
|
||||
useRoles,
|
||||
} from '../../api/queries/admin/rbac';
|
||||
import type { GroupDetail } 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 [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<GroupDetail | null>(null);
|
||||
const [removeRoleTarget, setRemoveRoleTarget] = useState<string | null>(null);
|
||||
|
||||
// Create form state
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newParent, setNewParent] = useState('');
|
||||
|
||||
// Detail query
|
||||
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedId);
|
||||
|
||||
// Mutations
|
||||
const createGroup = useCreateGroup();
|
||||
const updateGroup = useUpdateGroup();
|
||||
const deleteGroup = useDeleteGroup();
|
||||
@@ -53,350 +63,385 @@ export default function GroupsTab() {
|
||||
const addUserToGroup = useAddUserToGroup();
|
||||
const removeUserFromGroup = useRemoveUserFromGroup();
|
||||
|
||||
const filteredGroups = groups.filter((g) =>
|
||||
g.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return groups;
|
||||
const q = search.toLowerCase();
|
||||
return groups.filter((g) => g.name.toLowerCase().includes(q));
|
||||
}, [groups, search]);
|
||||
|
||||
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
|
||||
|
||||
const parentOptions = [
|
||||
{ value: '', label: 'Top-level' },
|
||||
...groups.map((g) => ({ value: g.id, label: g.name })),
|
||||
...groups
|
||||
.filter((g) => g.id !== selectedId)
|
||||
.map((g) => ({ value: g.id, label: g.name })),
|
||||
];
|
||||
|
||||
const parentName = (parentGroupId: string | null) => {
|
||||
const duplicateGroupName =
|
||||
newName.trim() !== '' &&
|
||||
groups.some(
|
||||
(g) => g.name.toLowerCase() === newName.trim().toLowerCase(),
|
||||
);
|
||||
|
||||
// Derived data for the detail pane
|
||||
const children = selectedGroup?.childGroups ?? [];
|
||||
const members = selectedGroup?.members ?? [];
|
||||
const parentGroup = selectedGroup?.parentGroupId
|
||||
? groups.find((g) => g.id === selectedGroup.parentGroupId)
|
||||
: null;
|
||||
|
||||
const memberUserIds = new Set(members.map((m) => m.userId));
|
||||
const assignedRoleIds = new Set(
|
||||
(selectedGroup?.directRoles ?? []).map((r) => r.id),
|
||||
);
|
||||
|
||||
const availableRoles = roles
|
||||
.filter((r) => !assignedRoleIds.has(r.id))
|
||||
.map((r) => ({ value: r.id, label: r.name }));
|
||||
|
||||
const availableMembers = users
|
||||
.filter((u) => !memberUserIds.has(u.userId))
|
||||
.map((u) => ({ value: u.userId, label: u.displayName }));
|
||||
|
||||
function parentName(parentGroupId: string | null): string {
|
||||
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;
|
||||
async function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await createGroup.mutateAsync({
|
||||
name,
|
||||
parentGroupId: newGroupParentId || null,
|
||||
name: newName.trim(),
|
||||
parentGroupId: newParent || null,
|
||||
});
|
||||
toast({ title: 'Group created', variant: 'success' });
|
||||
setNewGroupName('');
|
||||
setNewGroupParentId('');
|
||||
setShowCreate(false);
|
||||
toast({ title: 'Group created', description: newName.trim(), variant: 'success' });
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
setNewParent('');
|
||||
} catch {
|
||||
toast({ title: 'Failed to create group', variant: 'error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleRename = async (newName: string) => {
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteGroup.mutateAsync(deleteTarget.id);
|
||||
toast({
|
||||
title: 'Group deleted',
|
||||
description: deleteTarget.name,
|
||||
variant: 'warning',
|
||||
});
|
||||
if (selectedId === deleteTarget.id) setSelectedId(null);
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
toast({ title: 'Failed to delete group', variant: 'error' });
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRename(newNameVal: string) {
|
||||
if (!selectedGroup) return;
|
||||
try {
|
||||
await updateGroup.mutateAsync({
|
||||
id: selectedGroup.id,
|
||||
name: newName,
|
||||
name: newNameVal,
|
||||
parentGroupId: selectedGroup.parentGroupId,
|
||||
});
|
||||
toast({ title: 'Group renamed', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to rename group', variant: 'error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
async function handleRemoveMember(userId: string) {
|
||||
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,
|
||||
await removeUserFromGroup.mutateAsync({
|
||||
userId,
|
||||
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' });
|
||||
async function handleAddMembers(userIds: string[]) {
|
||||
if (!selectedGroup) return;
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await addUserToGroup.mutateAsync({
|
||||
userId,
|
||||
groupId: selectedGroup.id,
|
||||
});
|
||||
toast({ title: 'Member added', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to add member', variant: 'error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleRemoveRole = async (roleId: string) => {
|
||||
async function handleAddRoles(roleIds: string[]) {
|
||||
if (!selectedGroup) return;
|
||||
for (const roleId of roleIds) {
|
||||
try {
|
||||
await assignRoleToGroup.mutateAsync({
|
||||
groupId: selectedGroup.id,
|
||||
roleId,
|
||||
});
|
||||
toast({ title: 'Role assigned', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to assign role', variant: 'error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveRole(roleId: string) {
|
||||
if (!selectedGroup) return;
|
||||
try {
|
||||
await removeRoleFromGroup.mutateAsync({ groupId: selectedGroup.id, roleId });
|
||||
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));
|
||||
if (groupsLoading) return <Spinner size="md" />;
|
||||
|
||||
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>
|
||||
<>
|
||||
<SplitPane
|
||||
list={
|
||||
<>
|
||||
{creating && (
|
||||
<div className={styles.createForm}>
|
||||
<Input
|
||||
placeholder="Group name *"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
{duplicateGroupName && (
|
||||
<span style={{ color: 'var(--error)', fontSize: 11 }}>
|
||||
Group name already exists
|
||||
</span>
|
||||
)}
|
||||
<Select
|
||||
options={parentOptions}
|
||||
value={newParent}
|
||||
onChange={(e) => setNewParent(e.target.value)}
|
||||
/>
|
||||
<div className={styles.createFormActions}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setCreating(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
loading={createGroup.isPending}
|
||||
disabled={!newName.trim() || duplicateGroupName}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</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 */}
|
||||
<EntityList
|
||||
items={filtered}
|
||||
renderItem={(group) => {
|
||||
const groupChildren = groups.filter(
|
||||
(g) => g.parentGroupId === group.id,
|
||||
);
|
||||
const groupParent = group.parentGroupId
|
||||
? groups.find((g) => g.id === group.parentGroupId)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<Avatar name={group.name} size="sm" />
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>{group.name}</div>
|
||||
<div className={styles.entityMeta}>
|
||||
{groupParent
|
||||
? `Child of ${groupParent.name}`
|
||||
: 'Top-level'}
|
||||
{' \u00b7 '}
|
||||
{groupChildren.length} children
|
||||
{' \u00b7 '}
|
||||
{(group.members ?? []).length} members
|
||||
</div>
|
||||
<div className={styles.entityTags}>
|
||||
{(group.directRoles ?? []).map((r) => (
|
||||
<Badge key={r.id} label={r.name} color="warning" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
getItemId={(group) => group.id}
|
||||
selectedId={selectedId ?? undefined}
|
||||
onSelect={setSelectedId}
|
||||
searchPlaceholder="Search groups..."
|
||||
onSearch={setSearch}
|
||||
addLabel="+ Add group"
|
||||
onAdd={() => setCreating(true)}
|
||||
emptyMessage="No groups match your search"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
detail={
|
||||
selectedId && detailLoading ? (
|
||||
<Spinner size="md" />
|
||||
) : selectedGroup ? (
|
||||
<>
|
||||
<div className={styles.detailHeader}>
|
||||
<Avatar name={selectedGroup.name} size="lg" />
|
||||
<div className={styles.detailHeaderInfo}>
|
||||
<div className={styles.detailName}>
|
||||
{isBuiltinAdmins ? (
|
||||
selectedGroup.name
|
||||
) : (
|
||||
<InlineEdit
|
||||
value={selectedGroup.name}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.detailEmail}>
|
||||
{parentGroup
|
||||
? `${parentGroup.name} > ${selectedGroup.name}`
|
||||
: 'Top-level group'}
|
||||
{isBuiltinAdmins && ' (built-in)'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => setDeleteTarget(selectedGroup)}
|
||||
disabled={isBuiltinAdmins}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.metaGrid}>
|
||||
<span className={styles.metaLabel}>ID</span>
|
||||
<MonoText size="xs">{selectedGroup.id}</MonoText>
|
||||
</div>
|
||||
|
||||
{parentGroup && (
|
||||
<>
|
||||
<SectionHeader>Member of</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
<Tag label={parentGroup.name} color="auto" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SectionHeader>Members (direct)</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{members.map((u) => (
|
||||
<Tag
|
||||
key={u.userId}
|
||||
label={u.displayName}
|
||||
color="auto"
|
||||
onRemove={() => handleRemoveMember(u.userId)}
|
||||
/>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<span className={styles.inheritedNote}>(no members)</span>
|
||||
)}
|
||||
<MultiSelect
|
||||
options={availableMembers}
|
||||
value={[]}
|
||||
onChange={handleAddMembers}
|
||||
placeholder="+ Add"
|
||||
/>
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
<span className={styles.inheritedNote}>
|
||||
+ all members of {children.map((c) => c.name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<SectionHeader>Child groups</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{children.map((c) => (
|
||||
<Tag key={c.id} label={c.name} color="success" />
|
||||
))}
|
||||
{children.length === 0 && (
|
||||
<span className={styles.inheritedNote}>
|
||||
(no child groups)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeader>Assigned roles</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{(selectedGroup.directRoles ?? []).map((r) => (
|
||||
<Tag
|
||||
key={r.id}
|
||||
label={r.name}
|
||||
color="warning"
|
||||
onRemove={() => {
|
||||
if (members.length > 0) {
|
||||
setRemoveRoleTarget(r.id);
|
||||
} else {
|
||||
handleRemoveRole(r.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{(selectedGroup.directRoles ?? []).length === 0 && (
|
||||
<span className={styles.inheritedNote}>(no roles)</span>
|
||||
)}
|
||||
<MultiSelect
|
||||
options={availableRoles}
|
||||
value={[]}
|
||||
onChange={handleAddRoles}
|
||||
placeholder="+ Add"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
emptyMessage="Select a group to view details"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
open={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Group"
|
||||
message={`Delete group "${selectedGroup?.name}"? This action cannot be undone.`}
|
||||
confirmText="DELETE"
|
||||
variant="danger"
|
||||
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
|
||||
confirmText={deleteTarget?.name ?? ''}
|
||||
loading={deleteGroup.isPending}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={removeRoleTarget !== null}
|
||||
onClose={() => setRemoveRoleTarget(null)}
|
||||
onConfirm={() => {
|
||||
if (removeRoleTarget && selectedGroup) {
|
||||
handleRemoveRole(removeRoleTarget);
|
||||
}
|
||||
setRemoveRoleTarget(null);
|
||||
}}
|
||||
title="Remove role from group"
|
||||
description={`Removing this role will affect ${members.length} member(s) who inherit it. Continue?`}
|
||||
confirmLabel="Remove"
|
||||
variant="warning"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,53 @@
|
||||
.page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tagRow {
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.tagList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
min-height: 2rem;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.addRow {
|
||||
.noRoles {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.addRoleRow {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.addRow input {
|
||||
flex: 1;
|
||||
.roleInput {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
@@ -1,110 +1,226 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader, Tag, ConfirmDialog } from '@cameleer/design-system';
|
||||
import {
|
||||
Button, Input, Toggle, FormField, SectionHeader, Tag, ConfirmDialog, Alert,
|
||||
} from '@cameleer/design-system';
|
||||
import { useToast } from '@cameleer/design-system';
|
||||
import { adminFetch } from '../../api/queries/admin/admin-api';
|
||||
import styles from './OidcConfigPage.module.css';
|
||||
|
||||
interface OidcConfig {
|
||||
interface OidcFormData {
|
||||
enabled: boolean;
|
||||
autoSignup: boolean;
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
rolesClaim: string;
|
||||
defaultRoles: string[];
|
||||
autoSignup: boolean;
|
||||
displayNameClaim: string;
|
||||
defaultRoles: string[];
|
||||
}
|
||||
|
||||
const EMPTY_CONFIG: OidcFormData = {
|
||||
enabled: false,
|
||||
autoSignup: true,
|
||||
issuerUri: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
rolesClaim: 'roles',
|
||||
displayNameClaim: 'name',
|
||||
defaultRoles: ['VIEWER'],
|
||||
};
|
||||
|
||||
export default function OidcConfigPage() {
|
||||
const [config, setConfig] = useState<OidcConfig | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [form, setForm] = useState<OidcFormData | null>(null);
|
||||
const [newRole, setNewRole] = useState('');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch<OidcConfig>('/oidc')
|
||||
.then(setConfig)
|
||||
.catch(() => setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' }));
|
||||
adminFetch<OidcFormData>('/oidc')
|
||||
.then(setForm)
|
||||
.catch(() => setForm(EMPTY_CONFIG));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
|
||||
setForm((prev) => prev ? { ...prev, [key]: value } : prev);
|
||||
}
|
||||
|
||||
function addRole() {
|
||||
if (!form) return;
|
||||
const role = newRole.trim().toUpperCase();
|
||||
if (role && !form.defaultRoles.includes(role)) {
|
||||
update('defaultRoles', [...form.defaultRoles, role]);
|
||||
setNewRole('');
|
||||
}
|
||||
}
|
||||
|
||||
function removeRole(role: string) {
|
||||
if (!form) return;
|
||||
update('defaultRoles', form.defaultRoles.filter((r) => r !== role));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(config) });
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 3000);
|
||||
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(form) });
|
||||
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' });
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
toast({ title: 'Save failed', description: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
async function handleTest() {
|
||||
if (!form) return;
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await adminFetch('/oidc', { method: 'DELETE' });
|
||||
setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' });
|
||||
const result = await adminFetch<{ status: string; authorizationEndpoint?: string }>('/oidc/test', { method: 'POST' });
|
||||
toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' });
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
toast({ title: 'Connection test failed', description: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
async function handleDelete() {
|
||||
setDeleteOpen(false);
|
||||
setError(null);
|
||||
try {
|
||||
await adminFetch('/oidc', { method: 'DELETE' });
|
||||
setForm(EMPTY_CONFIG);
|
||||
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' });
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
toast({ title: 'Delete failed', description: e.message, variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!form) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '1rem' }}>OIDC Configuration</h2>
|
||||
<Card>
|
||||
<div style={{ padding: '1.5rem', display: 'grid', gap: '1rem' }}>
|
||||
<Toggle checked={config.enabled} onChange={(e) => setConfig({ ...config, enabled: e.target.checked })} label="Enable OIDC" />
|
||||
<FormField label="Issuer URI"><Input value={config.issuerUri} onChange={(e) => setConfig({ ...config, issuerUri: e.target.value })} /></FormField>
|
||||
<FormField label="Client ID"><Input value={config.clientId} onChange={(e) => setConfig({ ...config, clientId: e.target.value })} /></FormField>
|
||||
<FormField label="Client Secret"><Input type="password" value={config.clientSecret} onChange={(e) => setConfig({ ...config, clientSecret: e.target.value })} /></FormField>
|
||||
<FormField label="Roles Claim"><Input value={config.rolesClaim} onChange={(e) => setConfig({ ...config, rolesClaim: e.target.value })} /></FormField>
|
||||
<FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField>
|
||||
<Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" />
|
||||
<div className={styles.page}>
|
||||
<div className={styles.toolbar}>
|
||||
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri || testing}>
|
||||
{testing ? 'Testing...' : 'Test Connection'}
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{error && <div style={{ marginBottom: 16 }}><Alert variant="error">{error}</Alert></div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||
<Button variant="danger" onClick={() => setDeleteOpen(true)}>Delete Configuration</Button>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
{success && <Alert variant="success">Configuration saved</Alert>}
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Behavior</SectionHeader>
|
||||
<div className={styles.toggleRow}>
|
||||
<Toggle
|
||||
label="Enabled"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => update('enabled', e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<div className={styles.toggleRow}>
|
||||
<Toggle
|
||||
label="Auto Sign-Up"
|
||||
checked={form.autoSignup}
|
||||
onChange={(e) => update('autoSignup', e.target.checked)}
|
||||
/>
|
||||
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete OIDC Configuration"
|
||||
message="Delete OIDC configuration? All OIDC users will lose access."
|
||||
confirmText="DELETE"
|
||||
/>
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Provider Settings</SectionHeader>
|
||||
<FormField label="Issuer URI" htmlFor="issuer">
|
||||
<Input
|
||||
id="issuer"
|
||||
type="url"
|
||||
placeholder="https://idp.example.com/realms/my-realm"
|
||||
value={form.issuerUri}
|
||||
onChange={(e) => update('issuerUri', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Client ID" htmlFor="client-id">
|
||||
<Input
|
||||
id="client-id"
|
||||
value={form.clientId}
|
||||
onChange={(e) => update('clientId', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Client Secret" htmlFor="client-secret">
|
||||
<Input
|
||||
id="client-secret"
|
||||
type="password"
|
||||
value={form.clientSecret}
|
||||
onChange={(e) => update('clientSecret', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Claim Mapping</SectionHeader>
|
||||
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
|
||||
<Input
|
||||
id="roles-claim"
|
||||
value={form.rolesClaim}
|
||||
onChange={(e) => update('rolesClaim', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
|
||||
<Input
|
||||
id="name-claim"
|
||||
value={form.displayNameClaim}
|
||||
onChange={(e) => update('displayNameClaim', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Default Roles</SectionHeader>
|
||||
<div className={styles.tagList}>
|
||||
{form.defaultRoles.map((role) => (
|
||||
<Tag key={role} label={role} color="primary" onRemove={() => removeRole(role)} />
|
||||
))}
|
||||
{form.defaultRoles.length === 0 && (
|
||||
<span className={styles.noRoles}>No default roles configured</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.addRoleRow}>
|
||||
<Input
|
||||
placeholder="Add role..."
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }}
|
||||
className={styles.roleInput}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Danger Zone</SectionHeader>
|
||||
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
|
||||
Delete OIDC Configuration
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
|
||||
confirmText="delete oidc"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,30 +6,29 @@ import UsersTab from './UsersTab';
|
||||
import GroupsTab from './GroupsTab';
|
||||
import RolesTab from './RolesTab';
|
||||
|
||||
const TABS = [
|
||||
{ label: 'Users', value: 'users' },
|
||||
{ label: 'Groups', value: 'groups' },
|
||||
{ label: 'Roles', value: 'roles' },
|
||||
];
|
||||
|
||||
export default function RbacPage() {
|
||||
const { data: stats } = useRbacStats();
|
||||
const [tab, setTab] = useState('users');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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' },
|
||||
{ label: 'Groups', value: 'groups' },
|
||||
{ label: 'Roles', value: 'roles' },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
{tab === 'users' && <UsersTab />}
|
||||
{tab === 'groups' && <GroupsTab />}
|
||||
{tab === 'roles' && <RolesTab />}
|
||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||
<div className={styles.tabContent}>
|
||||
{tab === 'users' && <UsersTab />}
|
||||
{tab === 'groups' && <GroupsTab />}
|
||||
{tab === 'roles' && <RolesTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Input,
|
||||
MonoText,
|
||||
Spinner,
|
||||
SectionHeader,
|
||||
Tag,
|
||||
ConfirmDialog,
|
||||
SplitPane,
|
||||
EntityList,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import {
|
||||
@@ -20,33 +23,54 @@ import type { RoleDetail } from '../../api/queries/admin/rbac';
|
||||
import styles from './UserManagement.module.css';
|
||||
|
||||
export default function RolesTab() {
|
||||
const { toast } = useToast();
|
||||
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 [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RoleDetail | null>(null);
|
||||
|
||||
// Create form state
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
// Detail query
|
||||
const { data: detail, isLoading: detailLoading } = useRole(selectedId);
|
||||
|
||||
// Mutations
|
||||
const createRole = useCreateRole();
|
||||
const deleteRole = useDeleteRole();
|
||||
const { toast } = useToast();
|
||||
|
||||
const filtered = (roles ?? []).filter((r) =>
|
||||
r.name.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
const filtered = useMemo(() => {
|
||||
const list = roles ?? [];
|
||||
if (!search) return list;
|
||||
const q = search.toLowerCase();
|
||||
return list.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.description.toLowerCase().includes(q),
|
||||
);
|
||||
}, [roles, search]);
|
||||
|
||||
const duplicateRoleName =
|
||||
newName.trim() !== '' &&
|
||||
(roles ?? []).some((r) => r.name === newName.trim().toUpperCase());
|
||||
|
||||
function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
createRole.mutate(
|
||||
{ name: newName.trim(), description: newDescription.trim() || undefined },
|
||||
{ name: newName.trim().toUpperCase(), description: newDesc.trim() || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Role created', variant: 'success' });
|
||||
setShowCreate(false);
|
||||
toast({
|
||||
title: 'Role created',
|
||||
description: newName.trim().toUpperCase(),
|
||||
variant: 'success',
|
||||
});
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
setNewDescription('');
|
||||
setNewDesc('');
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to create role', variant: 'error' });
|
||||
@@ -56,152 +80,144 @@ export default function RolesTab() {
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!selectedId) return;
|
||||
deleteRole.mutate(selectedId, {
|
||||
if (!deleteTarget) return;
|
||||
deleteRole.mutate(deleteTarget.id, {
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Role deleted', variant: 'success' });
|
||||
setSelectedId(null);
|
||||
setConfirmDelete(false);
|
||||
toast({
|
||||
title: 'Role deleted',
|
||||
description: deleteTarget.name,
|
||||
variant: 'warning',
|
||||
});
|
||||
if (selectedId === deleteTarget.id) setSelectedId(null);
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to delete role', variant: 'error' });
|
||||
setConfirmDelete(false);
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getAssignmentCount(role: RoleDetail): number {
|
||||
return (
|
||||
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) return <Spinner size="md" />;
|
||||
|
||||
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>
|
||||
<>
|
||||
<SplitPane
|
||||
list={
|
||||
<>
|
||||
{creating && (
|
||||
<div className={styles.createForm}>
|
||||
<Input
|
||||
placeholder="Role name *"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
{duplicateRoleName && (
|
||||
<span style={{ color: 'var(--error)', fontSize: 11 }}>
|
||||
Role name already exists
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
placeholder="Description"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
/>
|
||||
<div className={styles.createFormActions}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setCreating(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
loading={createRole.isPending}
|
||||
disabled={!newName.trim() || duplicateRoleName}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</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)}
|
||||
>
|
||||
<EntityList
|
||||
items={filtered}
|
||||
renderItem={(role) => (
|
||||
<>
|
||||
<Avatar name={role.name} size="sm" />
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>
|
||||
{role.name}
|
||||
{role.system && <Badge label="system" variant="outlined" />}
|
||||
{role.system && (
|
||||
<Badge
|
||||
label="system"
|
||||
color="auto"
|
||||
variant="outlined"
|
||||
className={styles.providerBadge}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.entityMeta}>
|
||||
{role.description || '—'} · {assignmentCount} assignment
|
||||
{assignmentCount !== 1 ? 's' : ''}
|
||||
{role.description || '\u2014'} \u00b7{' '}
|
||||
{getAssignmentCount(role)} assignments
|
||||
</div>
|
||||
<div className={styles.entityTags}>
|
||||
{(role.assignedGroups ?? []).map((g) => (
|
||||
<Badge key={g.id} label={g.name} color="success" />
|
||||
))}
|
||||
{(role.directUsers ?? []).map((u) => (
|
||||
<Badge
|
||||
key={u.userId}
|
||||
label={u.displayName}
|
||||
color="auto"
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
getItemId={(role) => role.id}
|
||||
selectedId={selectedId ?? undefined}
|
||||
onSelect={setSelectedId}
|
||||
searchPlaceholder="Search roles..."
|
||||
onSearch={setSearch}
|
||||
addLabel="+ Add role"
|
||||
onAdd={() => setCreating(true)}
|
||||
emptyMessage="No roles match your search"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
detail={
|
||||
selectedId && (detailLoading || !detail) ? (
|
||||
<Spinner size="md" />
|
||||
) : detail ? (
|
||||
<RoleDetailPanel
|
||||
role={detail}
|
||||
onDeleteRequest={() => setDeleteTarget(detail)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
emptyMessage="Select a role to view details"
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
|
||||
confirmText={deleteTarget?.name ?? ''}
|
||||
loading={deleteRole.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,93 +229,93 @@ interface RoleDetailPanelProps {
|
||||
}
|
||||
|
||||
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));
|
||||
const directUserIds = new Set(
|
||||
(role.directUsers ?? []).map((u) => u.userId),
|
||||
);
|
||||
|
||||
const assignedGroups = role.assignedGroups ?? [];
|
||||
const directUsers = role.directUsers ?? [];
|
||||
const effectivePrincipals = role.effectivePrincipals ?? [];
|
||||
|
||||
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>
|
||||
<Avatar name={role.name} size="lg" />
|
||||
<div className={styles.detailHeaderInfo}>
|
||||
<div className={styles.detailName}>{role.name}</div>
|
||||
{role.description && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{role.description}
|
||||
</div>
|
||||
<div className={styles.detailEmail}>{role.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={role.system}
|
||||
onClick={onDeleteRequest}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{!role.system && (
|
||||
<Button size="sm" variant="danger" 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" />
|
||||
))
|
||||
<span className={styles.metaValue}>{role.scope || '\u2014'}</span>
|
||||
{role.system && (
|
||||
<>
|
||||
<span className={styles.metaLabel}>Type</span>
|
||||
<span className={styles.metaValue}>System role (read-only)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assigned to users (direct) */}
|
||||
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
|
||||
<SectionHeader>Assigned to groups</SectionHeader>
|
||||
<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} />
|
||||
))
|
||||
{assignedGroups.map((g) => (
|
||||
<Tag key={g.id} label={g.name} color="success" />
|
||||
))}
|
||||
{assignedGroups.length === 0 && (
|
||||
<span className={styles.inheritedNote}>(none)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Effective principals */}
|
||||
<div className={styles.sectionTitle}>Effective principals</div>
|
||||
<SectionHeader>Assigned to users (direct)</SectionHeader>
|
||||
<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"
|
||||
/>
|
||||
);
|
||||
})
|
||||
{directUsers.map((u) => (
|
||||
<Tag key={u.userId} label={u.displayName} color="auto" />
|
||||
))}
|
||||
{directUsers.length === 0 && (
|
||||
<span className={styles.inheritedNote}>(none)</span>
|
||||
)}
|
||||
</div>
|
||||
{(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && (
|
||||
<div className={styles.inheritedNote}>
|
||||
|
||||
<SectionHeader>Effective principals</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{effectivePrincipals.map((u) => {
|
||||
const isDirect = directUserIds.has(u.userId);
|
||||
return isDirect ? (
|
||||
<Badge
|
||||
key={u.userId}
|
||||
label={u.displayName}
|
||||
color="auto"
|
||||
variant="filled"
|
||||
/>
|
||||
) : (
|
||||
<Badge
|
||||
key={u.userId}
|
||||
label={u.displayName}
|
||||
color="auto"
|
||||
variant="dashed"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{effectivePrincipals.length === 0 && (
|
||||
<span className={styles.inheritedNote}>(none)</span>
|
||||
)}
|
||||
</div>
|
||||
{effectivePrincipals.some((u) => !directUserIds.has(u.userId)) && (
|
||||
<span className={styles.inheritedNote}>
|
||||
Dashed entries inherit this role through group membership
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,187 +5,149 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.splitPane {
|
||||
display: grid;
|
||||
grid-template-columns: 52fr 48fr;
|
||||
gap: 1px;
|
||||
background: var(--border-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
min-height: 500px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.listPane {
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||
}
|
||||
|
||||
.detailPane {
|
||||
background: var(--bg-surface);
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.listHeader input { flex: 1; }
|
||||
|
||||
.entityList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entityItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.entityItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.entityItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.entityItemSelected {
|
||||
background: var(--bg-raised);
|
||||
.tabContent {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.entityMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.entityTags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.createForm {
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.createFormActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.detailHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.detailHeaderInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detailName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.detailEmail {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.metaGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 6px 12px;
|
||||
font-size: 13px;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
.metaValue {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.sectionTags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.createForm {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.createFormRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.createFormActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.inheritedNote {
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-family: var(--font-body);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.providerBadge {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.inherited {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.securitySection {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 16px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.securityRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.passwordDots {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.resetForm {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.emptyDetail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.emptySearch {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.providerBadge {
|
||||
font-size: 9px;
|
||||
.resetInput {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,13 @@
|
||||
/* Scrollable content area */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
min-width: 0;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
/* Stat strip */
|
||||
.statStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
@@ -5,13 +15,66 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Stat breakdown with colored dots */
|
||||
.breakdown {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.bpLive { color: var(--success); display: inline-flex; align-items: center; gap: 3px; }
|
||||
.bpStale { color: var(--warning); display: inline-flex; align-items: center; gap: 3px; }
|
||||
.bpDead { color: var(--error); display: inline-flex; align-items: center; gap: 3px; }
|
||||
|
||||
.routesSuccess { color: var(--success); }
|
||||
.routesWarning { color: var(--warning); }
|
||||
.routesError { color: var(--error); }
|
||||
|
||||
/* Scope breadcrumb trail */
|
||||
.scopeTrail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scopeLink {
|
||||
color: var(--amber);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scopeLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.scopeSep {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.scopeCurrent {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Section header */
|
||||
.sectionTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sectionMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Group cards grid */
|
||||
.groupGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -19,115 +82,131 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* GroupCard meta strip */
|
||||
.groupGridSingle {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Group meta row */
|
||||
.groupMeta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
gap: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.groupMeta strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Instance table */
|
||||
.instanceTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.instanceTable thead tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.instanceTable thead th {
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thStatus {
|
||||
width: 24px;
|
||||
/* Alert banner in group footer */
|
||||
.alertBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--error-bg);
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tdStatus {
|
||||
width: 24px;
|
||||
padding: 0 4px 0 8px;
|
||||
}
|
||||
|
||||
.instanceRow {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.instanceRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.instanceRow:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.instanceRow td {
|
||||
padding: 7px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.instanceRowActive {
|
||||
background: var(--bg-selected, var(--bg-hover));
|
||||
.alertIcon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Instance fields */
|
||||
.instanceName {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.instanceMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.instanceError {
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.instanceHeartbeatDead {
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.instanceHeartbeatStale {
|
||||
font-size: 11px;
|
||||
color: var(--warning);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.instanceLink {
|
||||
.instanceHeartbeatDead {
|
||||
color: var(--error);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Detail panel content */
|
||||
.detailContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detailRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.instanceLink:hover {
|
||||
color: var(--text-primary);
|
||||
.detailProgress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.chartPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Event card (timeline panel) */
|
||||
.eventCard {
|
||||
margin-top: 20px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -144,136 +223,4 @@
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 13px;
|
||||
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);
|
||||
}
|
||||
|
||||
/* Status breakdown in stat card */
|
||||
.statusBreakdown {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.statusLive { color: var(--success); }
|
||||
.statusStale { color: var(--warning); }
|
||||
.statusDead { color: var(--error); }
|
||||
|
||||
/* Scope trail */
|
||||
.scopeLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* DetailPanel override */
|
||||
.detailPanelOverride {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.panelDivider {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import {
|
||||
StatCard, StatusDot, Badge, MonoText,
|
||||
GroupCard, EventFeed, Alert,
|
||||
DetailPanel, ProgressBar, LineChart,
|
||||
StatCard, StatusDot, Badge, MonoText, ProgressBar,
|
||||
GroupCard, DataTable, LineChart, EventFeed, DetailPanel,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column, FeedEvent } 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';
|
||||
import type { AgentInstance } from '../../api/types';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(iso?: string): string {
|
||||
if (!iso) return '\u2014';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const secs = Math.floor(diff / 1000);
|
||||
if (secs < 60) return `${secs}s ago`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
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 formatUptime(seconds?: number): string {
|
||||
if (!seconds) return '—';
|
||||
if (!seconds) return '\u2014';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
@@ -20,18 +34,65 @@ function formatUptime(seconds?: number): string {
|
||||
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 formatErrorRate(rate?: number): string {
|
||||
if (rate == null) return '\u2014';
|
||||
return `${(rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function AgentOverviewContent({ agent }: { agent: any }) {
|
||||
type NormStatus = 'live' | 'stale' | 'dead';
|
||||
|
||||
function normalizeStatus(status: string): NormStatus {
|
||||
return status.toLowerCase() as NormStatus;
|
||||
}
|
||||
|
||||
function statusColor(s: NormStatus): 'success' | 'warning' | 'error' {
|
||||
if (s === 'live') return 'success';
|
||||
if (s === 'stale') return 'warning';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// ── Data grouping ────────────────────────────────────────────────────────────
|
||||
|
||||
interface AppGroup {
|
||||
appId: string;
|
||||
instances: AgentInstance[];
|
||||
liveCount: number;
|
||||
staleCount: number;
|
||||
deadCount: number;
|
||||
totalTps: number;
|
||||
totalActiveRoutes: number;
|
||||
totalRoutes: number;
|
||||
}
|
||||
|
||||
function groupByApp(agentList: AgentInstance[]): AppGroup[] {
|
||||
const map = new Map<string, AgentInstance[]>();
|
||||
for (const a of agentList) {
|
||||
const app = a.application;
|
||||
const list = map.get(app) ?? [];
|
||||
list.push(a);
|
||||
map.set(app, list);
|
||||
}
|
||||
return Array.from(map.entries()).map(([appId, instances]) => ({
|
||||
appId,
|
||||
instances,
|
||||
liveCount: instances.filter((i) => normalizeStatus(i.status) === 'live').length,
|
||||
staleCount: instances.filter((i) => normalizeStatus(i.status) === 'stale').length,
|
||||
deadCount: instances.filter((i) => normalizeStatus(i.status) === 'dead').length,
|
||||
totalTps: instances.reduce((s, i) => s + (i.tps ?? 0), 0),
|
||||
totalActiveRoutes: instances.reduce((s, i) => s + (i.activeRoutes ?? 0), 0),
|
||||
totalRoutes: instances.reduce((s, i) => s + (i.totalRoutes ?? 0), 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function appHealth(group: AppGroup): 'success' | 'warning' | 'error' {
|
||||
if (group.deadCount > 0) return 'error';
|
||||
if (group.staleCount > 0) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
// ── Detail sub-components ────────────────────────────────────────────────────
|
||||
|
||||
function AgentOverviewContent({ agent }: { agent: AgentInstance }) {
|
||||
const { data: memMetrics } = useAgentMetrics(
|
||||
agent.id,
|
||||
['jvm.memory.heap.used', 'jvm.memory.heap.max'],
|
||||
@@ -43,93 +104,81 @@ function AgentOverviewContent({ agent }: { agent: any }) {
|
||||
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 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';
|
||||
const ns = normalizeStatus(agent.status);
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<div className={styles.overviewRow}>
|
||||
<StatusDot variant={statusVariant} />
|
||||
<Badge label={agent.status} color={statusColor} />
|
||||
<div className={styles.detailContent}>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Status</span>
|
||||
<Badge label={agent.status} color={statusColor(ns)} variant="filled" />
|
||||
</div>
|
||||
|
||||
<dl className={styles.detailList}>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>Application</dt>
|
||||
<dd><MonoText>{agent.application ?? '—'}</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 className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Application</span>
|
||||
<MonoText size="xs">{agent.application}</MonoText>
|
||||
</div>
|
||||
|
||||
<div className={styles.metricsSection}>
|
||||
<div className={styles.metricLabel}>
|
||||
CPU Usage{cpuPercent != null ? ` — ${cpuPercent}%` : ''}
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Uptime</span>
|
||||
<MonoText size="xs">{formatUptime(agent.uptimeSeconds)}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Last Seen</span>
|
||||
<MonoText size="xs">{timeAgo(agent.lastHeartbeat)}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Throughput</span>
|
||||
<MonoText size="xs">{agent.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Errors</span>
|
||||
<MonoText size="xs" className={agent.errorRate ? styles.instanceError : undefined}>
|
||||
{formatErrorRate(agent.errorRate)}
|
||||
</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Routes</span>
|
||||
<span>{agent.activeRoutes ?? 0}/{agent.totalRoutes ?? 0} active</span>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Heap Memory</span>
|
||||
<div className={styles.detailProgress}>
|
||||
<ProgressBar
|
||||
value={heapPercent}
|
||||
variant={heapPercent == null ? 'primary' : heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
|
||||
indeterminate={heapPercent == null}
|
||||
size="sm"
|
||||
/>
|
||||
<MonoText size="xs">{heapPercent != null ? `${heapPercent}%` : '\u2014'}</MonoText>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>CPU</span>
|
||||
<div className={styles.detailProgress}>
|
||||
<ProgressBar
|
||||
value={cpuPercent}
|
||||
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
|
||||
indeterminate={cpuPercent == null}
|
||||
size="sm"
|
||||
/>
|
||||
<MonoText size="xs">{cpuPercent != null ? `${cpuPercent}%` : '\u2014'}</MonoText>
|
||||
</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 }) {
|
||||
function AgentPerformanceContent({ agent }: { agent: AgentInstance }) {
|
||||
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 })),
|
||||
}];
|
||||
return [{ label: 'TPS', data: raw.map((p) => ({ x: new Date(p.time), y: p.value })) }];
|
||||
}, [tpsMetrics]);
|
||||
|
||||
const errSeries = useMemo(() => {
|
||||
@@ -137,24 +186,24 @@ function AgentPerformanceContent({ agent }: { agent: any }) {
|
||||
return [{
|
||||
label: 'Error Rate',
|
||||
data: raw.map((p) => ({ x: new Date(p.time), y: p.value * 100 })),
|
||||
color: 'var(--error)',
|
||||
}];
|
||||
}, [errMetrics]);
|
||||
|
||||
return (
|
||||
<div className={styles.performanceContent}>
|
||||
<div className={styles.chartSection}>
|
||||
<div className={styles.chartLabel}>Throughput (TPS)</div>
|
||||
<div className={styles.detailContent}>
|
||||
<div className={styles.chartPanel}>
|
||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||
{tpsSeries[0].data.length > 0 ? (
|
||||
<LineChart series={tpsSeries} yLabel="req/s" height={160} />
|
||||
<LineChart series={tpsSeries} height={160} yLabel="msg/s" />
|
||||
) : (
|
||||
<div className={styles.emptyChart}>No data available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.chartSection}>
|
||||
<div className={styles.chartLabel}>Error Rate (%)</div>
|
||||
<div className={styles.chartPanel}>
|
||||
<div className={styles.chartTitle}>Error Rate (%)</div>
|
||||
{errSeries[0].data.length > 0 ? (
|
||||
<LineChart series={errSeries} yLabel="%" height={160} />
|
||||
<LineChart series={errSeries} height={160} yLabel="%" />
|
||||
) : (
|
||||
<div className={styles.emptyChart}>No data available</div>
|
||||
)}
|
||||
@@ -163,197 +212,308 @@ function AgentPerformanceContent({ agent }: { agent: any }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── AgentHealth page ─────────────────────────────────────────────────────────
|
||||
|
||||
export default function AgentHealth() {
|
||||
const { appId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: agents } = useAgents(undefined, appId);
|
||||
const { data: catalog } = useRouteCatalog();
|
||||
const { data: events } = useAgentEvents(appId);
|
||||
|
||||
const [selectedAgent, setSelectedAgent] = useState<any>(null);
|
||||
const [selectedInstance, setSelectedInstance] = useState<AgentInstance | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
const agentsByApp = useMemo(() => {
|
||||
const map: Record<string, any[]> = {};
|
||||
(agents || []).forEach((a: any) => {
|
||||
const g = a.application;
|
||||
if (!map[g]) map[g] = [];
|
||||
map[g].push(a);
|
||||
});
|
||||
return map;
|
||||
}, [agents]);
|
||||
const agentList = agents ?? [];
|
||||
|
||||
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.application)).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 groups = useMemo(() => groupByApp(agentList), [agentList]);
|
||||
|
||||
const feedEvents = useMemo(() =>
|
||||
(events || []).map((e: any) => ({
|
||||
id: String(e.id),
|
||||
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
|
||||
: e.eventType === 'WENT_STALE' ? 'warning' as const
|
||||
: e.eventType === 'RECOVERED' ? 'success' as const
|
||||
: 'running' as const,
|
||||
message: `${e.agentId}: ${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
|
||||
timestamp: new Date(e.timestamp),
|
||||
})),
|
||||
// Aggregate stats
|
||||
const totalInstances = agentList.length;
|
||||
const liveCount = agentList.filter((a) => normalizeStatus(a.status) === 'live').length;
|
||||
const staleCount = agentList.filter((a) => normalizeStatus(a.status) === 'stale').length;
|
||||
const deadCount = agentList.filter((a) => normalizeStatus(a.status) === 'dead').length;
|
||||
const totalTps = agentList.reduce((s, a) => s + (a.tps ?? 0), 0);
|
||||
const totalActiveRoutes = agentList.reduce((s, a) => s + (a.activeRoutes ?? 0), 0);
|
||||
const totalRoutes = agentList.reduce((s, a) => s + (a.totalRoutes ?? 0), 0);
|
||||
|
||||
// Map events to FeedEvent
|
||||
const feedEvents: FeedEvent[] = useMemo(
|
||||
() =>
|
||||
(events ?? []).map((e: { id: number; agentId: string; eventType: string; detail: string; timestamp: string }) => ({
|
||||
id: String(e.id),
|
||||
severity:
|
||||
e.eventType === 'WENT_DEAD'
|
||||
? ('error' as const)
|
||||
: e.eventType === 'WENT_STALE'
|
||||
? ('warning' as const)
|
||||
: e.eventType === 'RECOVERED'
|
||||
? ('success' as const)
|
||||
: ('running' as const),
|
||||
message: `${e.agentId}: ${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
|
||||
timestamp: new Date(e.timestamp),
|
||||
})),
|
||||
[events],
|
||||
);
|
||||
|
||||
const apps = appId ? { [appId]: agentsByApp[appId] || [] } : agentsByApp;
|
||||
// Column definitions for the instance DataTable
|
||||
const instanceColumns: Column<AgentInstance>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'status',
|
||||
header: '',
|
||||
width: '12px',
|
||||
render: (_val, row) => <StatusDot variant={normalizeStatus(row.status)} />,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Instance',
|
||||
render: (_val, row) => (
|
||||
<MonoText size="sm" className={styles.instanceName}>{row.name ?? row.id}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
header: 'State',
|
||||
render: (_val, row) => {
|
||||
const ns = normalizeStatus(row.status);
|
||||
return <Badge label={row.status} color={statusColor(ns)} variant="filled" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'uptime',
|
||||
header: 'Uptime',
|
||||
render: (_val, row) => (
|
||||
<MonoText size="xs" className={styles.instanceMeta}>{formatUptime(row.uptimeSeconds)}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tps',
|
||||
header: 'TPS',
|
||||
render: (_val, row) => (
|
||||
<MonoText size="xs" className={styles.instanceMeta}>
|
||||
{row.tps != null ? `${row.tps.toFixed(1)}/s` : '\u2014'}
|
||||
</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'errorRate',
|
||||
header: 'Errors',
|
||||
render: (_val, row) => (
|
||||
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
|
||||
{formatErrorRate(row.errorRate)}
|
||||
</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastHeartbeat',
|
||||
header: 'Heartbeat',
|
||||
render: (_val, row) => {
|
||||
const ns = normalizeStatus(row.status);
|
||||
return (
|
||||
<MonoText
|
||||
size="xs"
|
||||
className={
|
||||
ns === 'dead'
|
||||
? styles.instanceHeartbeatDead
|
||||
: ns === 'stale'
|
||||
? styles.instanceHeartbeatStale
|
||||
: styles.instanceMeta
|
||||
}
|
||||
>
|
||||
{timeAgo(row.lastHeartbeat)}
|
||||
</MonoText>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
function handleInstanceClick(inst: AgentInstance) {
|
||||
setSelectedInstance(inst);
|
||||
setPanelOpen(true);
|
||||
}
|
||||
|
||||
// Detail panel tabs
|
||||
const detailTabs = selectedInstance
|
||||
? [
|
||||
{
|
||||
label: 'Overview',
|
||||
value: 'overview',
|
||||
content: <AgentOverviewContent agent={selectedInstance} />,
|
||||
},
|
||||
{
|
||||
label: 'Performance',
|
||||
value: 'performance',
|
||||
content: <AgentPerformanceContent agent={selectedInstance} />,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const isFullWidth = !!appId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.content}>
|
||||
{/* Stat strip */}
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard
|
||||
label="Total Agents"
|
||||
value={(agents || []).length}
|
||||
value={String(totalInstances)}
|
||||
accent={deadCount > 0 ? 'warning' : 'amber'}
|
||||
detail={
|
||||
<span className={styles.statusBreakdown}>
|
||||
<span className={styles.statusLive}>{liveCount} live</span>
|
||||
<span className={styles.statusStale}>{staleCount} stale</span>
|
||||
<span className={styles.statusDead}>{deadCount} dead</span>
|
||||
<span className={styles.breakdown}>
|
||||
<span className={styles.bpLive}><StatusDot variant="live" /> {liveCount} live</span>
|
||||
<span className={styles.bpStale}><StatusDot variant="stale" /> {staleCount} stale</span>
|
||||
<span className={styles.bpDead}><StatusDot variant="dead" /> {deadCount} dead</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatCard label="Applications" value={uniqueApps} />
|
||||
<StatCard label="Active Routes" value={activeRoutes} />
|
||||
<StatCard label="Total TPS" value={totalTps.toFixed(1)} detail="msg/s" />
|
||||
<StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} detail={deadCount > 0 ? 'requires attention' : undefined} />
|
||||
</div>
|
||||
|
||||
<div className={styles.scopeTrail}>
|
||||
<span className={styles.scopeLabel}>{liveCount}/{(agents || []).length} live</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.groupGrid}>
|
||||
{Object.entries(apps).map(([group, groupAgents]) => {
|
||||
const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD');
|
||||
const groupTps = (groupAgents || []).reduce((s: number, a: any) => s + (a.tps || 0), 0);
|
||||
const groupActiveRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.activeRoutes || 0), 0);
|
||||
const groupTotalRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.totalRoutes || 0), 0);
|
||||
const liveInGroup = (groupAgents || []).filter((a: any) => a.status === 'LIVE').length;
|
||||
return (
|
||||
<GroupCard
|
||||
key={group}
|
||||
title={group}
|
||||
headerRight={
|
||||
<Badge
|
||||
label={`${liveInGroup}/${groupAgents?.length ?? 0} LIVE`}
|
||||
color={
|
||||
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
|
||||
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
|
||||
: 'success'
|
||||
}
|
||||
variant="filled"
|
||||
/>
|
||||
}
|
||||
meta={
|
||||
<div className={styles.groupMeta}>
|
||||
<span><strong>{groupTps.toFixed(1)}</strong> msg/s</span>
|
||||
<span><strong>{groupActiveRoutes}</strong>/{groupTotalRoutes} routes</span>
|
||||
</div>
|
||||
}
|
||||
accent={
|
||||
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
|
||||
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
|
||||
: 'success'
|
||||
<StatCard
|
||||
label="Applications"
|
||||
value={String(groups.length)}
|
||||
accent="running"
|
||||
detail={
|
||||
<span className={styles.breakdown}>
|
||||
<span className={styles.bpLive}>
|
||||
<StatusDot variant="live" /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy
|
||||
</span>
|
||||
<span className={styles.bpStale}>
|
||||
<StatusDot variant="stale" /> {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded
|
||||
</span>
|
||||
<span className={styles.bpDead}>
|
||||
<StatusDot variant="dead" /> {groups.filter((g) => g.deadCount > 0).length} critical
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Routes"
|
||||
value={
|
||||
<span
|
||||
className={
|
||||
styles[
|
||||
totalActiveRoutes === 0
|
||||
? 'routesError'
|
||||
: totalActiveRoutes < totalRoutes
|
||||
? 'routesWarning'
|
||||
: 'routesSuccess'
|
||||
]
|
||||
}
|
||||
>
|
||||
{deadInGroup.length > 0 && (
|
||||
<Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
|
||||
)}
|
||||
<table className={styles.instanceTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.thStatus} />
|
||||
<th>Instance</th>
|
||||
<th>State</th>
|
||||
<th>Uptime</th>
|
||||
<th>TPS</th>
|
||||
<th>Errors</th>
|
||||
<th>Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(groupAgents || []).map((agent: any) => (
|
||||
<tr
|
||||
key={agent.id}
|
||||
className={[
|
||||
styles.instanceRow,
|
||||
selectedAgent?.id === agent.id ? styles.instanceRowActive : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setSelectedAgent(agent);
|
||||
navigate(`/agents/${group}/${agent.id}`);
|
||||
}}
|
||||
>
|
||||
<td className={styles.tdStatus}>
|
||||
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
|
||||
</td>
|
||||
<td>
|
||||
<MonoText size="sm" className={styles.instanceName}>{agent.name ?? agent.id}</MonoText>
|
||||
</td>
|
||||
<td>
|
||||
<Badge
|
||||
label={agent.status}
|
||||
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
|
||||
variant="filled"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.instanceMeta}>{formatUptime(agent.uptimeSeconds)}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.instanceMeta}>{agent.tps != null ? `${(agent.tps as number).toFixed(1)}/s` : '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={agent.errorRate != null ? styles.instanceError : styles.instanceMeta}>
|
||||
{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={
|
||||
agent.status === 'DEAD' ? styles.instanceHeartbeatDead
|
||||
: agent.status === 'STALE' ? styles.instanceHeartbeatStale
|
||||
: styles.instanceMeta
|
||||
}>
|
||||
{formatRelativeTime(agent.lastHeartbeat)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</GroupCard>
|
||||
);
|
||||
})}
|
||||
{totalActiveRoutes}/{totalRoutes}
|
||||
</span>
|
||||
}
|
||||
accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'}
|
||||
detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total TPS"
|
||||
value={totalTps.toFixed(1)}
|
||||
accent="amber"
|
||||
detail="msg/s"
|
||||
/>
|
||||
<StatCard
|
||||
label="Dead"
|
||||
value={String(deadCount)}
|
||||
accent={deadCount > 0 ? 'error' : 'success'}
|
||||
detail={deadCount > 0 ? 'requires attention' : 'all healthy'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope trail + badges */}
|
||||
<div className={styles.scopeTrail}>
|
||||
{appId && (
|
||||
<>
|
||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||
<span className={styles.scopeSep}>▸</span>
|
||||
<span className={styles.scopeCurrent}>{appId}</span>
|
||||
</>
|
||||
)}
|
||||
<Badge
|
||||
label={`${liveCount}/${totalInstances} live`}
|
||||
color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'}
|
||||
variant="filled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group cards grid */}
|
||||
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
|
||||
{groups.map((group) => (
|
||||
<GroupCard
|
||||
key={group.appId}
|
||||
title={group.appId}
|
||||
accent={appHealth(group)}
|
||||
headerRight={
|
||||
<Badge
|
||||
label={`${group.liveCount}/${group.instances.length} LIVE`}
|
||||
color={appHealth(group)}
|
||||
variant="filled"
|
||||
/>
|
||||
}
|
||||
meta={
|
||||
<div className={styles.groupMeta}>
|
||||
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
|
||||
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
|
||||
<span>
|
||||
<StatusDot
|
||||
variant={
|
||||
appHealth(group) === 'success'
|
||||
? 'live'
|
||||
: appHealth(group) === 'warning'
|
||||
? 'stale'
|
||||
: 'dead'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
group.deadCount > 0 ? (
|
||||
<div className={styles.alertBanner}>
|
||||
<span className={styles.alertIcon}>⚠</span>
|
||||
<span>
|
||||
Single point of failure —{' '}
|
||||
{group.deadCount === group.instances.length
|
||||
? 'no redundancy'
|
||||
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<DataTable<AgentInstance>
|
||||
columns={instanceColumns}
|
||||
data={group.instances}
|
||||
onRowClick={handleInstanceClick}
|
||||
selectedId={panelOpen ? selectedInstance?.id : undefined}
|
||||
pageSize={50}
|
||||
flush
|
||||
/>
|
||||
</GroupCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* EventFeed */}
|
||||
{feedEvents.length > 0 && (
|
||||
<div className={styles.eventCard}>
|
||||
<div className={styles.eventCardHeader}>
|
||||
<span>Timeline</span>
|
||||
<Badge label={`${feedEvents.length} events`} variant="outlined" />
|
||||
<span className={styles.sectionTitle}>Timeline</span>
|
||||
<span className={styles.sectionMeta}>{feedEvents.length} events</span>
|
||||
</div>
|
||||
<EventFeed events={feedEvents} maxItems={100} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedAgent && (
|
||||
{/* Detail panel */}
|
||||
{selectedInstance && (
|
||||
<DetailPanel
|
||||
key={selectedAgent.id}
|
||||
open={true}
|
||||
title={selectedAgent.name ?? selectedAgent.id}
|
||||
onClose={() => setSelectedAgent(null)}
|
||||
className={styles.detailPanelOverride}
|
||||
>
|
||||
<AgentOverviewContent agent={selectedAgent} />
|
||||
<div className={styles.panelDivider} />
|
||||
<AgentPerformanceContent agent={selectedAgent} />
|
||||
</DetailPanel>
|
||||
open={panelOpen}
|
||||
onClose={() => {
|
||||
setPanelOpen(false);
|
||||
setSelectedInstance(null);
|
||||
}}
|
||||
title={selectedInstance.name ?? selectedInstance.id}
|
||||
tabs={detailTabs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
min-width: 0;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
/* Stat strip — 5 columns matching /agents */
|
||||
.statStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
@@ -5,18 +14,67 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.agentHeader {
|
||||
/* Scope trail — matches /agents */
|
||||
.scopeTrail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.agentHeader h2 {
|
||||
font-size: 18px;
|
||||
.scopeLink {
|
||||
color: var(--amber);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scopeLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.scopeSep {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.scopeCurrent {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Process info card */
|
||||
.processCard {
|
||||
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;
|
||||
}
|
||||
|
||||
.processGrid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto 1fr;
|
||||
gap: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.processLabel {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.capTags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Route badges */
|
||||
.routeBadges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -24,9 +82,10 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Charts 3x2 grid */
|
||||
.chartsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -53,14 +112,46 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
.chartMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.eventCard {
|
||||
/* Log + Timeline side by side */
|
||||
.bottomRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Log viewer */
|
||||
.logCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Empty state (shared) */
|
||||
.logEmpty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Timeline card */
|
||||
.timelineCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -69,107 +160,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 420px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.eventCardHeader {
|
||||
.timelineHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
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;
|
||||
}
|
||||
|
||||
.scopeTrail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scopeLink {
|
||||
color: var(--text-accent, var(--text-primary));
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scopeLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.scopeSep {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.scopeCurrent {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.paneTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chartMeta {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.bottomSection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.eventCount {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.emptyEvents {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import {
|
||||
StatCard, StatusDot, Badge, Card,
|
||||
LineChart, AreaChart, BarChart, EventFeed, Breadcrumb, Spinner, EmptyState,
|
||||
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
|
||||
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
|
||||
LogViewer, Tabs, useGlobalFilters,
|
||||
} from '@cameleer/design-system';
|
||||
import type { FeedEvent, LogEntry } 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';
|
||||
|
||||
const LOG_TABS = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Warnings', value: 'warn' },
|
||||
{ label: 'Errors', value: 'error' },
|
||||
];
|
||||
|
||||
export default function AgentInstance() {
|
||||
const { appId, instanceId } = useParams();
|
||||
const { timeRange } = useGlobalFilters();
|
||||
const [logFilter, setLogFilter] = useState('all');
|
||||
const timeFrom = timeRange.start.toISOString();
|
||||
const timeTo = timeRange.end.toISOString();
|
||||
|
||||
@@ -20,8 +28,8 @@ export default function AgentInstance() {
|
||||
const { data: events } = useAgentEvents(appId, instanceId);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
||||
|
||||
const agent = useMemo(() =>
|
||||
(agents || []).find((a: any) => a.id === instanceId) as any,
|
||||
const agent = useMemo(
|
||||
() => (agents || []).find((a: any) => a.id === instanceId) as any,
|
||||
[agents, instanceId],
|
||||
);
|
||||
|
||||
@@ -43,26 +51,34 @@ export default function AgentInstance() {
|
||||
60,
|
||||
);
|
||||
|
||||
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,
|
||||
})),
|
||||
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,
|
||||
})),
|
||||
[timeseries],
|
||||
);
|
||||
|
||||
const feedEvents = useMemo(() =>
|
||||
(events || []).filter((e: any) => !instanceId || e.agentId === instanceId).map((e: any) => ({
|
||||
id: String(e.id),
|
||||
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
|
||||
: e.eventType === 'WENT_STALE' ? 'warning' as const
|
||||
: e.eventType === 'RECOVERED' ? 'success' as const
|
||||
: 'running' as const,
|
||||
message: `${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
|
||||
timestamp: new Date(e.timestamp),
|
||||
})),
|
||||
const feedEvents = useMemo<FeedEvent[]>(
|
||||
() =>
|
||||
(events || [])
|
||||
.filter((e: any) => !instanceId || e.agentId === instanceId)
|
||||
.map((e: any) => ({
|
||||
id: String(e.id),
|
||||
severity:
|
||||
e.eventType === 'WENT_DEAD'
|
||||
? ('error' as const)
|
||||
: e.eventType === 'WENT_STALE'
|
||||
? ('warning' as const)
|
||||
: e.eventType === 'RECOVERED'
|
||||
? ('success' as const)
|
||||
: ('running' as const),
|
||||
message: `${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
|
||||
timestamp: new Date(e.timestamp),
|
||||
})),
|
||||
[events, instanceId],
|
||||
);
|
||||
|
||||
@@ -88,194 +104,305 @@ export default function AgentInstance() {
|
||||
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 })) }];
|
||||
return [{ label: 'GC ms', data: pts.map((p: any) => ({ x: String(p.time ?? ''), y: p.value })) }];
|
||||
}, [jvmMetrics]);
|
||||
|
||||
const throughputSeries = useMemo(() =>
|
||||
chartData.length ? [{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }] : null,
|
||||
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,
|
||||
const errorSeries = useMemo(
|
||||
() =>
|
||||
chartData.length
|
||||
? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }]
|
||||
: null,
|
||||
[chartData],
|
||||
);
|
||||
|
||||
// Placeholder log entries (backend does not stream logs yet)
|
||||
const logEntries = useMemo<LogEntry[]>(() => [], []);
|
||||
const filteredLogs =
|
||||
logFilter === 'all' ? logEntries : logEntries.filter((l) => l.level === logFilter);
|
||||
|
||||
if (isLoading) return <Spinner size="lg" />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Agents', href: '/agents' },
|
||||
{ label: appId || '', href: `/agents/${appId}` },
|
||||
{ label: agent?.name || instanceId || '' },
|
||||
]} />
|
||||
const statusVariant =
|
||||
agent?.status === 'LIVE' ? 'live' : agent?.status === 'STALE' ? 'stale' : 'dead';
|
||||
const statusColor: 'success' | 'warning' | 'error' =
|
||||
agent?.status === 'LIVE' ? 'success' : agent?.status === 'STALE' ? 'warning' : 'error';
|
||||
const cpuDisplay = cpuPct != null ? (cpuPct * 100).toFixed(0) : null;
|
||||
const heapUsedMB = heapUsed != null ? (heapUsed / (1024 * 1024)).toFixed(0) : null;
|
||||
const heapMaxMB = heapMax != null ? (heapMax / (1024 * 1024)).toFixed(0) : null;
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
{/* Stat strip — 5 columns */}
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard
|
||||
label="CPU"
|
||||
value={cpuDisplay != null ? `${cpuDisplay}%` : '\u2014'}
|
||||
accent={
|
||||
cpuDisplay != null
|
||||
? Number(cpuDisplay) > 85
|
||||
? 'error'
|
||||
: Number(cpuDisplay) > 70
|
||||
? 'warning'
|
||||
: 'success'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Memory"
|
||||
value={memPct != null ? `${memPct.toFixed(0)}%` : '\u2014'}
|
||||
accent={
|
||||
memPct != null
|
||||
? memPct > 85
|
||||
? 'error'
|
||||
: memPct > 70
|
||||
? 'warning'
|
||||
: 'success'
|
||||
: undefined
|
||||
}
|
||||
detail={
|
||||
heapUsedMB != null && heapMaxMB != null
|
||||
? `${heapUsedMB} MB / ${heapMaxMB} MB`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Throughput"
|
||||
value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'}
|
||||
accent="amber"
|
||||
detail="msg/s"
|
||||
/>
|
||||
<StatCard
|
||||
label="Errors"
|
||||
value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '\u2014'}
|
||||
accent={agent?.errorRate > 0 ? 'error' : 'success'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Uptime"
|
||||
value={formatUptime(agent?.uptimeSeconds)}
|
||||
accent="running"
|
||||
detail={
|
||||
agent?.registeredAt
|
||||
? `since ${new Date(agent.registeredAt).toLocaleDateString()}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope trail + badges */}
|
||||
{agent && (
|
||||
<>
|
||||
<div className={styles.agentHeader}>
|
||||
<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="CPU" value={cpuPct != null ? `${(cpuPct * 100).toFixed(0)}%` : '—'} />
|
||||
<StatCard
|
||||
label="Memory"
|
||||
value={memPct != null ? `${memPct.toFixed(0)}%` : '—'}
|
||||
detail={heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : undefined}
|
||||
/>
|
||||
<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)}
|
||||
detail={agent?.registeredAt ? `since ${new Date(agent.registeredAt).toLocaleDateString()}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.scopeTrail}>
|
||||
<a href="/agents" className={styles.scopeLink}>All Agents</a>
|
||||
<Link to="/agents" className={styles.scopeLink}>
|
||||
All Agents
|
||||
</Link>
|
||||
<span className={styles.scopeSep}>▸</span>
|
||||
<a href={`/agents/${appId}`} className={styles.scopeLink}>{appId}</a>
|
||||
<Link to={`/agents/${appId}`} className={styles.scopeLink}>
|
||||
{appId}
|
||||
</Link>
|
||||
<span className={styles.scopeSep}>▸</span>
|
||||
<span className={styles.scopeCurrent}>{agent.name}</span>
|
||||
<Badge
|
||||
label={agent.status.toUpperCase()}
|
||||
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
|
||||
/>
|
||||
{agent.version && <Badge label={agent.version} variant="outlined" />}
|
||||
<StatusDot variant={statusVariant} />
|
||||
<Badge label={agent.status} color={statusColor} />
|
||||
{agent.version && <Badge label={agent.version} variant="outlined" color="auto" />}
|
||||
<Badge
|
||||
label={`${agent.activeRoutes ?? (agent.routeIds?.length ?? 0)}/${agent.totalRoutes ?? (agent.routeIds?.length ?? 0)} routes`}
|
||||
color={(agent.activeRoutes ?? 0) < (agent.totalRoutes ?? 0) ? 'warning' : 'success'}
|
||||
color={
|
||||
(agent.activeRoutes ?? 0) < (agent.totalRoutes ?? 0) ? 'warning' : 'success'
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
{/* Process info card */}
|
||||
<div className={styles.processCard}>
|
||||
<SectionHeader>Process Information</SectionHeader>
|
||||
<div className={styles.processGrid}>
|
||||
{agent.capabilities?.jvmVersion && (
|
||||
<>
|
||||
<span className={styles.processLabel}>JVM</span>
|
||||
<MonoText size="xs">{agent.capabilities.jvmVersion}</MonoText>
|
||||
</>
|
||||
)}
|
||||
{agent?.capabilities?.camelVersion && (
|
||||
<div>
|
||||
<span className={styles.infoLabel}>Camel</span>
|
||||
<span>{agent.capabilities.camelVersion}</span>
|
||||
</div>
|
||||
{agent.capabilities?.camelVersion && (
|
||||
<>
|
||||
<span className={styles.processLabel}>Camel</span>
|
||||
<MonoText size="xs">{agent.capabilities.camelVersion}</MonoText>
|
||||
</>
|
||||
)}
|
||||
{agent?.capabilities?.springBootVersion && (
|
||||
<div>
|
||||
<span className={styles.infoLabel}>Spring Boot</span>
|
||||
<span>{agent.capabilities.springBootVersion}</span>
|
||||
</div>
|
||||
{agent.capabilities?.springBootVersion && (
|
||||
<>
|
||||
<span className={styles.processLabel}>Spring Boot</span>
|
||||
<MonoText size="xs">{agent.capabilities.springBootVersion}</MonoText>
|
||||
</>
|
||||
)}
|
||||
<span className={styles.processLabel}>Started</span>
|
||||
<MonoText size="xs">
|
||||
{agent.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '\u2014'}
|
||||
</MonoText>
|
||||
{agent.capabilities && (
|
||||
<>
|
||||
<span className={styles.processLabel}>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>
|
||||
<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) => (
|
||||
<Badge key={r} label={r} color="auto" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Routes */}
|
||||
{(agent.routeIds?.length ?? 0) > 0 && (
|
||||
<>
|
||||
<SectionHeader>Routes</SectionHeader>
|
||||
<div className={styles.routeBadges}>
|
||||
{(agent.routeIds || []).map((r: string) => (
|
||||
<Badge key={r} label={r} color="auto" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Charts grid — 3x2 */}
|
||||
<div className={styles.chartsGrid}>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>CPU Usage</div>
|
||||
<div className={styles.chartMeta}>{cpuPct != null ? `${(cpuPct * 100).toFixed(0)}% current` : ''}</div>
|
||||
<span className={styles.chartTitle}>CPU Usage</span>
|
||||
<span className={styles.chartMeta}>
|
||||
{cpuDisplay != null ? `${cpuDisplay}% current` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{cpuSeries
|
||||
? <AreaChart series={cpuSeries} yLabel="%" height={200} />
|
||||
: <EmptyState title="No data" description="No CPU metrics available" />}
|
||||
{cpuSeries ? (
|
||||
<AreaChart
|
||||
series={cpuSeries}
|
||||
height={160}
|
||||
yLabel="%"
|
||||
threshold={{ value: 85, label: 'Alert' }}
|
||||
/>
|
||||
) : (
|
||||
<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 className={styles.chartMeta}>{heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : ''}</div>
|
||||
<span className={styles.chartTitle}>Memory (Heap)</span>
|
||||
<span className={styles.chartMeta}>
|
||||
{heapUsedMB != null && heapMaxMB != null
|
||||
? `${heapUsedMB} MB / ${heapMaxMB} MB`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
{heapSeries
|
||||
? <AreaChart series={heapSeries} yLabel="MB" height={200} />
|
||||
: <EmptyState title="No data" description="No heap metrics available" />}
|
||||
{heapSeries ? (
|
||||
<AreaChart series={heapSeries} height={160} yLabel="MB" />
|
||||
) : (
|
||||
<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 className={styles.chartMeta}>{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}</div>
|
||||
<span className={styles.chartTitle}>Throughput</span>
|
||||
<span className={styles.chartMeta}>
|
||||
{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{throughputSeries
|
||||
? <AreaChart series={throughputSeries} yLabel="msg/s" height={200} />
|
||||
: <EmptyState title="No data" description="No throughput data in range" />}
|
||||
{throughputSeries ? (
|
||||
<LineChart series={throughputSeries} height={160} yLabel="msg/s" />
|
||||
) : (
|
||||
<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 className={styles.chartMeta}>{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}</div>
|
||||
<span className={styles.chartTitle}>Error Rate</span>
|
||||
<span className={styles.chartMeta}>
|
||||
{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{errorSeries
|
||||
? <LineChart series={errorSeries} yLabel="%" height={200} />
|
||||
: <EmptyState title="No data" description="No error data in range" />}
|
||||
{errorSeries ? (
|
||||
<LineChart series={errorSeries} height={160} yLabel="err/h" />
|
||||
) : (
|
||||
<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>
|
||||
{threadSeries && <div className={styles.chartMeta}>{threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active</div>}
|
||||
<span className={styles.chartTitle}>Thread Count</span>
|
||||
<span className={styles.chartMeta}>
|
||||
{threadSeries
|
||||
? `${threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
{threadSeries
|
||||
? <LineChart series={threadSeries} yLabel="threads" height={200} />
|
||||
: <EmptyState title="No data" description="No thread metrics available" />}
|
||||
{threadSeries ? (
|
||||
<LineChart series={threadSeries} height={160} yLabel="threads" />
|
||||
) : (
|
||||
<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>
|
||||
<span className={styles.chartTitle}>GC Pauses</span>
|
||||
<span className={styles.chartMeta} />
|
||||
</div>
|
||||
{gcSeries
|
||||
? <BarChart series={gcSeries} yLabel="ms" height={200} />
|
||||
: <EmptyState title="No data" description="No GC metrics available" />}
|
||||
{gcSeries ? (
|
||||
<BarChart series={gcSeries} height={160} yLabel="ms" />
|
||||
) : (
|
||||
<EmptyState title="No data" description="No GC metrics available" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.bottomSection}>
|
||||
<EmptyState title="Application Log" description="Application log streaming is not yet available" />
|
||||
|
||||
<div className={styles.eventCard}>
|
||||
<div className={styles.eventCardHeader}>
|
||||
<span>Timeline</span>
|
||||
<span className={styles.eventCount}>{feedEvents.length} events</span>
|
||||
{/* Log + Timeline side by side */}
|
||||
<div className={styles.bottomRow}>
|
||||
<div className={styles.logCard}>
|
||||
<div className={styles.logHeader}>
|
||||
<SectionHeader>Application Log</SectionHeader>
|
||||
<Tabs tabs={LOG_TABS} active={logFilter} onChange={setLogFilter} />
|
||||
</div>
|
||||
{feedEvents.length > 0
|
||||
? <EventFeed events={feedEvents} maxItems={50} />
|
||||
: <div className={styles.emptyEvents}>No events in the selected time range.</div>}
|
||||
{filteredLogs.length > 0 ? (
|
||||
<LogViewer entries={filteredLogs} maxHeight={360} />
|
||||
) : (
|
||||
<div className={styles.logEmpty}>
|
||||
Application log streaming is not yet available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.timelineCard}>
|
||||
<div className={styles.timelineHeader}>
|
||||
<span className={styles.chartTitle}>Timeline</span>
|
||||
<span className={styles.chartMeta}>{feedEvents.length} events</span>
|
||||
</div>
|
||||
{feedEvents.length > 0 ? (
|
||||
<EventFeed events={feedEvents} maxItems={50} />
|
||||
) : (
|
||||
<div className={styles.logEmpty}>No events in the selected time range.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUptime(seconds?: number): string {
|
||||
if (!seconds) return '—';
|
||||
if (!seconds) return '\u2014';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
.healthStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
/* Scrollable content area */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
min-width: 0;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
/* Filter bar spacing */
|
||||
.filterBar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Table section */
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -39,6 +47,93 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Status cell */
|
||||
.statusCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* Route cells */
|
||||
.routeName {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Application column */
|
||||
.appName {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Duration color classes */
|
||||
.durFast {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.durNormal {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.durSlow {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.durBreach {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Agent badge in table */
|
||||
.agentBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.agentDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #5db866;
|
||||
box-shadow: 0 0 4px rgba(93, 184, 102, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Inline error preview below row */
|
||||
.inlineError {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--error-bg);
|
||||
border-left: 3px solid var(--error-border);
|
||||
}
|
||||
|
||||
.inlineErrorIcon {
|
||||
color: var(--error);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.inlineErrorText {
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.inlineErrorHint {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Detail panel sections */
|
||||
.panelSection {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
@@ -59,19 +154,21 @@
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panelSectionMeta {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Overview grid */
|
||||
.overviewGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -95,45 +192,67 @@
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* Error block */
|
||||
.errorBlock {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.errorClass {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--error);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Inspect exchange icon in table */
|
||||
.inspectLink {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.inspectLink:hover {
|
||||
color: var(--accent, #c6820e);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.detailPanelOverride {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
|
||||
color: var(--text-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Open full details link in panel */
|
||||
.openDetailLink {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #c6820e);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-body);
|
||||
transition: color 0.1s;
|
||||
}
|
||||
|
||||
.openDetailLink:hover {
|
||||
color: var(--amber-deep);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1,186 +1,417 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router'
|
||||
import {
|
||||
StatCard, StatusDot, Badge, MonoText,
|
||||
DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
|
||||
Alert, Collapsible, CodeBlock, ShortcutsBar,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail } from '../../api/queries/executions';
|
||||
import { useDiagramLayout } from '../../api/queries/diagrams';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import type { ExecutionSummary } from '../../api/types';
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
||||
import styles from './Dashboard.module.css';
|
||||
DataTable,
|
||||
DetailPanel,
|
||||
ShortcutsBar,
|
||||
ProcessorTimeline,
|
||||
RouteFlow,
|
||||
KpiStrip,
|
||||
StatusDot,
|
||||
MonoText,
|
||||
Badge,
|
||||
useGlobalFilters,
|
||||
} from '@cameleer/design-system'
|
||||
import type { Column, KpiItem, RouteNode } from '@cameleer/design-system'
|
||||
import {
|
||||
useSearchExecutions,
|
||||
useExecutionStats,
|
||||
useStatsTimeseries,
|
||||
useExecutionDetail,
|
||||
} from '../../api/queries/executions'
|
||||
import { useDiagramLayout } from '../../api/queries/diagrams'
|
||||
import type { ExecutionSummary } from '../../api/types'
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
|
||||
import styles from './Dashboard.module.css'
|
||||
|
||||
interface Row extends ExecutionSummary { id: string }
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
// Row type extends ExecutionSummary with an `id` field for DataTable
|
||||
interface Row extends ExecutionSummary {
|
||||
id: string
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { appId, routeId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { timeRange } = useGlobalFilters();
|
||||
const timeFrom = timeRange.start.toISOString();
|
||||
const timeTo = timeRange.end.toISOString();
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
const y = date.getFullYear()
|
||||
const mo = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const mi = String(date.getMinutes()).padStart(2, '0')
|
||||
const s = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
|
||||
}
|
||||
|
||||
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
||||
const { data: searchResult } = useSearchExecutions({
|
||||
timeFrom, timeTo,
|
||||
routeId: routeId || undefined,
|
||||
application: appId || undefined,
|
||||
offset: 0, limit: 50,
|
||||
}, true);
|
||||
const { data: detail } = useExecutionDetail(selectedId);
|
||||
function statusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
|
||||
switch (status) {
|
||||
case 'COMPLETED': return 'success'
|
||||
case 'FAILED': return 'error'
|
||||
case 'RUNNING': return 'running'
|
||||
default: return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
const rows: Row[] = useMemo(() =>
|
||||
(searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
||||
[searchResult],
|
||||
);
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'COMPLETED': return 'OK'
|
||||
case 'FAILED': return 'ERR'
|
||||
case 'RUNNING': return 'RUN'
|
||||
default: return 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
|
||||
function durationClass(ms: number, status: string): string {
|
||||
if (status === 'FAILED') return styles.durBreach
|
||||
if (ms < 100) return styles.durFast
|
||||
if (ms < 200) return styles.durNormal
|
||||
if (ms < 300) return styles.durSlow
|
||||
return styles.durBreach
|
||||
}
|
||||
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const failedCount = stats?.failedCount ?? 0;
|
||||
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount * 100) : 100;
|
||||
const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 0;
|
||||
function flattenProcessors(nodes: any[]): any[] {
|
||||
const result: any[] = []
|
||||
let offset = 0
|
||||
function walk(node: any) {
|
||||
result.push({
|
||||
name: node.processorId || node.processorType,
|
||||
type: node.processorType,
|
||||
durationMs: node.durationMs ?? 0,
|
||||
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
|
||||
startMs: offset,
|
||||
})
|
||||
offset += node.durationMs ?? 0
|
||||
if (node.children) node.children.forEach(walk)
|
||||
}
|
||||
nodes.forEach(walk)
|
||||
return result
|
||||
}
|
||||
|
||||
const sparkExchanges = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.totalCount as number), [timeseries]);
|
||||
const sparkErrors = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.failedCount as number), [timeseries]);
|
||||
const sparkLatency = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number), [timeseries]);
|
||||
const sparkThroughput = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => {
|
||||
const bucketSeconds = timeWindowSeconds / Math.max((timeseries?.buckets || []).length, 1);
|
||||
return bucketSeconds > 0 ? (b.totalCount as number) / bucketSeconds : 0;
|
||||
}), [timeseries, timeWindowSeconds]);
|
||||
// ─── Table columns (base, without inspect action) ────────────────────────────
|
||||
|
||||
const prevTotal = stats?.prevTotalCount ?? 0;
|
||||
const prevFailed = stats?.prevFailedCount ?? 0;
|
||||
const exchangeTrend = prevTotal > 0 ? ((totalCount - prevTotal) / prevTotal * 100) : 0;
|
||||
const prevSuccessRate = prevTotal > 0 ? ((prevTotal - prevFailed) / prevTotal * 100) : 100;
|
||||
const successRateDelta = successRate - prevSuccessRate;
|
||||
const errorDelta = failedCount - prevFailed;
|
||||
|
||||
const columns: Column<Row>[] = [
|
||||
function buildBaseColumns(): Column<Row>[] {
|
||||
return [
|
||||
{
|
||||
key: 'status', header: 'Status', width: '80px',
|
||||
render: (v, row) => (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />
|
||||
<MonoText size="xs">{v === 'COMPLETED' ? 'OK' : v === 'FAILED' ? 'ERR' : 'RUN'}</MonoText>
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: '80px',
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.statusCell}>
|
||||
<StatusDot variant={statusToVariant(row.status)} />
|
||||
<MonoText size="xs">{statusLabel(row.status)}</MonoText>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '_inspect' as any, header: '', width: '36px',
|
||||
render: (_v, row) => (
|
||||
<a
|
||||
href={`/exchanges/${row.executionId}`}
|
||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); navigate(`/exchanges/${row.executionId}`); }}
|
||||
className={styles.inspectLink}
|
||||
title="Open full details"
|
||||
>↗</a>
|
||||
key: 'routeId',
|
||||
header: 'Route',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.routeName}>{row.routeId}</span>
|
||||
),
|
||||
},
|
||||
{ key: 'routeId', header: 'Route', sortable: true, render: (v) => <span>{String(v)}</span> },
|
||||
{ key: 'applicationName', header: 'Application', sortable: true, render: (v) => <span>{String(v ?? '')}</span> },
|
||||
{ key: 'executionId', header: 'Exchange ID', sortable: true, render: (v) => <MonoText size="xs">{String(v)}</MonoText> },
|
||||
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => <MonoText size="xs">{new Date(v as string).toLocaleString()}</MonoText> },
|
||||
{
|
||||
key: 'durationMs', header: 'Duration', sortable: true,
|
||||
render: (v) => <MonoText size="sm">{formatDuration(v as number)}</MonoText>,
|
||||
key: 'applicationName',
|
||||
header: 'Application',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.appName}>{row.applicationName ?? ''}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agentId', header: 'Agent',
|
||||
render: (v) => v ? <Badge label={String(v)} color="auto" /> : null,
|
||||
key: 'executionId',
|
||||
header: 'Exchange ID',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<MonoText size="xs">{row.executionId}</MonoText>
|
||||
),
|
||||
},
|
||||
];
|
||||
{
|
||||
key: 'startTime',
|
||||
header: 'Started',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<MonoText size="xs">{formatTimestamp(row.startTime)}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'durationMs',
|
||||
header: 'Duration',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
|
||||
{formatDuration(row.durationMs)}
|
||||
</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agentId',
|
||||
header: 'Agent',
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.agentBadge}>
|
||||
<span className={styles.agentDot} />
|
||||
{row.agentId}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
|
||||
const SHORTCUTS = [
|
||||
{ keys: 'Ctrl+K', label: 'Search' },
|
||||
{ keys: '\u2191\u2193', label: 'Navigate rows' },
|
||||
{ keys: 'Enter', label: 'Open detail' },
|
||||
{ keys: 'Esc', label: 'Close panel' },
|
||||
]
|
||||
|
||||
// ─── Dashboard component ─────────────────────────────────────────────────────
|
||||
|
||||
export default function Dashboard() {
|
||||
const { appId, routeId } = useParams<{ appId: string; routeId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>()
|
||||
const [panelOpen, setPanelOpen] = useState(false)
|
||||
|
||||
const { timeRange, statusFilters } = useGlobalFilters()
|
||||
const timeFrom = timeRange.start.toISOString()
|
||||
const timeTo = timeRange.end.toISOString()
|
||||
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000
|
||||
|
||||
// ─── API hooks ───────────────────────────────────────────────────────────
|
||||
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId)
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId)
|
||||
const { data: searchResult } = useSearchExecutions(
|
||||
{
|
||||
timeFrom,
|
||||
timeTo,
|
||||
routeId: routeId || undefined,
|
||||
application: appId || undefined,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
},
|
||||
true,
|
||||
)
|
||||
const { data: detail } = useExecutionDetail(selectedId ?? null)
|
||||
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null)
|
||||
|
||||
// ─── Rows ────────────────────────────────────────────────────────────────
|
||||
const allRows: Row[] = useMemo(
|
||||
() => (searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
||||
[searchResult],
|
||||
)
|
||||
|
||||
// Apply global status filters (time filtering is done server-side via timeFrom/timeTo)
|
||||
const rows: Row[] = useMemo(() => {
|
||||
if (statusFilters.size === 0) return allRows
|
||||
return allRows.filter((r) => statusFilters.has(r.status.toLowerCase() as any))
|
||||
}, [allRows, statusFilters])
|
||||
|
||||
// ─── KPI items ───────────────────────────────────────────────────────────
|
||||
const totalCount = stats?.totalCount ?? 0
|
||||
const failedCount = stats?.failedCount ?? 0
|
||||
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100
|
||||
const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 0
|
||||
|
||||
const prevTotal = stats?.prevTotalCount ?? 0
|
||||
const prevFailed = stats?.prevFailedCount ?? 0
|
||||
const exchangeTrend = prevTotal > 0 ? ((totalCount - prevTotal) / prevTotal) * 100 : 0
|
||||
const prevSuccessRate = prevTotal > 0 ? ((prevTotal - prevFailed) / prevTotal) * 100 : 100
|
||||
const successRateDelta = successRate - prevSuccessRate
|
||||
const errorDelta = failedCount - prevFailed
|
||||
|
||||
const sparkExchanges = useMemo(
|
||||
() => (timeseries?.buckets || []).map((b: any) => b.totalCount as number),
|
||||
[timeseries],
|
||||
)
|
||||
const sparkErrors = useMemo(
|
||||
() => (timeseries?.buckets || []).map((b: any) => b.failedCount as number),
|
||||
[timeseries],
|
||||
)
|
||||
const sparkLatency = useMemo(
|
||||
() => (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number),
|
||||
[timeseries],
|
||||
)
|
||||
const sparkThroughput = useMemo(
|
||||
() =>
|
||||
(timeseries?.buckets || []).map((b: any) => {
|
||||
const bucketSeconds = timeWindowSeconds / Math.max((timeseries?.buckets || []).length, 1)
|
||||
return bucketSeconds > 0 ? (b.totalCount as number) / bucketSeconds : 0
|
||||
}),
|
||||
[timeseries, timeWindowSeconds],
|
||||
)
|
||||
|
||||
const kpiItems: KpiItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: 'Exchanges',
|
||||
value: totalCount.toLocaleString(),
|
||||
trend: {
|
||||
label: `${exchangeTrend > 0 ? '\u2191' : exchangeTrend < 0 ? '\u2193' : '\u2192'} ${exchangeTrend > 0 ? '+' : ''}${exchangeTrend.toFixed(0)}%`,
|
||||
variant: (exchangeTrend > 0 ? 'success' : exchangeTrend < 0 ? 'error' : 'muted') as 'success' | 'error' | 'muted',
|
||||
},
|
||||
subtitle: `${successRate.toFixed(1)}% success rate`,
|
||||
sparkline: sparkExchanges,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'Success Rate',
|
||||
value: `${successRate.toFixed(1)}%`,
|
||||
trend: {
|
||||
label: `${successRateDelta >= 0 ? '\u2191' : '\u2193'} ${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`,
|
||||
variant: (successRateDelta >= 0 ? 'success' : 'error') as 'success' | 'error',
|
||||
},
|
||||
subtitle: `${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`,
|
||||
borderColor: 'var(--success)',
|
||||
},
|
||||
{
|
||||
label: 'Errors',
|
||||
value: failedCount,
|
||||
trend: {
|
||||
label: `${errorDelta > 0 ? '\u2191' : errorDelta < 0 ? '\u2193' : '\u2192'} ${errorDelta > 0 ? '+' : ''}${errorDelta}`,
|
||||
variant: (errorDelta > 0 ? 'error' : errorDelta < 0 ? 'success' : 'muted') as 'success' | 'error' | 'muted',
|
||||
},
|
||||
subtitle: `${failedCount} errors in selected period`,
|
||||
sparkline: sparkErrors,
|
||||
borderColor: 'var(--error)',
|
||||
},
|
||||
{
|
||||
label: 'Throughput',
|
||||
value: `${throughput.toFixed(1)} msg/s`,
|
||||
trend: { label: '\u2192', variant: 'muted' as const },
|
||||
subtitle: `${throughput.toFixed(1)} msg/s`,
|
||||
sparkline: sparkThroughput,
|
||||
borderColor: 'var(--running)',
|
||||
},
|
||||
{
|
||||
label: 'Latency p99',
|
||||
value: `${(stats?.p99LatencyMs ?? 0).toLocaleString()} ms`,
|
||||
trend: { label: '', variant: 'muted' as const },
|
||||
subtitle: `${(stats?.p99LatencyMs ?? 0).toLocaleString()}ms`,
|
||||
sparkline: sparkLatency,
|
||||
borderColor: 'var(--warning)',
|
||||
},
|
||||
],
|
||||
[totalCount, failedCount, successRate, throughput, exchangeTrend, successRateDelta, errorDelta, sparkExchanges, sparkErrors, sparkLatency, sparkThroughput, stats?.p99LatencyMs],
|
||||
)
|
||||
|
||||
// ─── Table columns with inspect action ───────────────────────────────────
|
||||
const columns: Column<Row>[] = useMemo(() => {
|
||||
const inspectCol: Column<Row> = {
|
||||
key: 'correlationId',
|
||||
header: '',
|
||||
width: '36px',
|
||||
render: (_: unknown, row: Row) => (
|
||||
<button
|
||||
className={styles.inspectLink}
|
||||
title="Inspect exchange"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate(`/exchanges/${row.executionId}`)
|
||||
}}
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
),
|
||||
}
|
||||
const base = buildBaseColumns()
|
||||
const [statusCol, ...rest] = base
|
||||
return [statusCol, inspectCol, ...rest]
|
||||
}, [navigate])
|
||||
|
||||
// ─── Row click / detail panel ────────────────────────────────────────────
|
||||
const selectedRow = useMemo(
|
||||
() => rows.find((r) => r.id === selectedId),
|
||||
[rows, selectedId],
|
||||
)
|
||||
|
||||
function handleRowClick(row: Row) {
|
||||
setSelectedId(row.id)
|
||||
setPanelOpen(true)
|
||||
}
|
||||
|
||||
function handleRowAccent(row: Row): 'error' | 'warning' | undefined {
|
||||
if (row.status === 'FAILED') return 'error'
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ─── Detail panel data ───────────────────────────────────────────────────
|
||||
const procList = detail
|
||||
? detail.processors?.length
|
||||
? detail.processors
|
||||
: (detail.children ?? [])
|
||||
: []
|
||||
|
||||
const routeNodes: RouteNode[] = useMemo(() => {
|
||||
if (diagram?.nodes) {
|
||||
return mapDiagramToRouteNodes(diagram.nodes || [], procList)
|
||||
}
|
||||
return []
|
||||
}, [diagram, procList])
|
||||
|
||||
const flatProcs = useMemo(() => flattenProcessors(procList), [procList])
|
||||
|
||||
// Error info from detail
|
||||
const errorClass = detail?.errorMessage?.split(':')[0] ?? ''
|
||||
const errorMsg = detail?.errorMessage ?? ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.healthStrip}>
|
||||
<StatCard
|
||||
label="Exchanges"
|
||||
value={totalCount.toLocaleString()}
|
||||
detail={`${successRate.toFixed(1)}% success rate`}
|
||||
trend={exchangeTrend > 0 ? 'up' : exchangeTrend < 0 ? 'down' : 'neutral'}
|
||||
trendValue={exchangeTrend > 0 ? `+${exchangeTrend.toFixed(0)}%` : `${exchangeTrend.toFixed(0)}%`}
|
||||
sparkline={sparkExchanges}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label="Success Rate"
|
||||
value={`${successRate.toFixed(1)}%`}
|
||||
detail={`${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`}
|
||||
trend={successRateDelta >= 0 ? 'up' : 'down'}
|
||||
trendValue={`${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`}
|
||||
accent="success"
|
||||
/>
|
||||
<StatCard
|
||||
label="Errors"
|
||||
value={failedCount}
|
||||
detail={`${failedCount} errors in selected period`}
|
||||
trend={errorDelta > 0 ? 'up' : errorDelta < 0 ? 'down' : 'neutral'}
|
||||
trendValue={errorDelta > 0 ? `+${errorDelta}` : `${errorDelta}`}
|
||||
sparkline={sparkErrors}
|
||||
accent="error"
|
||||
/>
|
||||
<StatCard
|
||||
label="Throughput"
|
||||
value={throughput.toFixed(1)}
|
||||
detail={`${throughput.toFixed(1)} msg/s`}
|
||||
sparkline={sparkThroughput}
|
||||
accent="running"
|
||||
/>
|
||||
<StatCard
|
||||
label="Latency p99"
|
||||
value={(stats?.p99LatencyMs ?? 0).toLocaleString()}
|
||||
detail={`${(stats?.p99LatencyMs ?? 0).toLocaleString()}ms`}
|
||||
sparkline={sparkLatency}
|
||||
accent="warning"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* Scrollable content */}
|
||||
<div className={styles.content}>
|
||||
{/* KPI strip */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Recent Exchanges</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>{rows.length} of {searchResult?.total ?? 0} exchanges</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
{/* Exchanges table */}
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Recent Exchanges</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>
|
||||
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
|
||||
</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
onRowClick={handleRowClick}
|
||||
selectedId={selectedId}
|
||||
sortable
|
||||
flush
|
||||
rowAccent={handleRowAccent}
|
||||
expandedContent={(row: Row) =>
|
||||
row.errorMessage ? (
|
||||
<div className={styles.inlineError}>
|
||||
<span className={styles.inlineErrorIcon}>{'\u26A0'}</span>
|
||||
<div>
|
||||
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
||||
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
onRowClick={(row) => { setSelectedId(row.id); }}
|
||||
selectedId={selectedId ?? undefined}
|
||||
sortable
|
||||
pageSize={25}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedId && detail && (
|
||||
{/* Shortcuts bar */}
|
||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||
|
||||
{/* Detail panel */}
|
||||
{selectedRow && detail && (
|
||||
<DetailPanel
|
||||
key={selectedId}
|
||||
open={true}
|
||||
onClose={() => setSelectedId(null)}
|
||||
title={`${detail.routeId} — ${selectedId.slice(0, 12)}`}
|
||||
className={styles.detailPanelOverride}
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
|
||||
>
|
||||
{/* Open full details link */}
|
||||
{/* Link to full detail page */}
|
||||
<div className={styles.panelSection}>
|
||||
<button
|
||||
className={styles.openDetailLink}
|
||||
@@ -196,9 +427,9 @@ export default function Dashboard() {
|
||||
<div className={styles.overviewGrid}>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Status</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : 'error'} />
|
||||
<span>{detail.status}</span>
|
||||
<span className={styles.statusCell}>
|
||||
<StatusDot variant={statusToVariant(detail.status)} />
|
||||
<span>{statusLabel(detail.status)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
@@ -211,44 +442,38 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Agent</span>
|
||||
<MonoText size="sm">{detail.agentId ?? '—'}</MonoText>
|
||||
<MonoText size="sm">{detail.agentId ?? '\u2014'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Correlation</span>
|
||||
<MonoText size="xs">{detail.correlationId ?? '—'}</MonoText>
|
||||
<MonoText size="xs">{detail.correlationId ?? '\u2014'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Timestamp</span>
|
||||
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toLocaleString() : '—'}</MonoText>
|
||||
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'}</MonoText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{detail.errorMessage && (
|
||||
{errorMsg && (
|
||||
<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 className={styles.errorBlock}>
|
||||
<div className={styles.errorClass}>{errorClass}</div>
|
||||
<div className={styles.errorMessage}>{errorMsg}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route Flow */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||
{diagram ? (
|
||||
<RouteFlow
|
||||
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
|
||||
onNodeClick={(_node, _i) => {}}
|
||||
/>
|
||||
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>}
|
||||
{routeNodes.length > 0 ? (
|
||||
<RouteFlow nodes={routeNodes} />
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Processor Timeline */}
|
||||
@@ -257,33 +482,17 @@ export default function Dashboard() {
|
||||
Processor Timeline
|
||||
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
|
||||
</div>
|
||||
{procList.length ? (
|
||||
{flatProcs.length > 0 ? (
|
||||
<ProcessorTimeline
|
||||
processors={flattenProcessors(procList)}
|
||||
processors={flatProcs}
|
||||
totalMs={detail.durationMs}
|
||||
/>
|
||||
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>}
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailPanel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function flattenProcessors(nodes: any[]): any[] {
|
||||
const result: any[] = [];
|
||||
let offset = 0;
|
||||
function walk(node: any) {
|
||||
result.push({
|
||||
name: node.processorId || node.processorType,
|
||||
type: node.processorType,
|
||||
durationMs: node.durationMs ?? 0,
|
||||
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
|
||||
startMs: offset,
|
||||
});
|
||||
offset += node.durationMs ?? 0;
|
||||
if (node.children) node.children.forEach(walk);
|
||||
}
|
||||
nodes.forEach(walk);
|
||||
return result;
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/* Scrollable content area */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
min-width: 0;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
EXCHANGE HEADER CARD
|
||||
========================================================================== */
|
||||
.exchangeHeader {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -38,14 +56,14 @@
|
||||
}
|
||||
|
||||
.routeLink {
|
||||
color: var(--accent, #c6820e);
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.routeLink:hover {
|
||||
color: var(--amber-deep, #a36b0b);
|
||||
color: var(--amber-deep);
|
||||
}
|
||||
|
||||
.headerDivider {
|
||||
@@ -78,7 +96,9 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Correlation Chain */
|
||||
/* ==========================================================================
|
||||
CORRELATION CHAIN
|
||||
========================================================================== */
|
||||
.correlationChain {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -104,7 +124,7 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
@@ -120,20 +140,37 @@
|
||||
}
|
||||
|
||||
.chainNodeCurrent {
|
||||
background: var(--amber-bg, rgba(198, 130, 14, 0.08));
|
||||
border-color: var(--accent, #c6820e);
|
||||
color: var(--accent, #c6820e);
|
||||
background: var(--amber-bg);
|
||||
border-color: var(--amber-light);
|
||||
color: var(--amber-deep);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chainNodeSuccess { border-left: 3px solid var(--success); }
|
||||
.chainNodeError { border-left: 3px solid var(--error); }
|
||||
.chainNodeRunning { border-left: 3px solid var(--running); }
|
||||
.chainNodeWarning { border-left: 3px solid var(--warning); }
|
||||
.chainNodeSuccess {
|
||||
border-left: 3px solid var(--success);
|
||||
}
|
||||
|
||||
.chainMore { color: var(--text-muted); font-size: 11px; font-style: italic; }
|
||||
.chainNodeError {
|
||||
border-left: 3px solid var(--error);
|
||||
}
|
||||
|
||||
/* Timeline Section */
|
||||
.chainNodeRunning {
|
||||
border-left: 3px solid var(--running);
|
||||
}
|
||||
|
||||
.chainNodeWarning {
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
|
||||
.chainMore {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
TIMELINE SECTION
|
||||
========================================================================== */
|
||||
.timelineSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -174,7 +211,7 @@
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -194,20 +231,22 @@
|
||||
}
|
||||
|
||||
.toggleBtnActive {
|
||||
background: var(--accent, #c6820e);
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toggleBtnActive:hover {
|
||||
background: var(--amber-deep, #a36b0b);
|
||||
background: var(--amber-deep);
|
||||
}
|
||||
|
||||
.timelineBody {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Detail Split (IN / OUT panels) */
|
||||
/* ==========================================================================
|
||||
DETAIL SPLIT (IN / OUT panels)
|
||||
========================================================================== */
|
||||
.detailSplit {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -224,7 +263,7 @@
|
||||
}
|
||||
|
||||
.detailPanelError {
|
||||
border-color: var(--error-border, rgba(220, 38, 38, 0.3));
|
||||
border-color: var(--error-border);
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
@@ -238,8 +277,8 @@
|
||||
}
|
||||
|
||||
.detailPanelError .panelHeader {
|
||||
background: var(--error-bg, rgba(220, 38, 38, 0.06));
|
||||
border-bottom-color: var(--error-border, rgba(220, 38, 38, 0.3));
|
||||
background: var(--error-bg);
|
||||
border-bottom-color: var(--error-border);
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
@@ -350,14 +389,33 @@
|
||||
}
|
||||
|
||||
/* Error panel styles */
|
||||
.errorBadgeRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.errorHttpBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
.errorMessageBox {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--error-bg, rgba(220, 38, 38, 0.06));
|
||||
background: var(--error-bg);
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border: 1px solid var(--error-border, rgba(220, 38, 38, 0.3));
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--error-border);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
@@ -382,3 +440,11 @@
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Snapshot loading */
|
||||
.snapshotLoading {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@@ -1,112 +1,187 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router'
|
||||
import {
|
||||
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
|
||||
ProcessorTimeline, Breadcrumb, Spinner, RouteFlow,
|
||||
} from '@cameleer/design-system';
|
||||
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
|
||||
import { useCorrelationChain } from '../../api/queries/correlation';
|
||||
import { useDiagramLayout } from '../../api/queries/diagrams';
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
||||
import styles from './ExchangeDetail.module.css';
|
||||
} from '@cameleer/design-system'
|
||||
import type { ProcessorStep, RouteNode } from '@cameleer/design-system'
|
||||
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
|
||||
import { useCorrelationChain } from '../../api/queries/correlation'
|
||||
import { useDiagramLayout } 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);
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
|
||||
return `${ms}ms`;
|
||||
function backendStatusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'COMPLETED': return 'success'
|
||||
case 'FAILED': return 'error'
|
||||
case 'RUNNING': return 'running'
|
||||
default: return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
function backendStatusToLabel(status: string): string {
|
||||
return status.toUpperCase()
|
||||
}
|
||||
|
||||
function procStatusToStep(status: string): 'ok' | 'slow' | 'fail' {
|
||||
const s = status.toUpperCase()
|
||||
if (s === 'FAILED') return 'fail'
|
||||
if (s === 'RUNNING') return 'slow'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
function parseHeaders(raw: string | undefined | null): Record<string, string> {
|
||||
if (!raw) return {};
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const result: Record<string, string> = {};
|
||||
const result: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
result[k] = typeof v === 'string' ? v : JSON.stringify(v);
|
||||
result[k] = typeof v === 'string' ? v : JSON.stringify(v)
|
||||
}
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return {};
|
||||
return {}
|
||||
}
|
||||
|
||||
function countProcessors(nodes: Array<{ children?: any[] }>): number {
|
||||
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0)
|
||||
}
|
||||
|
||||
// ── ExchangeDetail ───────────────────────────────────────────────────────────
|
||||
export default function ExchangeDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: detail, isLoading } = useExecutionDetail(id ?? null);
|
||||
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt');
|
||||
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null);
|
||||
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
|
||||
const { data: detail, isLoading } = useExecutionDetail(id ?? null)
|
||||
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null)
|
||||
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null)
|
||||
|
||||
// Auto-select first failed processor, or 0
|
||||
const defaultIndex = useMemo(() => {
|
||||
if (!procList.length) return 0;
|
||||
const failIdx = procList.findIndex((p: any) =>
|
||||
(p.status || '').toUpperCase() === 'FAILED' || p.status === 'fail'
|
||||
);
|
||||
return failIdx >= 0 ? failIdx : 0;
|
||||
}, [procList]);
|
||||
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
|
||||
|
||||
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null);
|
||||
const activeIndex = selectedProcessorIndex ?? defaultIndex;
|
||||
const procList = detail
|
||||
? (detail.processors?.length ? detail.processors : (detail.children ?? []))
|
||||
: []
|
||||
|
||||
const { data: snapshot } = useProcessorSnapshot(id ?? null, procList.length > 0 ? activeIndex : null);
|
||||
|
||||
const processors = useMemo(() => {
|
||||
if (!procList.length) return [];
|
||||
const result: any[] = [];
|
||||
let offset = 0;
|
||||
// Flatten processor tree into ProcessorStep[]
|
||||
const processors: ProcessorStep[] = useMemo(() => {
|
||||
if (!procList.length) return []
|
||||
const result: ProcessorStep[] = []
|
||||
let offset = 0
|
||||
function walk(node: any) {
|
||||
result.push({
|
||||
name: node.processorId || node.processorType,
|
||||
type: node.processorType,
|
||||
durationMs: node.durationMs ?? 0,
|
||||
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
|
||||
status: procStatusToStep(node.status ?? ''),
|
||||
startMs: offset,
|
||||
});
|
||||
offset += node.durationMs ?? 0;
|
||||
if (node.children) node.children.forEach(walk);
|
||||
})
|
||||
offset += node.durationMs ?? 0
|
||||
if (node.children) node.children.forEach(walk)
|
||||
}
|
||||
procList.forEach(walk);
|
||||
return result;
|
||||
}, [procList]);
|
||||
procList.forEach(walk)
|
||||
return result
|
||||
}, [procList])
|
||||
|
||||
const selectedProc = processors[activeIndex];
|
||||
const isSelectedFailed = selectedProc?.status === 'fail';
|
||||
// Default selected processor: first failed, or 0
|
||||
const defaultIndex = useMemo(() => {
|
||||
if (!processors.length) return 0
|
||||
const failIdx = processors.findIndex((p) => p.status === 'fail')
|
||||
return failIdx >= 0 ? failIdx : 0
|
||||
}, [processors])
|
||||
|
||||
// Parse snapshot headers
|
||||
const inputHeaders = parseHeaders(snapshot?.inputHeaders);
|
||||
const outputHeaders = parseHeaders(snapshot?.outputHeaders);
|
||||
const inputBody = snapshot?.inputBody ?? null;
|
||||
const outputBody = snapshot?.outputBody ?? null;
|
||||
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null)
|
||||
const activeIndex = selectedProcessorIndex ?? defaultIndex
|
||||
|
||||
if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
|
||||
if (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>;
|
||||
const { data: snapshot } = useProcessorSnapshot(
|
||||
id ?? null,
|
||||
procList.length > 0 ? activeIndex : null,
|
||||
)
|
||||
|
||||
const selectedProc = processors[activeIndex]
|
||||
const isSelectedFailed = selectedProc?.status === 'fail'
|
||||
|
||||
// Parse snapshot data
|
||||
const inputHeaders = parseHeaders(snapshot?.inputHeaders)
|
||||
const outputHeaders = parseHeaders(snapshot?.outputHeaders)
|
||||
const inputBody = snapshot?.inputBody ?? null
|
||||
const outputBody = snapshot?.outputBody ?? null
|
||||
|
||||
// Build RouteFlow nodes from diagram + execution data
|
||||
const routeNodes: RouteNode[] = useMemo(() => {
|
||||
if (diagram?.nodes) {
|
||||
return mapDiagramToRouteNodes(diagram.nodes, procList)
|
||||
}
|
||||
// Fallback: build from processor list
|
||||
return processors.map((p) => ({
|
||||
name: p.name,
|
||||
type: 'process' as RouteNode['type'],
|
||||
durationMs: p.durationMs,
|
||||
status: p.status,
|
||||
}))
|
||||
}, [diagram, processors, procList])
|
||||
|
||||
// Correlation chain
|
||||
const correlatedExchanges = useMemo(() => {
|
||||
if (!correlationData?.data || correlationData.data.length <= 1) return []
|
||||
return correlationData.data
|
||||
}, [correlationData])
|
||||
|
||||
// ── Loading state ────────────────────────────────────────────────────────
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Not found state ──────────────────────────────────────────────────────
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
{ label: 'Exchanges' },
|
||||
{ label: id ?? 'Unknown' },
|
||||
]} />
|
||||
<InfoCallout variant="warning">Exchange "{id}" not found.</InfoCallout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusVariant = backendStatusToVariant(detail.status)
|
||||
const statusLabel = backendStatusToLabel(detail.status)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.content}>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Dashboard', href: '/apps' },
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
{ label: detail.applicationName || 'App', href: `/apps/${detail.applicationName}` },
|
||||
{ label: id?.slice(0, 12) || '' },
|
||||
{ label: detail.routeId, href: `/apps/${detail.applicationName}/${detail.routeId}` },
|
||||
{ label: detail.executionId?.slice(0, 12) || '' },
|
||||
]} />
|
||||
|
||||
{/* Exchange header card */}
|
||||
<div className={styles.exchangeHeader}>
|
||||
<div className={styles.headerRow}>
|
||||
<div className={styles.headerLeft}>
|
||||
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
|
||||
<StatusDot variant={statusVariant} />
|
||||
<div>
|
||||
<div className={styles.exchangeId}>
|
||||
<MonoText size="md">{id}</MonoText>
|
||||
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} variant="filled" />
|
||||
<MonoText size="md">{detail.executionId}</MonoText>
|
||||
<Badge label={statusLabel} color={statusVariant} variant="filled" />
|
||||
</div>
|
||||
<div className={styles.exchangeRoute}>
|
||||
Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${detail.applicationName}/${detail.routeId}`)}>{detail.routeId}</span>
|
||||
@@ -116,6 +191,12 @@ export default function ExchangeDetail() {
|
||||
App: <MonoText size="xs">{detail.applicationName}</MonoText>
|
||||
</>
|
||||
)}
|
||||
{detail.correlationId && (
|
||||
<>
|
||||
<span className={styles.headerDivider}>·</span>
|
||||
Correlation: <MonoText size="xs">{detail.correlationId}</MonoText>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,7 +212,9 @@ export default function ExchangeDetail() {
|
||||
<div className={styles.headerStat}>
|
||||
<div className={styles.headerStatLabel}>Started</div>
|
||||
<div className={styles.headerStatValue}>
|
||||
{detail.startTime ? new Date(detail.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—'}
|
||||
{detail.startTime
|
||||
? new Date(detail.startTime).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
: '\u2014'}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerStat}>
|
||||
@@ -142,43 +225,39 @@ export default function ExchangeDetail() {
|
||||
</div>
|
||||
|
||||
{/* Correlation Chain */}
|
||||
{correlationData?.data && correlationData.data.length > 1 && (
|
||||
{correlatedExchanges.length > 1 && (
|
||||
<div className={styles.correlationChain}>
|
||||
<span className={styles.chainLabel}>Correlated Exchanges</span>
|
||||
{correlationData.data.map((exec: any) => {
|
||||
const isCurrent = exec.executionId === id;
|
||||
const variant = exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'running';
|
||||
{correlatedExchanges.map((ce) => {
|
||||
const isCurrent = ce.executionId === id
|
||||
const variant = backendStatusToVariant(ce.status)
|
||||
const statusCls =
|
||||
variant === 'success' ? styles.chainNodeSuccess
|
||||
: variant === 'error' ? styles.chainNodeError
|
||||
: styles.chainNodeRunning;
|
||||
: variant === 'running' ? styles.chainNodeRunning
|
||||
: styles.chainNodeWarning
|
||||
return (
|
||||
<button
|
||||
key={exec.executionId}
|
||||
key={ce.executionId}
|
||||
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
|
||||
onClick={() => { if (!isCurrent) navigate(`/exchanges/${exec.executionId}`); }}
|
||||
title={`${exec.executionId} — ${exec.routeId}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) navigate(`/exchanges/${ce.executionId}`)
|
||||
}}
|
||||
title={`${ce.executionId} \u2014 ${ce.routeId}`}
|
||||
>
|
||||
<StatusDot variant={variant as any} />
|
||||
<span>{exec.routeId}</span>
|
||||
<StatusDot variant={variant} />
|
||||
<span>{ce.routeId}</span>
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
{correlationData.total > 20 && (
|
||||
{correlationData && correlationData.total > 20 && (
|
||||
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error callout */}
|
||||
{detail.errorMessage && (
|
||||
<InfoCallout variant="error">
|
||||
{detail.errorMessage}
|
||||
</InfoCallout>
|
||||
)}
|
||||
|
||||
{/* Processor Timeline / Flow Section */}
|
||||
{/* Processor Timeline Section */}
|
||||
<div className={styles.timelineSection}>
|
||||
<div className={styles.timelineHeader}>
|
||||
<span className={styles.timelineTitle}>
|
||||
@@ -206,17 +285,17 @@ export default function ExchangeDetail() {
|
||||
<ProcessorTimeline
|
||||
processors={processors}
|
||||
totalMs={detail.durationMs}
|
||||
onProcessorClick={(_p, i) => setSelectedProcessorIndex(i)}
|
||||
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
|
||||
selectedIndex={activeIndex}
|
||||
/>
|
||||
) : (
|
||||
<InfoCallout>No processor data available</InfoCallout>
|
||||
)
|
||||
) : (
|
||||
diagram ? (
|
||||
routeNodes.length > 0 ? (
|
||||
<RouteFlow
|
||||
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
|
||||
onNodeClick={(_node, i) => setSelectedProcessorIndex(i)}
|
||||
nodes={routeNodes}
|
||||
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
|
||||
selectedIndex={activeIndex}
|
||||
/>
|
||||
) : (
|
||||
@@ -226,7 +305,7 @@ export default function ExchangeDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processor Detail: Message IN / Message OUT or Error */}
|
||||
{/* Processor Detail Panel (split IN / OUT) */}
|
||||
{selectedProc && snapshot && (
|
||||
<div className={styles.detailSplit}>
|
||||
{/* Message IN */}
|
||||
@@ -255,7 +334,7 @@ export default function ExchangeDetail() {
|
||||
)}
|
||||
<div className={styles.bodySection}>
|
||||
<div className={styles.sectionLabel}>Body</div>
|
||||
<CodeBlock content={inputBody ?? 'null'} />
|
||||
<CodeBlock content={inputBody ?? 'null'} language="json" copyable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,7 +388,7 @@ export default function ExchangeDetail() {
|
||||
)}
|
||||
<div className={styles.bodySection}>
|
||||
<div className={styles.sectionLabel}>Body</div>
|
||||
<CodeBlock content={outputBody ?? 'null'} />
|
||||
<CodeBlock content={outputBody ?? 'null'} language="json" copyable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,12 +396,13 @@ export default function ExchangeDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No snapshot loaded yet - show prompt */}
|
||||
{/* Snapshot loading indicator */}
|
||||
{selectedProc && !snapshot && procList.length > 0 && (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', padding: 20 }}>
|
||||
<div className={styles.snapshotLoading}>
|
||||
Loading exchange snapshot...
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,39 +1,288 @@
|
||||
/* Back link */
|
||||
.backLink {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
margin-bottom: 12px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Route header card */
|
||||
.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;
|
||||
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;
|
||||
|
||||
.headerRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.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; }
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Diagram + Stats side-by-side */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Processor type badges */
|
||||
.processorType {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.typeConsumer {
|
||||
background: var(--running-bg);
|
||||
color: var(--running);
|
||||
}
|
||||
|
||||
.typeProducer {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.typeEnricher {
|
||||
background: var(--amber-bg);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.typeValidator {
|
||||
background: var(--running-bg);
|
||||
color: var(--running);
|
||||
}
|
||||
|
||||
.typeTransformer {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.typeRouter {
|
||||
background: var(--purple-bg);
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
.typeProcessor {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Tabs section */
|
||||
.tabSection {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Rate color classes */
|
||||
.rateGood {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.rateWarn {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.rateBad {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.rateNeutral {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Route name in table */
|
||||
.routeNameCell {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Table section (reused for processor table) */
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.tableTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tableRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tableMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Chart grid */
|
||||
.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;
|
||||
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; }
|
||||
|
||||
.chartTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Executions table */
|
||||
.executionsTable {
|
||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden;
|
||||
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; }
|
||||
|
||||
/* Error patterns */
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Route flow section */
|
||||
.routeFlowSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Empty / muted text */
|
||||
.emptyText {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.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); }
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router';
|
||||
import {
|
||||
Badge, StatusDot, DataTable, Tabs,
|
||||
AreaChart, LineChart, BarChart, RouteFlow, Spinner,
|
||||
KpiStrip,
|
||||
Badge,
|
||||
StatusDot,
|
||||
DataTable,
|
||||
Tabs,
|
||||
AreaChart,
|
||||
LineChart,
|
||||
BarChart,
|
||||
RouteFlow,
|
||||
Spinner,
|
||||
MonoText,
|
||||
Sparkline,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import type { KpiItem, 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 { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../../api/queries/executions';
|
||||
import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
||||
import styles from './RouteDetail.module.css';
|
||||
|
||||
// ── Row types ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ExchangeRow extends ExecutionSummary {
|
||||
id: string;
|
||||
}
|
||||
@@ -26,6 +37,8 @@ interface ProcessorRow {
|
||||
avgDurationMs: number;
|
||||
p99DurationMs: number;
|
||||
errorCount: number;
|
||||
errorRate: number;
|
||||
sparkline: number[];
|
||||
}
|
||||
|
||||
interface ErrorPattern {
|
||||
@@ -34,6 +47,211 @@ interface ErrorPattern {
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
// ── Processor type badge classes ─────────────────────────────────────────────
|
||||
|
||||
const TYPE_STYLE_MAP: Record<string, string> = {
|
||||
consumer: styles.typeConsumer,
|
||||
producer: styles.typeProducer,
|
||||
enricher: styles.typeEnricher,
|
||||
validator: styles.typeValidator,
|
||||
transformer: styles.typeTransformer,
|
||||
router: styles.typeRouter,
|
||||
processor: styles.typeProcessor,
|
||||
};
|
||||
|
||||
function classifyProcessorType(processorId: string): string {
|
||||
const lower = processorId.toLowerCase();
|
||||
if (lower.startsWith('from(') || lower.includes('consumer')) return 'consumer';
|
||||
if (lower.startsWith('to(')) return 'producer';
|
||||
if (lower.includes('enrich')) return 'enricher';
|
||||
if (lower.includes('validate') || lower.includes('check')) return 'validator';
|
||||
if (lower.includes('unmarshal') || lower.includes('marshal')) return 'transformer';
|
||||
if (lower.includes('route') || lower.includes('choice')) return 'router';
|
||||
return 'processor';
|
||||
}
|
||||
|
||||
// ── Processor table columns ──────────────────────────────────────────────────
|
||||
|
||||
function makeProcessorColumns(css: typeof styles): Column<ProcessorRow>[] {
|
||||
return [
|
||||
{
|
||||
key: 'processorId',
|
||||
header: 'Processor',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<span className={css.routeNameCell}>{row.processorId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'callCount',
|
||||
header: 'Invocations',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.callCount.toLocaleString()}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'avgDurationMs',
|
||||
header: 'Avg Duration',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.avgDurationMs > 200 ? css.rateBad : row.avgDurationMs > 100 ? css.rateWarn : css.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{Math.round(row.avgDurationMs)}ms</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'p99DurationMs',
|
||||
header: 'p99 Duration',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.p99DurationMs > 300 ? css.rateBad : row.p99DurationMs > 200 ? css.rateWarn : css.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'errorCount',
|
||||
header: 'Errors',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm" className={row.errorCount > 10 ? css.rateBad : css.rateNeutral}>
|
||||
{row.errorCount}
|
||||
</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'errorRate',
|
||||
header: 'Error Rate',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.errorRate > 1 ? css.rateBad : row.errorRate > 0.5 ? css.rateWarn : css.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{row.errorRate.toFixed(2)}%</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sparkline',
|
||||
header: 'Trend',
|
||||
render: (_, row) => (
|
||||
<Sparkline data={row.sparkline} width={80} height={24} />
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Exchange table columns ───────────────────────────────────────────────────
|
||||
|
||||
const EXCHANGE_COLUMNS: Column<ExchangeRow>[] = [
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: '80px',
|
||||
render: (_, row) => (
|
||||
<StatusDot variant={row.status === 'COMPLETED' ? 'success' : row.status === 'FAILED' ? 'error' : 'running'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'executionId',
|
||||
header: 'Exchange ID',
|
||||
render: (_, row) => <MonoText size="xs">{row.executionId.slice(0, 12)}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'startTime',
|
||||
header: 'Started',
|
||||
sortable: true,
|
||||
render: (_, row) => new Date(row.startTime).toLocaleTimeString(),
|
||||
},
|
||||
{
|
||||
key: 'durationMs',
|
||||
header: 'Duration',
|
||||
sortable: true,
|
||||
render: (_, row) => `${row.durationMs}ms`,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Build KPI items ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildDetailKpiItems(
|
||||
stats: {
|
||||
totalCount: number;
|
||||
failedCount: number;
|
||||
avgDurationMs: number;
|
||||
p99LatencyMs: number;
|
||||
activeCount: number;
|
||||
prevTotalCount: number;
|
||||
prevFailedCount: number;
|
||||
prevP99LatencyMs: number;
|
||||
} | undefined,
|
||||
throughputSparkline: number[],
|
||||
errorSparkline: number[],
|
||||
latencySparkline: number[],
|
||||
): KpiItem[] {
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const failedCount = stats?.failedCount ?? 0;
|
||||
const prevTotalCount = stats?.prevTotalCount ?? 0;
|
||||
const p99Ms = stats?.p99LatencyMs ?? 0;
|
||||
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
|
||||
const avgMs = stats?.avgDurationMs ?? 0;
|
||||
const activeCount = stats?.activeCount ?? 0;
|
||||
|
||||
const errorRate = totalCount > 0 ? (failedCount / totalCount) * 100 : 0;
|
||||
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
|
||||
|
||||
const throughputPctChange = prevTotalCount > 0
|
||||
? Math.round(((totalCount - prevTotalCount) / prevTotalCount) * 100)
|
||||
: 0;
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Total Throughput',
|
||||
value: totalCount.toLocaleString(),
|
||||
trend: {
|
||||
label: throughputPctChange >= 0 ? `\u25B2 +${throughputPctChange}%` : `\u25BC ${throughputPctChange}%`,
|
||||
variant: throughputPctChange >= 0 ? 'success' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `${activeCount} in-flight`,
|
||||
sparkline: throughputSparkline,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'System Error Rate',
|
||||
value: `${errorRate.toFixed(2)}%`,
|
||||
trend: {
|
||||
label: errorRate < 1 ? '\u25BC low' : `\u25B2 ${errorRate.toFixed(1)}%`,
|
||||
variant: errorRate < 1 ? 'success' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `${failedCount} errors / ${totalCount.toLocaleString()} total`,
|
||||
sparkline: errorSparkline,
|
||||
borderColor: errorRate < 1 ? 'var(--success)' : 'var(--error)',
|
||||
},
|
||||
{
|
||||
label: 'Latency P99',
|
||||
value: `${p99Ms}ms`,
|
||||
trend: {
|
||||
label: p99Ms > prevP99Ms ? `\u25B2 +${p99Ms - prevP99Ms}ms` : `\u25BC ${prevP99Ms - p99Ms}ms`,
|
||||
variant: p99Ms > 300 ? 'error' as const : 'warning' as const,
|
||||
},
|
||||
subtitle: `Avg ${avgMs}ms \u00B7 SLA <300ms`,
|
||||
sparkline: latencySparkline,
|
||||
borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)',
|
||||
},
|
||||
{
|
||||
label: 'Success Rate',
|
||||
value: `${successRate.toFixed(1)}%`,
|
||||
trend: { label: '\u2194', variant: 'muted' as const },
|
||||
subtitle: `${totalCount - failedCount} ok / ${failedCount} failed`,
|
||||
borderColor: 'var(--success)',
|
||||
},
|
||||
{
|
||||
label: 'In-Flight',
|
||||
value: String(activeCount),
|
||||
trend: { label: '\u2194', variant: 'muted' as const },
|
||||
subtitle: `${activeCount} active exchanges`,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function RouteDetail() {
|
||||
const { appId, routeId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -43,9 +261,11 @@ export default function RouteDetail() {
|
||||
|
||||
const [activeTab, setActiveTab] = useState('performance');
|
||||
|
||||
// ── API queries ────────────────────────────────────────────────────────────
|
||||
const { data: catalog } = useRouteCatalog();
|
||||
const { data: diagram } = useDiagramByRoute(appId, routeId);
|
||||
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId);
|
||||
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
||||
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
|
||||
timeFrom,
|
||||
@@ -65,6 +285,8 @@ export default function RouteDetail() {
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
// ── Derived data ───────────────────────────────────────────────────────────
|
||||
|
||||
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
|
||||
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
|
||||
[catalog, appId],
|
||||
@@ -79,7 +301,7 @@ export default function RouteDetail() {
|
||||
const exchangeCount = routeSummary?.exchangeCount ?? 0;
|
||||
const lastSeen = routeSummary?.lastSeen
|
||||
? new Date(routeSummary.lastSeen).toLocaleString()
|
||||
: '—';
|
||||
: '\u2014';
|
||||
|
||||
const healthVariant = useMemo((): 'success' | 'warning' | 'error' | 'dead' => {
|
||||
const h = health.toLowerCase();
|
||||
@@ -89,39 +311,70 @@ export default function RouteDetail() {
|
||||
return 'dead';
|
||||
}, [health]);
|
||||
|
||||
// Route flow from diagram
|
||||
const diagramNodes = useMemo(() => {
|
||||
if (!diagram?.nodes) return [];
|
||||
return mapDiagramToRouteNodes(diagram.nodes, []);
|
||||
}, [diagram]);
|
||||
|
||||
// Processor table rows
|
||||
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 || []).map((p: any) => {
|
||||
const callCount = p.callCount ?? 0;
|
||||
const errorCount = p.errorCount ?? 0;
|
||||
const errRate = callCount > 0 ? (errorCount / callCount) * 100 : 0;
|
||||
return {
|
||||
id: p.processorId,
|
||||
processorId: p.processorId,
|
||||
type: classifyProcessorType(p.processorId ?? ''),
|
||||
callCount,
|
||||
avgDurationMs: p.avgDurationMs ?? 0,
|
||||
p99DurationMs: p.p99DurationMs ?? 0,
|
||||
errorCount,
|
||||
errorRate: Number(errRate.toFixed(2)),
|
||||
sparkline: p.sparkline ?? [],
|
||||
};
|
||||
}),
|
||||
[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-derived data
|
||||
const throughputSparkline = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => b.totalCount),
|
||||
[timeseries],
|
||||
);
|
||||
const errorSparkline = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => b.failedCount),
|
||||
[timeseries],
|
||||
);
|
||||
const latencySparkline = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => b.p99DurationMs),
|
||||
[timeseries],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => {
|
||||
const ts = new Date(b.time);
|
||||
return {
|
||||
time: !isNaN(ts.getTime())
|
||||
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: '\u2014',
|
||||
throughput: b.totalCount,
|
||||
latency: b.avgDurationMs,
|
||||
errors: b.failedCount,
|
||||
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
||||
};
|
||||
}),
|
||||
[timeseries],
|
||||
);
|
||||
|
||||
// Exchange rows
|
||||
const exchangeRows: ExchangeRow[] = useMemo(() =>
|
||||
(recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
||||
[recentResult],
|
||||
);
|
||||
|
||||
// Error patterns
|
||||
const errorPatterns: ErrorPattern[] = useMemo(() => {
|
||||
const failed = (errorResult?.data || []) as ExecutionSummary[];
|
||||
const grouped = new Map<string, { count: number; lastSeen: string }>();
|
||||
@@ -141,31 +394,18 @@ export default function RouteDetail() {
|
||||
.map(([message, { count, lastSeen: ls }]) => ({
|
||||
message,
|
||||
count,
|
||||
lastSeen: ls ? new Date(ls).toLocaleString() : '—',
|
||||
lastSeen: ls ? new Date(ls).toLocaleString() : '\u2014',
|
||||
}))
|
||||
.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>;
|
||||
}},
|
||||
];
|
||||
// KPI items
|
||||
const kpiItems = useMemo(() =>
|
||||
buildDetailKpiItems(stats, throughputSparkline, errorSparkline, latencySparkline),
|
||||
[stats, throughputSparkline, errorSparkline, latencySparkline],
|
||||
);
|
||||
|
||||
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 processorColumns = useMemo(() => makeProcessorColumns(styles), []);
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Performance', value: 'performance' },
|
||||
@@ -173,12 +413,15 @@ export default function RouteDetail() {
|
||||
{ label: 'Error Patterns', value: 'errors', count: errorPatterns.length },
|
||||
];
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link to={`/routes/${appId}`} className={styles.backLink}>
|
||||
← {appId} routes
|
||||
← {appId} routes
|
||||
</Link>
|
||||
|
||||
{/* Route header card */}
|
||||
<div className={styles.headerCard}>
|
||||
<div className={styles.headerRow}>
|
||||
<div className={styles.headerLeft}>
|
||||
@@ -199,13 +442,17 @@ export default function RouteDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI strip */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
{/* Diagram + Processor Stats grid */}
|
||||
<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' }}>
|
||||
<div className={styles.emptyText}>
|
||||
No diagram available for this route.
|
||||
</div>
|
||||
)}
|
||||
@@ -217,13 +464,40 @@ export default function RouteDetail() {
|
||||
) : processorRows.length > 0 ? (
|
||||
<DataTable columns={processorColumns} data={processorRows} sortable pageSize={10} />
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
|
||||
<div className={styles.emptyText}>
|
||||
No processor data available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processor Performance table (full width) */}
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Processor Performance</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>{processorRows.length} processors</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={processorColumns}
|
||||
data={processorRows}
|
||||
sortable
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Route Flow section */}
|
||||
{diagramNodes.length > 0 && (
|
||||
<div className={styles.routeFlowSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Route Flow</span>
|
||||
</div>
|
||||
<RouteFlow nodes={diagramNodes} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabbed section: Performance charts, Recent Executions, Error Patterns */}
|
||||
<div className={styles.tabSection}>
|
||||
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
@@ -232,28 +506,41 @@ export default function RouteDetail() {
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Throughput</div>
|
||||
<AreaChart
|
||||
series={[{ label: 'Throughput', data: chartData.map((d, i) => ({ x: i, y: d.throughput })) }]}
|
||||
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 })) }]}
|
||||
series={[{
|
||||
label: 'Latency',
|
||||
data: chartData.map((d, i) => ({ x: i, y: d.latency })),
|
||||
}]}
|
||||
height={200}
|
||||
threshold={{ value: 300, label: 'SLA 300ms' }}
|
||||
/>
|
||||
</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 })) }]}
|
||||
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 })) }]}
|
||||
series={[{
|
||||
label: 'Success Rate',
|
||||
data: chartData.map((d, i) => ({ x: i, y: d.successRate })),
|
||||
}]}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
@@ -268,7 +555,7 @@ export default function RouteDetail() {
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={exchangeColumns}
|
||||
columns={EXCHANGE_COLUMNS}
|
||||
data={exchangeRows}
|
||||
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
|
||||
sortable
|
||||
@@ -281,7 +568,7 @@ export default function RouteDetail() {
|
||||
{activeTab === 'errors' && (
|
||||
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
|
||||
{errorPatterns.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
|
||||
<div className={styles.emptyText}>
|
||||
No error patterns found in the selected time range.
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
.statStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
/* Scrollable content area */
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.refreshIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.refreshDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 4px rgba(61, 124, 71, 0.5);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.refreshText {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Route performance table */
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
@@ -28,36 +55,56 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tableRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tableMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Route name in table */
|
||||
.routeNameCell {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Application column */
|
||||
.appCell {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Rate color classes */
|
||||
.rateGood {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.rateWarn {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.rateBad {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.rateNeutral {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 2x2 chart grid */
|
||||
.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;
|
||||
.chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rateGood { color: var(--success); }
|
||||
.rateWarn { color: var(--warning); }
|
||||
.rateBad { color: var(--error); }
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import {
|
||||
StatCard, Sparkline, MonoText, Badge,
|
||||
DataTable, AreaChart, LineChart, BarChart,
|
||||
KpiStrip,
|
||||
DataTable,
|
||||
AreaChart,
|
||||
LineChart,
|
||||
BarChart,
|
||||
Card,
|
||||
Sparkline,
|
||||
MonoText,
|
||||
Badge,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import type { KpiItem, Column } from '@cameleer/design-system';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import { useRouteMetrics } from '../../api/queries/catalog';
|
||||
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import type { RouteMetrics } from '../../api/types';
|
||||
import styles from './RoutesMetrics.module.css';
|
||||
|
||||
interface RouteRow {
|
||||
@@ -23,186 +31,322 @@ interface RouteRow {
|
||||
sparkline: number[];
|
||||
}
|
||||
|
||||
// ── Route table columns ──────────────────────────────────────────────────────
|
||||
|
||||
const ROUTE_COLUMNS: Column<RouteRow>[] = [
|
||||
{
|
||||
key: 'routeId',
|
||||
header: 'Route',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<span className={styles.routeNameCell}>{row.routeId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'appId',
|
||||
header: 'Application',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<span className={styles.appCell}>{row.appId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'exchangeCount',
|
||||
header: 'Exchanges',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.exchangeCount.toLocaleString()}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'successRate',
|
||||
header: 'Success %',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const pct = row.successRate * 100;
|
||||
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad;
|
||||
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'avgDurationMs',
|
||||
header: 'Avg Duration',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{Math.round(row.avgDurationMs)}ms</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'p99DurationMs',
|
||||
header: 'p99 Duration',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'errorRate',
|
||||
header: 'Error Rate',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const pct = row.errorRate * 100;
|
||||
const cls = pct > 5 ? styles.rateBad : pct > 1 ? styles.rateWarn : styles.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sparkline',
|
||||
header: 'Trend',
|
||||
render: (_, row) => (
|
||||
<Sparkline data={row.sparkline} width={80} height={24} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Build KPI items from backend stats ───────────────────────────────────────
|
||||
|
||||
function buildKpiItems(
|
||||
stats: {
|
||||
totalCount: number;
|
||||
failedCount: number;
|
||||
avgDurationMs: number;
|
||||
p99LatencyMs: number;
|
||||
activeCount: number;
|
||||
prevTotalCount: number;
|
||||
prevFailedCount: number;
|
||||
prevP99LatencyMs: number;
|
||||
} | undefined,
|
||||
routeCount: number,
|
||||
throughputSparkline: number[],
|
||||
errorSparkline: number[],
|
||||
): KpiItem[] {
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const failedCount = stats?.failedCount ?? 0;
|
||||
const prevTotalCount = stats?.prevTotalCount ?? 0;
|
||||
const p99Ms = stats?.p99LatencyMs ?? 0;
|
||||
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
|
||||
const avgMs = stats?.avgDurationMs ?? 0;
|
||||
const activeCount = stats?.activeCount ?? 0;
|
||||
|
||||
const errorRate = totalCount > 0 ? (failedCount / totalCount) * 100 : 0;
|
||||
|
||||
const throughputPctChange = prevTotalCount > 0
|
||||
? Math.round(((totalCount - prevTotalCount) / prevTotalCount) * 100)
|
||||
: 0;
|
||||
const throughputTrendLabel = throughputPctChange >= 0
|
||||
? `\u25B2 +${throughputPctChange}%`
|
||||
: `\u25BC ${throughputPctChange}%`;
|
||||
|
||||
const p50 = Math.round(avgMs * 0.5);
|
||||
const p95 = Math.round(avgMs * 1.4);
|
||||
const slaStatus = p99Ms > 300 ? 'BREACH' : 'OK';
|
||||
|
||||
const prevErrorRate = prevTotalCount > 0
|
||||
? ((stats?.prevFailedCount ?? 0) / prevTotalCount) * 100
|
||||
: 0;
|
||||
const errorDelta = (errorRate - prevErrorRate).toFixed(1);
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Total Throughput',
|
||||
value: totalCount.toLocaleString(),
|
||||
trend: {
|
||||
label: throughputTrendLabel,
|
||||
variant: throughputPctChange >= 0 ? 'success' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `${activeCount} active exchanges`,
|
||||
sparkline: throughputSparkline,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'System Error Rate',
|
||||
value: `${errorRate.toFixed(2)}%`,
|
||||
trend: {
|
||||
label: errorRate <= prevErrorRate ? `\u25BC ${errorDelta}%` : `\u25B2 +${errorDelta}%`,
|
||||
variant: errorRate < 1 ? 'success' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `${failedCount} errors / ${totalCount.toLocaleString()} total`,
|
||||
sparkline: errorSparkline,
|
||||
borderColor: errorRate < 1 ? 'var(--success)' : 'var(--error)',
|
||||
},
|
||||
{
|
||||
label: 'Latency Percentiles',
|
||||
value: `${p99Ms}ms`,
|
||||
trend: {
|
||||
label: p99Ms > prevP99Ms ? `\u25B2 +${p99Ms - prevP99Ms}ms` : `\u25BC ${prevP99Ms - p99Ms}ms`,
|
||||
variant: p99Ms > 300 ? 'error' as const : 'warning' as const,
|
||||
},
|
||||
subtitle: `P50 ${p50}ms \u00B7 P95 ${p95}ms \u00B7 SLA <300ms P99: ${slaStatus}`,
|
||||
borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)',
|
||||
},
|
||||
{
|
||||
label: 'Active Routes',
|
||||
value: `${routeCount}`,
|
||||
trend: { label: '\u2194 stable', variant: 'muted' as const },
|
||||
subtitle: `${routeCount} routes reporting`,
|
||||
borderColor: 'var(--running)',
|
||||
},
|
||||
{
|
||||
label: 'In-Flight Exchanges',
|
||||
value: String(activeCount),
|
||||
trend: { label: '\u2194', variant: 'muted' as const },
|
||||
subtitle: `${activeCount} active`,
|
||||
sparkline: throughputSparkline,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function RoutesMetrics() {
|
||||
const { appId, routeId } = useParams();
|
||||
const { appId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { timeRange } = useGlobalFilters();
|
||||
const timeFrom = timeRange.start.toISOString();
|
||||
const timeTo = timeRange.end.toISOString();
|
||||
|
||||
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
|
||||
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
||||
const { data: stats } = useExecutionStats(timeFrom, timeTo, undefined, appId);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
||||
|
||||
// Map backend RouteMetrics[] to table rows
|
||||
const rows: RouteRow[] = useMemo(() =>
|
||||
(metrics || []).map((m: any) => ({
|
||||
(metrics || []).map((m: RouteMetrics) => ({
|
||||
id: `${m.appId}/${m.routeId}`,
|
||||
...m,
|
||||
routeId: m.routeId,
|
||||
appId: m.appId,
|
||||
exchangeCount: m.exchangeCount,
|
||||
successRate: m.successRate,
|
||||
avgDurationMs: m.avgDurationMs,
|
||||
p99DurationMs: m.p99DurationMs,
|
||||
errorRate: m.errorRate,
|
||||
throughputPerSec: m.throughputPerSec,
|
||||
sparkline: m.sparkline ?? [],
|
||||
})),
|
||||
[metrics],
|
||||
);
|
||||
|
||||
const sparklineData = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
|
||||
// Sparkline data from timeseries buckets
|
||||
const throughputSparkline = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => b.totalCount),
|
||||
[timeseries],
|
||||
);
|
||||
const errorSparkline = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => b.failedCount),
|
||||
[timeseries],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any, i: number) => {
|
||||
const ts = b.timestamp ? new Date(b.timestamp) : null;
|
||||
const time = ts && !isNaN(ts.getTime())
|
||||
// Chart series from timeseries buckets
|
||||
const throughputChartSeries = useMemo(() => [{
|
||||
label: 'Throughput',
|
||||
data: (timeseries?.buckets || []).map((b, i) => ({
|
||||
x: i as number,
|
||||
y: b.totalCount,
|
||||
})),
|
||||
}], [timeseries]);
|
||||
|
||||
const latencyChartSeries = useMemo(() => [{
|
||||
label: 'Latency',
|
||||
data: (timeseries?.buckets || []).map((b, i) => ({
|
||||
x: i as number,
|
||||
y: b.avgDurationMs,
|
||||
})),
|
||||
}], [timeseries]);
|
||||
|
||||
const errorBarSeries = useMemo(() => [{
|
||||
label: 'Errors',
|
||||
data: (timeseries?.buckets || []).map((b) => {
|
||||
const ts = new Date(b.time);
|
||||
const label = !isNaN(ts.getTime())
|
||||
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: String(i);
|
||||
return {
|
||||
time,
|
||||
throughput: b.totalCount ?? 0,
|
||||
latency: b.avgDurationMs ?? 0,
|
||||
errors: b.failedCount ?? 0,
|
||||
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
||||
};
|
||||
: '—';
|
||||
return { x: label, y: b.failedCount };
|
||||
}),
|
||||
[timeseries],
|
||||
}], [timeseries]);
|
||||
|
||||
const volumeChartSeries = useMemo(() => [{
|
||||
label: 'Volume',
|
||||
data: (timeseries?.buckets || []).map((b, i) => ({
|
||||
x: i as number,
|
||||
y: b.totalCount,
|
||||
})),
|
||||
}], [timeseries]);
|
||||
|
||||
const kpiItems = useMemo(() =>
|
||||
buildKpiItems(stats, rows.length, throughputSparkline, errorSparkline),
|
||||
[stats, rows.length, throughputSparkline, errorSparkline],
|
||||
);
|
||||
|
||||
const columns: Column<RouteRow>[] = [
|
||||
{ key: 'routeId', header: 'Route', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
||||
{ key: 'appId', header: 'App', render: (v) => <Badge label={String(v)} color="auto" /> },
|
||||
{ key: 'exchangeCount', header: 'Exchanges', sortable: true },
|
||||
{
|
||||
key: 'successRate', header: 'Success', sortable: true,
|
||||
render: (v) => `${((v as number) * 100).toFixed(1)}%`,
|
||||
},
|
||||
{ key: 'avgDurationMs', header: 'Avg Duration', sortable: true, render: (v) => `${(v as number).toFixed(0)}ms` },
|
||||
{ key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(0)}ms` },
|
||||
{
|
||||
key: 'errorRate', header: 'Error Rate', sortable: true,
|
||||
render: (v) => {
|
||||
const rate = v as number;
|
||||
const cls = rate > 0.05 ? styles.rateBad : rate > 0.01 ? styles.rateWarn : styles.rateGood;
|
||||
return <span className={cls}>{(rate * 100).toFixed(1)}%</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sparkline', header: 'Trend', width: '80px',
|
||||
render: (v) => <Sparkline data={v as number[]} />,
|
||||
},
|
||||
];
|
||||
|
||||
const errorRate = stats?.totalCount
|
||||
? (((stats.failedCount ?? 0) / stats.totalCount) * 100)
|
||||
: 0;
|
||||
const prevErrorRate = stats?.prevTotalCount
|
||||
? (((stats.prevFailedCount ?? 0) / stats.prevTotalCount) * 100)
|
||||
: 0;
|
||||
const errorTrend: 'up' | 'down' | 'neutral' = errorRate > prevErrorRate ? 'up' : errorRate < prevErrorRate ? 'down' : 'neutral';
|
||||
const errorTrendValue = stats?.prevTotalCount
|
||||
? `${Math.abs(errorRate - prevErrorRate).toFixed(2)}%`
|
||||
: undefined;
|
||||
|
||||
const p99Ms = stats?.p99LatencyMs ?? 0;
|
||||
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
|
||||
const latencyTrend: 'up' | 'down' | 'neutral' = p99Ms > prevP99Ms ? 'up' : p99Ms < prevP99Ms ? 'down' : 'neutral';
|
||||
const latencyTrendValue = prevP99Ms ? `${Math.abs(p99Ms - prevP99Ms)}ms` : undefined;
|
||||
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const prevTotalCount = stats?.prevTotalCount ?? 0;
|
||||
const throughputTrend: 'up' | 'down' | 'neutral' = totalCount > prevTotalCount ? 'up' : totalCount < prevTotalCount ? 'down' : 'neutral';
|
||||
const throughputTrendValue = prevTotalCount
|
||||
? `${Math.abs(((totalCount - prevTotalCount) / prevTotalCount) * 100).toFixed(0)}%`
|
||||
: undefined;
|
||||
|
||||
const successRate = stats?.totalCount
|
||||
? (((stats.totalCount - (stats.failedCount ?? 0)) / stats.totalCount) * 100)
|
||||
: 100;
|
||||
|
||||
const activeCount = stats?.activeCount ?? 0;
|
||||
|
||||
const errorSparkline = (timeseries?.buckets || []).map((b: any) => b.failedCount as number);
|
||||
const latencySparkline = (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard
|
||||
label="Total Throughput"
|
||||
value={totalCount.toLocaleString()}
|
||||
detail="exchanges"
|
||||
trend={throughputTrend}
|
||||
trendValue={throughputTrendValue}
|
||||
accent="amber"
|
||||
sparkline={sparklineData}
|
||||
/>
|
||||
<StatCard
|
||||
label="System Error Rate"
|
||||
value={`${errorRate.toFixed(2)}%`}
|
||||
detail={`${stats?.failedCount ?? 0} errors / ${totalCount.toLocaleString()} total`}
|
||||
trend={errorTrend}
|
||||
trendValue={errorTrendValue}
|
||||
accent={errorRate < 1 ? 'success' : 'error'}
|
||||
sparkline={errorSparkline}
|
||||
/>
|
||||
<StatCard
|
||||
label="P99 Latency"
|
||||
value={`${p99Ms}ms`}
|
||||
detail={`Avg: ${stats?.avgDurationMs ?? 0}ms`}
|
||||
trend={latencyTrend}
|
||||
trendValue={latencyTrendValue}
|
||||
accent={p99Ms > 300 ? 'error' : p99Ms > 200 ? 'warning' : 'success'}
|
||||
sparkline={latencySparkline}
|
||||
/>
|
||||
<StatCard
|
||||
label="Success Rate"
|
||||
value={`${successRate.toFixed(1)}%`}
|
||||
detail={`${activeCount} active routes`}
|
||||
accent="success"
|
||||
sparkline={sparklineData.map((v, i) => {
|
||||
const failed = errorSparkline[i] ?? 0;
|
||||
return v > 0 ? ((v - failed) / v) * 100 : 100;
|
||||
})}
|
||||
/>
|
||||
<StatCard
|
||||
label="In-Flight"
|
||||
value={activeCount}
|
||||
detail="active exchanges"
|
||||
accent="amber"
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.refreshIndicator}>
|
||||
<span className={styles.refreshDot} />
|
||||
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
||||
</div>
|
||||
|
||||
{/* KPI header cards */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
{/* Per-route performance table */}
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Per-Route Performance</span>
|
||||
<span className={styles.tableMeta}>{rows.length} routes</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>{rows.length} routes</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
columns={ROUTE_COLUMNS}
|
||||
data={rows}
|
||||
sortable
|
||||
pageSize={20}
|
||||
onRowClick={(row) => {
|
||||
const targetAppId = appId ?? row.appId;
|
||||
navigate(`/routes/${targetAppId}/${row.routeId}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{chartData.length > 0 && (
|
||||
{/* 2x2 chart grid */}
|
||||
{(timeseries?.buckets?.length ?? 0) > 0 && (
|
||||
<div className={styles.chartGrid}>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/s" height={200} />
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Latency (ms)</div>
|
||||
<LineChart
|
||||
series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]}
|
||||
yLabel="ms"
|
||||
<Card title="Throughput (msg/s)">
|
||||
<AreaChart
|
||||
series={throughputChartSeries}
|
||||
yLabel="msg/s"
|
||||
height={200}
|
||||
threshold={{ value: 300, label: 'SLA 300ms' }}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Errors by Route</div>
|
||||
<BarChart series={[{ label: 'Errors', data: chartData.map((d: any) => ({ x: d.time as string, y: d.errors })) }]} height={200} />
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
|
||||
<AreaChart series={[{ label: 'Volume', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/min" height={200} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Latency (ms)">
|
||||
<LineChart
|
||||
series={latencyChartSeries}
|
||||
yLabel="ms"
|
||||
threshold={{ value: 300, label: 'SLA 300ms' }}
|
||||
height={200}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Errors by Route">
|
||||
<BarChart
|
||||
series={errorBarSeries}
|
||||
height={200}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Message Volume (msg/min)">
|
||||
<AreaChart
|
||||
series={volumeChartSeries}
|
||||
yLabel="msg/min"
|
||||
height={200}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user