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 { useAuthStore } from './auth-store';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||||
|
import styles from './LoginPage.module.css';
|
||||||
|
|
||||||
interface OidcInfo {
|
interface OidcInfo {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -50,53 +51,75 @@ export function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'var(--surface-ground)' }}>
|
<div className={styles.page}>
|
||||||
<Card>
|
<Card className={styles.card}>
|
||||||
<form onSubmit={handleSubmit} style={{ padding: '2rem', minWidth: 360 }}>
|
<div className={styles.loginForm}>
|
||||||
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
|
<div className={styles.logo}>cameleer3</div>
|
||||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>cameleer3</h1>
|
<p className={styles.subtitle}>Sign in to access the observability dashboard</p>
|
||||||
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem', fontSize: '0.875rem' }}>
|
|
||||||
Sign in to access the observability dashboard
|
{error && (
|
||||||
</p>
|
<div className={styles.error}>
|
||||||
|
<Alert variant="error">{error}</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{oidc && (
|
{oidc && (
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={handleOidcLogin} disabled={oidcLoading} style={{ width: '100%', marginBottom: '1rem' }}>
|
<div className={styles.socialSection}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className={styles.ssoButton}
|
||||||
|
onClick={handleOidcLogin}
|
||||||
|
disabled={oidcLoading}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
|
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
|
||||||
</Button>
|
</Button>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', margin: '1rem 0' }}>
|
</div>
|
||||||
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--border)' }} />
|
<div className={styles.divider}>
|
||||||
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>or</span>
|
<div className={styles.dividerLine} />
|
||||||
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--border)' }} />
|
<span className={styles.dividerText}>or</span>
|
||||||
|
<div className={styles.dividerLine} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField label="Username">
|
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
||||||
|
<FormField label="Username" htmlFor="login-username">
|
||||||
<Input
|
<Input
|
||||||
|
id="login-username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter your username"
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Password">
|
<FormField label="Password" htmlFor="login-password">
|
||||||
<Input
|
<Input
|
||||||
|
id="login-password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<Button variant="primary" disabled={loading || !username || !password} style={{ width: '100%', marginTop: '0.5rem' }}>
|
<Button
|
||||||
{loading ? 'Signing in...' : 'Sign In'}
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || !username || !password}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{error && <div style={{ marginTop: '1rem' }}><Alert variant="error">{error}</Alert></div>}
|
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,72 @@
|
|||||||
import { Outlet, useNavigate, useLocation } from 'react-router';
|
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||||
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, useCommandPalette } from '@cameleer/design-system';
|
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 { useRouteCatalog } from '../api/queries/catalog';
|
||||||
|
import { useAgents } from '../api/queries/agents';
|
||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
import { useMemo, useCallback } from 'react';
|
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() {
|
function LayoutContent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { data: catalog } = useRouteCatalog();
|
const { data: catalog } = useRouteCatalog();
|
||||||
const { username, roles, logout } = useAuthStore();
|
const { data: agents } = useAgents();
|
||||||
|
const { username, logout } = useAuthStore();
|
||||||
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
||||||
|
|
||||||
const sidebarApps: SidebarApp[] = useMemo(() => {
|
const sidebarApps: SidebarApp[] = useMemo(() => {
|
||||||
@@ -33,6 +90,11 @@ function LayoutContent() {
|
|||||||
}));
|
}));
|
||||||
}, [catalog]);
|
}, [catalog]);
|
||||||
|
|
||||||
|
const searchData = useMemo(
|
||||||
|
() => buildSearchData(catalog, agents as any[]),
|
||||||
|
[catalog, agents],
|
||||||
|
);
|
||||||
|
|
||||||
const breadcrumb = useMemo(() => {
|
const breadcrumb = useMemo(() => {
|
||||||
const parts = location.pathname.split('/').filter(Boolean);
|
const parts = location.pathname.split('/').filter(Boolean);
|
||||||
return parts.map((part, i) => ({
|
return parts.map((part, i) => ({
|
||||||
@@ -47,12 +109,12 @@ function LayoutContent() {
|
|||||||
}, [logout, navigate]);
|
}, [logout, navigate]);
|
||||||
|
|
||||||
const handlePaletteSelect = useCallback((result: any) => {
|
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);
|
setPaletteOpen(false);
|
||||||
}, [navigate, setPaletteOpen]);
|
}, [navigate, setPaletteOpen]);
|
||||||
|
|
||||||
const isAdmin = roles.includes('ADMIN');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
sidebar={
|
sidebar={
|
||||||
@@ -70,7 +132,7 @@ function LayoutContent() {
|
|||||||
open={paletteOpen}
|
open={paletteOpen}
|
||||||
onClose={() => setPaletteOpen(false)}
|
onClose={() => setPaletteOpen(false)}
|
||||||
onSelect={handlePaletteSelect}
|
onSelect={handlePaletteSelect}
|
||||||
data={[]}
|
data={searchData}
|
||||||
/>
|
/>
|
||||||
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export default function AdminLayout() {
|
|||||||
active={location.pathname}
|
active={location.pathname}
|
||||||
onChange={(path) => navigate(path)}
|
onChange={(path) => navigate(path)}
|
||||||
/>
|
/>
|
||||||
|
<div style={{ padding: '20px 24px 40px' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</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 { 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 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() {
|
export default function AuditLogPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [dateRange, setDateRange] = useState({
|
||||||
const [category, setCategory] = 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 [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>[] = [
|
const rows: AuditRow[] = useMemo(
|
||||||
{ key: 'timestamp', header: 'Time', sortable: true, render: (v) => new Date(v as string).toLocaleString() },
|
() => (data?.items || []).map((item) => ({ ...item, id: String(item.id) })),
|
||||||
{ 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) })),
|
|
||||||
[data],
|
[data],
|
||||||
);
|
);
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginBottom: '1rem' }}>Audit Log</h2>
|
<div className={styles.filters}>
|
||||||
|
<DateRangePicker
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
|
value={dateRange}
|
||||||
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
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
|
<Select
|
||||||
options={[
|
options={CATEGORIES}
|
||||||
{ value: '', label: 'All Categories' },
|
value={categoryFilter}
|
||||||
{ value: 'AUTH', label: 'Auth' },
|
onChange={(e) => { setCategoryFilter(e.target.value); setPage(0); }}
|
||||||
{ value: 'CONFIG', label: 'Config' },
|
className={styles.filterSelect}
|
||||||
{ value: 'RBAC', label: 'RBAC' },
|
/>
|
||||||
{ value: 'INFRA', label: 'Infra' },
|
<Input
|
||||||
]}
|
placeholder="Search action or target..."
|
||||||
value={category}
|
value={searchFilter}
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
onChange={(e) => { setSearchFilter(e.target.value); setPage(0); }}
|
||||||
|
onClear={() => { setSearchFilter(''); setPage(0); }}
|
||||||
|
className={styles.filterInput}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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
|
<DataTable
|
||||||
columns={columns}
|
columns={COLUMNS}
|
||||||
data={rows}
|
data={rows}
|
||||||
sortable
|
sortable
|
||||||
|
flush
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
|
||||||
expandedContent={(row) => (
|
expandedContent={(row) => (
|
||||||
<div style={{ padding: '0.75rem' }}>
|
<div className={styles.expandedDetail}>
|
||||||
<CodeBlock content={JSON.stringify(row.detail, null, 2)} />
|
<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>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
MonoText,
|
|
||||||
Tag,
|
|
||||||
Select,
|
Select,
|
||||||
ConfirmDialog,
|
MonoText,
|
||||||
Spinner,
|
SectionHeader,
|
||||||
|
Tag,
|
||||||
InlineEdit,
|
InlineEdit,
|
||||||
|
MultiSelect,
|
||||||
|
ConfirmDialog,
|
||||||
|
AlertDialog,
|
||||||
|
SplitPane,
|
||||||
|
EntityList,
|
||||||
|
Spinner,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import {
|
import {
|
||||||
@@ -25,26 +30,31 @@ import {
|
|||||||
useUsers,
|
useUsers,
|
||||||
useRoles,
|
useRoles,
|
||||||
} from '../../api/queries/admin/rbac';
|
} from '../../api/queries/admin/rbac';
|
||||||
|
import type { GroupDetail } from '../../api/queries/admin/rbac';
|
||||||
import styles from './UserManagement.module.css';
|
import styles from './UserManagement.module.css';
|
||||||
|
|
||||||
const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010';
|
const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010';
|
||||||
|
|
||||||
export default function GroupsTab() {
|
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 { toast } = useToast();
|
||||||
const { data: groups = [], isLoading: groupsLoading } = useGroups();
|
const { data: groups = [], isLoading: groupsLoading } = useGroups();
|
||||||
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedGroupId);
|
|
||||||
const { data: users = [] } = useUsers();
|
const { data: users = [] } = useUsers();
|
||||||
const { data: roles = [] } = useRoles();
|
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 createGroup = useCreateGroup();
|
||||||
const updateGroup = useUpdateGroup();
|
const updateGroup = useUpdateGroup();
|
||||||
const deleteGroup = useDeleteGroup();
|
const deleteGroup = useDeleteGroup();
|
||||||
@@ -53,173 +63,194 @@ export default function GroupsTab() {
|
|||||||
const addUserToGroup = useAddUserToGroup();
|
const addUserToGroup = useAddUserToGroup();
|
||||||
const removeUserFromGroup = useRemoveUserFromGroup();
|
const removeUserFromGroup = useRemoveUserFromGroup();
|
||||||
|
|
||||||
const filteredGroups = groups.filter((g) =>
|
const filtered = useMemo(() => {
|
||||||
g.name.toLowerCase().includes(search.toLowerCase())
|
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 = [
|
const parentOptions = [
|
||||||
{ value: '', label: 'Top-level' },
|
{ 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';
|
if (!parentGroupId) return 'Top-level';
|
||||||
const parent = groups.find((g) => g.id === parentGroupId);
|
const parent = groups.find((g) => g.id === parentGroupId);
|
||||||
return parent ? parent.name : parentGroupId;
|
return parent ? parent.name : parentGroupId;
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
async function handleCreate() {
|
||||||
const name = newGroupName.trim();
|
if (!newName.trim()) return;
|
||||||
if (!name) return;
|
|
||||||
try {
|
try {
|
||||||
await createGroup.mutateAsync({
|
await createGroup.mutateAsync({
|
||||||
name,
|
name: newName.trim(),
|
||||||
parentGroupId: newGroupParentId || null,
|
parentGroupId: newParent || null,
|
||||||
});
|
});
|
||||||
toast({ title: 'Group created', variant: 'success' });
|
toast({ title: 'Group created', description: newName.trim(), variant: 'success' });
|
||||||
setNewGroupName('');
|
setCreating(false);
|
||||||
setNewGroupParentId('');
|
setNewName('');
|
||||||
setShowCreate(false);
|
setNewParent('');
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Failed to create group', variant: 'error' });
|
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;
|
if (!selectedGroup) return;
|
||||||
try {
|
try {
|
||||||
await updateGroup.mutateAsync({
|
await updateGroup.mutateAsync({
|
||||||
id: selectedGroup.id,
|
id: selectedGroup.id,
|
||||||
name: newName,
|
name: newNameVal,
|
||||||
parentGroupId: selectedGroup.parentGroupId,
|
parentGroupId: selectedGroup.parentGroupId,
|
||||||
});
|
});
|
||||||
toast({ title: 'Group renamed', variant: 'success' });
|
toast({ title: 'Group renamed', variant: 'success' });
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Failed to rename group', variant: 'error' });
|
toast({ title: 'Failed to rename group', variant: 'error' });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
async function handleRemoveMember(userId: string) {
|
||||||
if (!selectedGroup) return;
|
if (!selectedGroup) return;
|
||||||
try {
|
try {
|
||||||
await deleteGroup.mutateAsync(selectedGroup.id);
|
await removeUserFromGroup.mutateAsync({
|
||||||
toast({ title: 'Group deleted', variant: 'success' });
|
userId,
|
||||||
setSelectedGroupId(null);
|
|
||||||
setDeleteOpen(false);
|
|
||||||
} catch {
|
|
||||||
toast({ title: 'Failed to delete group', variant: 'error' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddMember = async () => {
|
|
||||||
if (!selectedGroup || !addMemberUserId) return;
|
|
||||||
try {
|
|
||||||
await addUserToGroup.mutateAsync({
|
|
||||||
userId: addMemberUserId,
|
|
||||||
groupId: selectedGroup.id,
|
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' });
|
toast({ title: 'Member removed', variant: 'success' });
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Failed to remove member', variant: 'error' });
|
toast({ title: 'Failed to remove member', variant: 'error' });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleAddRole = async () => {
|
async function handleAddMembers(userIds: string[]) {
|
||||||
if (!selectedGroup || !addRoleId) return;
|
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddRoles(roleIds: string[]) {
|
||||||
|
if (!selectedGroup) return;
|
||||||
|
for (const roleId of roleIds) {
|
||||||
try {
|
try {
|
||||||
await assignRoleToGroup.mutateAsync({
|
await assignRoleToGroup.mutateAsync({
|
||||||
groupId: selectedGroup.id,
|
groupId: selectedGroup.id,
|
||||||
roleId: addRoleId,
|
roleId,
|
||||||
});
|
});
|
||||||
toast({ title: 'Role assigned', variant: 'success' });
|
toast({ title: 'Role assigned', variant: 'success' });
|
||||||
setAddRoleId('');
|
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Failed to assign role', variant: 'error' });
|
toast({ title: 'Failed to assign role', variant: 'error' });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleRemoveRole = async (roleId: string) => {
|
async function handleRemoveRole(roleId: string) {
|
||||||
if (!selectedGroup) return;
|
if (!selectedGroup) return;
|
||||||
try {
|
try {
|
||||||
await removeRoleFromGroup.mutateAsync({ groupId: selectedGroup.id, roleId });
|
await removeRoleFromGroup.mutateAsync({
|
||||||
|
groupId: selectedGroup.id,
|
||||||
|
roleId,
|
||||||
|
});
|
||||||
toast({ title: 'Role removed', variant: 'success' });
|
toast({ title: 'Role removed', variant: 'success' });
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Failed to remove role', variant: 'error' });
|
toast({ title: 'Failed to remove role', variant: 'error' });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
|
if (groupsLoading) return <Spinner size="md" />;
|
||||||
|
|
||||||
// Build sets for quick lookup of already-assigned items
|
|
||||||
const memberUserIds = new Set((selectedGroup?.members ?? []).map((m) => m.userId));
|
|
||||||
const assignedRoleIds = new Set((selectedGroup?.directRoles ?? []).map((r) => r.id));
|
|
||||||
|
|
||||||
const availableUsers = users.filter((u) => !memberUserIds.has(u.userId));
|
|
||||||
const availableRoles = roles.filter((r) => !assignedRoleIds.has(r.id));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.splitPane}>
|
<>
|
||||||
{/* Left pane */}
|
<SplitPane
|
||||||
<div className={styles.listPane}>
|
list={
|
||||||
<div className={styles.listHeader}>
|
<>
|
||||||
<Input
|
{creating && (
|
||||||
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}>
|
<div className={styles.createForm}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Group name"
|
placeholder="Group name *"
|
||||||
value={newGroupName}
|
value={newName}
|
||||||
onChange={(e) => setNewGroupName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 8 }}>
|
{duplicateGroupName && (
|
||||||
|
<span style={{ color: 'var(--error)', fontSize: 11 }}>
|
||||||
|
Group name already exists
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Select
|
<Select
|
||||||
options={parentOptions}
|
options={parentOptions}
|
||||||
value={newGroupParentId}
|
value={newParent}
|
||||||
onChange={(e) => setNewGroupParentId(e.target.value)}
|
onChange={(e) => setNewParent(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className={styles.createFormActions}>
|
<div className={styles.createFormActions}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => setCreating(false)}
|
||||||
setShowCreate(false);
|
|
||||||
setNewGroupName('');
|
|
||||||
setNewGroupParentId('');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
loading={createGroup.isPending}
|
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={!newGroupName.trim()}
|
loading={createGroup.isPending}
|
||||||
|
disabled={!newName.trim() || duplicateGroupName}
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
@@ -227,176 +258,190 @@ export default function GroupsTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groupsLoading ? (
|
<EntityList
|
||||||
<Spinner />
|
items={filtered}
|
||||||
) : (
|
renderItem={(group) => {
|
||||||
<div className={styles.entityList} role="listbox">
|
const groupChildren = groups.filter(
|
||||||
{filteredGroups.map((group) => {
|
(g) => g.parentGroupId === group.id,
|
||||||
const isSelected = group.id === selectedGroupId;
|
);
|
||||||
|
const groupParent = group.parentGroupId
|
||||||
|
? groups.find((g) => g.id === group.parentGroupId)
|
||||||
|
: null;
|
||||||
return (
|
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" />
|
<Avatar name={group.name} size="sm" />
|
||||||
<div className={styles.entityInfo}>
|
<div className={styles.entityInfo}>
|
||||||
<div className={styles.entityName}>{group.name}</div>
|
<div className={styles.entityName}>{group.name}</div>
|
||||||
<div className={styles.entityMeta}>
|
<div className={styles.entityMeta}>
|
||||||
{group.parentGroupId
|
{groupParent
|
||||||
? `Child of ${parentName(group.parentGroupId)}`
|
? `Child of ${groupParent.name}`
|
||||||
: 'Top-level'}
|
: '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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
</div>
|
getItemId={(group) => group.id}
|
||||||
)}
|
selectedId={selectedId ?? undefined}
|
||||||
</div>
|
onSelect={setSelectedId}
|
||||||
|
searchPlaceholder="Search groups..."
|
||||||
{/* Right pane */}
|
onSearch={setSearch}
|
||||||
<div className={styles.detailPane}>
|
addLabel="+ Add group"
|
||||||
{!selectedGroupId ? (
|
onAdd={() => setCreating(true)}
|
||||||
<div className={styles.emptyDetail}>Select a group to view details</div>
|
emptyMessage="No groups match your search"
|
||||||
) : detailLoading ? (
|
/>
|
||||||
<Spinner />
|
</>
|
||||||
|
}
|
||||||
|
detail={
|
||||||
|
selectedId && detailLoading ? (
|
||||||
|
<Spinner size="md" />
|
||||||
) : selectedGroup ? (
|
) : selectedGroup ? (
|
||||||
<div>
|
<>
|
||||||
{/* Header */}
|
|
||||||
<div className={styles.detailHeader}>
|
<div className={styles.detailHeader}>
|
||||||
<Avatar name={selectedGroup.name} size="md" />
|
<Avatar name={selectedGroup.name} size="lg" />
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div className={styles.detailHeaderInfo}>
|
||||||
|
<div className={styles.detailName}>
|
||||||
|
{isBuiltinAdmins ? (
|
||||||
|
selectedGroup.name
|
||||||
|
) : (
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={selectedGroup.name}
|
value={selectedGroup.name}
|
||||||
onSave={handleRename}
|
onSave={handleRename}
|
||||||
disabled={isBuiltinAdmins}
|
|
||||||
/>
|
/>
|
||||||
<div className={styles.entityMeta}>
|
)}
|
||||||
{selectedGroup.parentGroupId
|
</div>
|
||||||
? `Child of ${parentName(selectedGroup.parentGroupId)}`
|
<div className={styles.detailEmail}>
|
||||||
: 'Top-level'}
|
{parentGroup
|
||||||
|
? `${parentGroup.name} > ${selectedGroup.name}`
|
||||||
|
: 'Top-level group'}
|
||||||
|
{isBuiltinAdmins && ' (built-in)'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => setDeleteTarget(selectedGroup)}
|
||||||
disabled={isBuiltinAdmins}
|
disabled={isBuiltinAdmins}
|
||||||
onClick={() => setDeleteOpen(true)}
|
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
<div className={styles.metaGrid}>
|
<div className={styles.metaGrid}>
|
||||||
<span className={styles.metaLabel}>Group ID</span>
|
<span className={styles.metaLabel}>ID</span>
|
||||||
<MonoText size="xs">{selectedGroup.id}</MonoText>
|
<MonoText size="xs">{selectedGroup.id}</MonoText>
|
||||||
<span className={styles.metaLabel}>Parent</span>
|
|
||||||
<span>{parentName(selectedGroup.parentGroupId)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Members */}
|
{parentGroup && (
|
||||||
<div className={styles.sectionTitle}>Members</div>
|
<>
|
||||||
|
<SectionHeader>Member of</SectionHeader>
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{(selectedGroup.members ?? []).map((member) => (
|
<Tag label={parentGroup.name} color="auto" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionHeader>Members (direct)</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{members.map((u) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={member.userId}
|
key={u.userId}
|
||||||
label={member.displayName}
|
label={u.displayName}
|
||||||
onRemove={() => handleRemoveMember(member.userId)}
|
color="auto"
|
||||||
|
onRemove={() => handleRemoveMember(u.userId)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{(selectedGroup.members ?? []).length === 0 && (
|
{members.length === 0 && (
|
||||||
<span className={styles.inheritedNote}>No members</span>
|
<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>
|
</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 */}
|
<SectionHeader>Assigned roles</SectionHeader>
|
||||||
<div className={styles.sectionTitle}>Assigned Roles</div>
|
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{(selectedGroup.directRoles ?? []).map((role) => (
|
{(selectedGroup.directRoles ?? []).map((r) => (
|
||||||
<Badge
|
<Tag
|
||||||
key={role.id}
|
key={r.id}
|
||||||
label={role.name}
|
label={r.name}
|
||||||
variant="outlined"
|
color="warning"
|
||||||
onRemove={() => handleRemoveRole(role.id)}
|
onRemove={() => {
|
||||||
|
if (members.length > 0) {
|
||||||
|
setRemoveRoleTarget(r.id);
|
||||||
|
} else {
|
||||||
|
handleRemoveRole(r.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{(selectedGroup.directRoles ?? []).length === 0 && (
|
{(selectedGroup.directRoles ?? []).length === 0 && (
|
||||||
<span className={styles.inheritedNote}>No roles assigned</span>
|
<span className={styles.inheritedNote}>(no roles)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
<MultiSelect
|
||||||
{(selectedGroup.effectiveRoles ?? []).length >
|
options={availableRoles}
|
||||||
(selectedGroup.directRoles ?? []).length && (
|
value={[]}
|
||||||
<div className={styles.inheritedNote}>
|
onChange={handleAddRoles}
|
||||||
+
|
placeholder="+ Add"
|
||||||
{(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>
|
</div>
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
emptyMessage="Select a group to view details"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteOpen}
|
open={deleteTarget !== null}
|
||||||
onClose={() => setDeleteOpen(false)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title="Delete Group"
|
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
|
||||||
message={`Delete group "${selectedGroup?.name}"? This action cannot be undone.`}
|
confirmText={deleteTarget?.name ?? ''}
|
||||||
confirmText="DELETE"
|
|
||||||
variant="danger"
|
|
||||||
loading={deleteGroup.isPending}
|
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 {
|
.section {
|
||||||
display: grid;
|
margin-bottom: 24px;
|
||||||
gap: 0.5rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section h3 {
|
.toggleRow {
|
||||||
font-size: 0.875rem;
|
display: flex;
|
||||||
font-weight: 600;
|
align-items: center;
|
||||||
margin: 0;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagRow {
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 6px;
|
||||||
min-height: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.addRow {
|
.noRoles {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addRoleRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addRow input {
|
.roleInput {
|
||||||
flex: 1;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,226 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { adminFetch } from '../../api/queries/admin/admin-api';
|
||||||
import styles from './OidcConfigPage.module.css';
|
import styles from './OidcConfigPage.module.css';
|
||||||
|
|
||||||
interface OidcConfig {
|
interface OidcFormData {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
autoSignup: boolean;
|
||||||
issuerUri: string;
|
issuerUri: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
rolesClaim: string;
|
rolesClaim: string;
|
||||||
defaultRoles: string[];
|
|
||||||
autoSignup: boolean;
|
|
||||||
displayNameClaim: string;
|
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() {
|
export default function OidcConfigPage() {
|
||||||
const [config, setConfig] = useState<OidcConfig | null>(null);
|
const [form, setForm] = useState<OidcFormData | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const [newRole, setNewRole] = useState('');
|
const [newRole, setNewRole] = useState('');
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
adminFetch<OidcConfig>('/oidc')
|
adminFetch<OidcFormData>('/oidc')
|
||||||
.then(setConfig)
|
.then(setForm)
|
||||||
.catch(() => setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' }));
|
.catch(() => setForm(EMPTY_CONFIG));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async () => {
|
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
|
||||||
if (!config) return;
|
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);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(config) });
|
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(form) });
|
||||||
setSuccess(true);
|
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' });
|
||||||
setTimeout(() => setSuccess(false), 3000);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
|
toast({ title: 'Save failed', description: e.message, variant: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
async function handleTest() {
|
||||||
|
if (!form) return;
|
||||||
|
setTesting(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await adminFetch('/oidc', { method: 'DELETE' });
|
const result = await adminFetch<{ status: string; authorizationEndpoint?: string }>('/oidc/test', { method: 'POST' });
|
||||||
setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' });
|
toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
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 (
|
return (
|
||||||
<div>
|
<div className={styles.page}>
|
||||||
<h2 style={{ marginBottom: '1rem' }}>OIDC Configuration</h2>
|
<div className={styles.toolbar}>
|
||||||
<Card>
|
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri || testing}>
|
||||||
<div style={{ padding: '1.5rem', display: 'grid', gap: '1rem' }}>
|
{testing ? 'Testing...' : 'Test Connection'}
|
||||||
<Toggle checked={config.enabled} onChange={(e) => setConfig({ ...config, enabled: e.target.checked })} label="Enable OIDC" />
|
</Button>
|
||||||
<FormField label="Issuer URI"><Input value={config.issuerUri} onChange={(e) => setConfig({ ...config, issuerUri: e.target.value })} /></FormField>
|
<Button size="sm" variant="primary" onClick={handleSave} disabled={saving}>
|
||||||
<FormField label="Client ID"><Input value={config.clientId} onChange={(e) => setConfig({ ...config, clientId: e.target.value })} /></FormField>
|
{saving ? 'Saving...' : 'Save'}
|
||||||
<FormField label="Client Secret"><Input type="password" value={config.clientSecret} onChange={(e) => setConfig({ ...config, clientSecret: e.target.value })} /></FormField>
|
</Button>
|
||||||
<FormField label="Roles Claim"><Input value={config.rolesClaim} onChange={(e) => setConfig({ ...config, rolesClaim: e.target.value })} /></FormField>
|
</div>
|
||||||
<FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField>
|
|
||||||
<Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" />
|
|
||||||
|
|
||||||
<div className={styles.section}>
|
{error && <div style={{ marginBottom: 16 }}><Alert variant="error">{error}</Alert></div>}
|
||||||
<h3>Default Roles</h3>
|
|
||||||
<div className={styles.tagRow}>
|
<section className={styles.section}>
|
||||||
{(config.defaultRoles || []).map(role => (
|
<SectionHeader>Behavior</SectionHeader>
|
||||||
<Tag key={role} label={role} onRemove={() => {
|
<div className={styles.toggleRow}>
|
||||||
setConfig(prev => ({ ...prev!, defaultRoles: prev!.defaultRoles.filter(r => r !== role) }));
|
<Toggle
|
||||||
}} />
|
label="Enabled"
|
||||||
|
checked={form.enabled}
|
||||||
|
onChange={(e) => update('enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
<div className={styles.addRow}>
|
<div className={styles.addRoleRow}>
|
||||||
<Input placeholder="Add role..." value={newRole} onChange={e => setNewRole(e.target.value)} />
|
<Input
|
||||||
<Button onClick={() => {
|
placeholder="Add role..."
|
||||||
if (newRole.trim() && !config.defaultRoles?.includes(newRole.trim())) {
|
value={newRole}
|
||||||
setConfig(prev => ({ ...prev!, defaultRoles: [...(prev!.defaultRoles || []), newRole.trim()] }));
|
onChange={(e) => setNewRole(e.target.value)}
|
||||||
setNewRole('');
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }}
|
||||||
}
|
className={styles.roleInput}
|
||||||
}}>Add</Button>
|
/>
|
||||||
</div>
|
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
<section className={styles.section}>
|
||||||
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
<SectionHeader>Danger Zone</SectionHeader>
|
||||||
<Button variant="danger" onClick={() => setDeleteOpen(true)}>Delete Configuration</Button>
|
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
|
||||||
</div>
|
Delete OIDC Configuration
|
||||||
|
</Button>
|
||||||
{error && <Alert variant="error">{error}</Alert>}
|
|
||||||
{success && <Alert variant="success">Configuration saved</Alert>}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
onClose={() => setDeleteOpen(false)}
|
onClose={() => setDeleteOpen(false)}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title="Delete OIDC Configuration"
|
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
|
||||||
message="Delete OIDC configuration? All OIDC users will lose access."
|
confirmText="delete oidc"
|
||||||
confirmText="DELETE"
|
|
||||||
/>
|
/>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,30 +6,29 @@ import UsersTab from './UsersTab';
|
|||||||
import GroupsTab from './GroupsTab';
|
import GroupsTab from './GroupsTab';
|
||||||
import RolesTab from './RolesTab';
|
import RolesTab from './RolesTab';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ label: 'Users', value: 'users' },
|
||||||
|
{ label: 'Groups', value: 'groups' },
|
||||||
|
{ label: 'Roles', value: 'roles' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function RbacPage() {
|
export default function RbacPage() {
|
||||||
const { data: stats } = useRbacStats();
|
const { data: stats } = useRbacStats();
|
||||||
const [tab, setTab] = useState('users');
|
const [tab, setTab] = useState('users');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ margin: '0 0 16px' }}>User Management</h2>
|
|
||||||
<div className={styles.statStrip}>
|
<div className={styles.statStrip}>
|
||||||
<StatCard label="Users" value={stats?.userCount ?? 0} />
|
<StatCard label="Users" value={stats?.userCount ?? 0} />
|
||||||
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
|
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
|
||||||
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
|
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
<Tabs
|
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||||
tabs={[
|
<div className={styles.tabContent}>
|
||||||
{ label: 'Users', value: 'users' },
|
|
||||||
{ label: 'Groups', value: 'groups' },
|
|
||||||
{ label: 'Roles', value: 'roles' },
|
|
||||||
]}
|
|
||||||
active={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
/>
|
|
||||||
{tab === 'users' && <UsersTab />}
|
{tab === 'users' && <UsersTab />}
|
||||||
{tab === 'groups' && <GroupsTab />}
|
{tab === 'groups' && <GroupsTab />}
|
||||||
{tab === 'roles' && <RolesTab />}
|
{tab === 'roles' && <RolesTab />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
|
||||||
Input,
|
Input,
|
||||||
MonoText,
|
MonoText,
|
||||||
Spinner,
|
SectionHeader,
|
||||||
Tag,
|
Tag,
|
||||||
|
ConfirmDialog,
|
||||||
|
SplitPane,
|
||||||
|
EntityList,
|
||||||
|
Spinner,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import {
|
import {
|
||||||
@@ -20,33 +23,54 @@ import type { RoleDetail } from '../../api/queries/admin/rbac';
|
|||||||
import styles from './UserManagement.module.css';
|
import styles from './UserManagement.module.css';
|
||||||
|
|
||||||
export default function RolesTab() {
|
export default function RolesTab() {
|
||||||
|
const { toast } = useToast();
|
||||||
const { data: roles, isLoading } = useRoles();
|
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);
|
const { data: detail, isLoading: detailLoading } = useRole(selectedId);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
const createRole = useCreateRole();
|
const createRole = useCreateRole();
|
||||||
const deleteRole = useDeleteRole();
|
const deleteRole = useDeleteRole();
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const filtered = (roles ?? []).filter((r) =>
|
const filtered = useMemo(() => {
|
||||||
r.name.toLowerCase().includes(search.toLowerCase()),
|
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() {
|
function handleCreate() {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
createRole.mutate(
|
createRole.mutate(
|
||||||
{ name: newName.trim(), description: newDescription.trim() || undefined },
|
{ name: newName.trim().toUpperCase(), description: newDesc.trim() || undefined },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: 'Role created', variant: 'success' });
|
toast({
|
||||||
setShowCreate(false);
|
title: 'Role created',
|
||||||
|
description: newName.trim().toUpperCase(),
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
setCreating(false);
|
||||||
setNewName('');
|
setNewName('');
|
||||||
setNewDescription('');
|
setNewDesc('');
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({ title: 'Failed to create role', variant: 'error' });
|
toast({ title: 'Failed to create role', variant: 'error' });
|
||||||
@@ -56,70 +80,68 @@ export default function RolesTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!selectedId) return;
|
if (!deleteTarget) return;
|
||||||
deleteRole.mutate(selectedId, {
|
deleteRole.mutate(deleteTarget.id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: 'Role deleted', variant: 'success' });
|
toast({
|
||||||
setSelectedId(null);
|
title: 'Role deleted',
|
||||||
setConfirmDelete(false);
|
description: deleteTarget.name,
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
|
if (selectedId === deleteTarget.id) setSelectedId(null);
|
||||||
|
setDeleteTarget(null);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({ title: 'Failed to delete role', variant: 'error' });
|
toast({ title: 'Failed to delete role', variant: 'error' });
|
||||||
setConfirmDelete(false);
|
setDeleteTarget(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAssignmentCount(role: RoleDetail): number {
|
||||||
return (
|
return (
|
||||||
<div className={styles.splitPane}>
|
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0)
|
||||||
{/* Left pane — list */}
|
);
|
||||||
<div className={styles.listPane}>
|
}
|
||||||
<div className={styles.listHeader}>
|
|
||||||
<Input
|
|
||||||
placeholder="Search roles…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowCreate((v) => !v)}
|
|
||||||
>
|
|
||||||
+ Add Role
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCreate && (
|
if (isLoading) return <Spinner size="md" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SplitPane
|
||||||
|
list={
|
||||||
|
<>
|
||||||
|
{creating && (
|
||||||
<div className={styles.createForm}>
|
<div className={styles.createForm}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Role name (e.g. EDITOR)"
|
placeholder="Role name *"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value.toUpperCase())}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
style={{ marginBottom: 8 }}
|
|
||||||
/>
|
/>
|
||||||
|
{duplicateRoleName && (
|
||||||
|
<span style={{ color: 'var(--error)', fontSize: 11 }}>
|
||||||
|
Role name already exists
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
placeholder="Description (optional)"
|
placeholder="Description"
|
||||||
value={newDescription}
|
value={newDesc}
|
||||||
onChange={(e) => setNewDescription(e.target.value)}
|
onChange={(e) => setNewDesc(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className={styles.createFormActions}>
|
<div className={styles.createFormActions}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
variant="ghost"
|
||||||
setShowCreate(false);
|
onClick={() => setCreating(false)}
|
||||||
setNewName('');
|
|
||||||
setNewDescription('');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
loading={createRole.isPending}
|
variant="primary"
|
||||||
disabled={!newName.trim()}
|
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
|
loading={createRole.isPending}
|
||||||
|
disabled={!newName.trim() || duplicateRoleName}
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
@@ -127,81 +149,75 @@ export default function RolesTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
<EntityList
|
||||||
<Spinner />
|
items={filtered}
|
||||||
) : (
|
renderItem={(role) => (
|
||||||
<div className={styles.entityList} role="listbox">
|
<>
|
||||||
{filtered.map((role) => {
|
|
||||||
const assignmentCount =
|
|
||||||
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={role.id}
|
|
||||||
className={
|
|
||||||
styles.entityItem +
|
|
||||||
(selectedId === role.id ? ' ' + styles.entityItemSelected : '')
|
|
||||||
}
|
|
||||||
role="option"
|
|
||||||
aria-selected={selectedId === role.id}
|
|
||||||
onClick={() => setSelectedId(role.id)}
|
|
||||||
>
|
|
||||||
<Avatar name={role.name} size="sm" />
|
<Avatar name={role.name} size="sm" />
|
||||||
<div className={styles.entityInfo}>
|
<div className={styles.entityInfo}>
|
||||||
<div className={styles.entityName}>
|
<div className={styles.entityName}>
|
||||||
{role.name}
|
{role.name}
|
||||||
{role.system && <Badge label="system" variant="outlined" />}
|
{role.system && (
|
||||||
|
<Badge
|
||||||
|
label="system"
|
||||||
|
color="auto"
|
||||||
|
variant="outlined"
|
||||||
|
className={styles.providerBadge}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.entityMeta}>
|
<div className={styles.entityMeta}>
|
||||||
{role.description || '—'} · {assignmentCount} assignment
|
{role.description || '\u2014'} \u00b7{' '}
|
||||||
{assignmentCount !== 1 ? 's' : ''}
|
{getAssignmentCount(role)} assignments
|
||||||
</div>
|
</div>
|
||||||
{((role.assignedGroups?.length ?? 0) > 0 ||
|
|
||||||
(role.directUsers?.length ?? 0) > 0) && (
|
|
||||||
<div className={styles.entityTags}>
|
<div className={styles.entityTags}>
|
||||||
{(role.assignedGroups ?? []).map((g) => (
|
{(role.assignedGroups ?? []).map((g) => (
|
||||||
<Tag key={g.id} label={g.name} color="success" />
|
<Badge key={g.id} label={g.name} color="success" />
|
||||||
))}
|
))}
|
||||||
{(role.directUsers ?? []).map((u) => (
|
{(role.directUsers ?? []).map((u) => (
|
||||||
<Tag key={u.userId} label={u.displayName} />
|
<Badge
|
||||||
|
key={u.userId}
|
||||||
|
label={u.displayName}
|
||||||
|
color="auto"
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
getItemId={(role) => role.id}
|
||||||
</div>
|
selectedId={selectedId ?? undefined}
|
||||||
);
|
onSelect={setSelectedId}
|
||||||
})}
|
searchPlaceholder="Search roles..."
|
||||||
</div>
|
onSearch={setSearch}
|
||||||
)}
|
addLabel="+ Add role"
|
||||||
</div>
|
onAdd={() => setCreating(true)}
|
||||||
|
emptyMessage="No roles match your search"
|
||||||
{/* Right pane — detail */}
|
/>
|
||||||
<div className={styles.detailPane}>
|
</>
|
||||||
{!selectedId ? (
|
}
|
||||||
<div className={styles.emptyDetail}>Select a role to view details</div>
|
detail={
|
||||||
) : detailLoading || !detail ? (
|
selectedId && (detailLoading || !detail) ? (
|
||||||
<Spinner />
|
<Spinner size="md" />
|
||||||
) : (
|
) : detail ? (
|
||||||
<RoleDetailPanel
|
<RoleDetailPanel
|
||||||
role={detail}
|
role={detail}
|
||||||
onDeleteRequest={() => setConfirmDelete(true)}
|
onDeleteRequest={() => setDeleteTarget(detail)}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
emptyMessage="Select a role to view details"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{detail && (
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmDelete}
|
open={deleteTarget !== null}
|
||||||
onClose={() => setConfirmDelete(false)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title="Delete role"
|
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
|
||||||
message={`Delete role "${detail.name}"? This cannot be undone.`}
|
confirmText={deleteTarget?.name ?? ''}
|
||||||
confirmText={detail.name}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
variant="danger"
|
|
||||||
loading={deleteRole.isPending}
|
loading={deleteRole.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,93 +229,93 @@ interface RoleDetailPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) {
|
function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) {
|
||||||
// Build a set of directly-assigned user IDs for distinguishing inherited principals
|
const directUserIds = new Set(
|
||||||
const directUserIds = new Set((role.directUsers ?? []).map((u) => u.userId));
|
(role.directUsers ?? []).map((u) => u.userId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignedGroups = role.assignedGroups ?? [];
|
||||||
|
const directUsers = role.directUsers ?? [];
|
||||||
|
const effectivePrincipals = role.effectivePrincipals ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
{/* Header */}
|
|
||||||
<div className={styles.detailHeader}>
|
<div className={styles.detailHeader}>
|
||||||
<Avatar name={role.name} size="md" />
|
<Avatar name={role.name} size="lg" />
|
||||||
<div style={{ flex: 1 }}>
|
<div className={styles.detailHeaderInfo}>
|
||||||
<div style={{ fontWeight: 700, fontSize: 16 }}>{role.name}</div>
|
<div className={styles.detailName}>{role.name}</div>
|
||||||
{role.description && (
|
{role.description && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
<div className={styles.detailEmail}>{role.description}</div>
|
||||||
{role.description}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{!role.system && (
|
||||||
variant="danger"
|
<Button size="sm" variant="danger" onClick={onDeleteRequest}>
|
||||||
size="sm"
|
|
||||||
disabled={role.system}
|
|
||||||
onClick={onDeleteRequest}
|
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
<div className={styles.metaGrid}>
|
<div className={styles.metaGrid}>
|
||||||
<span className={styles.metaLabel}>ID</span>
|
<span className={styles.metaLabel}>ID</span>
|
||||||
<MonoText size="xs">{role.id}</MonoText>
|
<MonoText size="xs">{role.id}</MonoText>
|
||||||
|
|
||||||
<span className={styles.metaLabel}>Scope</span>
|
<span className={styles.metaLabel}>Scope</span>
|
||||||
<span>{role.scope || '—'}</span>
|
<span className={styles.metaValue}>{role.scope || '\u2014'}</span>
|
||||||
|
{role.system && (
|
||||||
|
<>
|
||||||
<span className={styles.metaLabel}>Type</span>
|
<span className={styles.metaLabel}>Type</span>
|
||||||
<span>{role.system ? 'System role (read-only)' : 'Custom role'}</span>
|
<span className={styles.metaValue}>System role (read-only)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assigned to groups */}
|
<SectionHeader>Assigned to groups</SectionHeader>
|
||||||
<div className={styles.sectionTitle}>Assigned to groups</div>
|
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{(role.assignedGroups ?? []).length === 0 ? (
|
{assignedGroups.map((g) => (
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
|
||||||
) : (
|
|
||||||
(role.assignedGroups ?? []).map((g) => (
|
|
||||||
<Tag key={g.id} label={g.name} color="success" />
|
<Tag key={g.id} label={g.name} color="success" />
|
||||||
))
|
))}
|
||||||
|
{assignedGroups.length === 0 && (
|
||||||
|
<span className={styles.inheritedNote}>(none)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assigned to users (direct) */}
|
<SectionHeader>Assigned to users (direct)</SectionHeader>
|
||||||
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
|
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{(role.directUsers ?? []).length === 0 ? (
|
{directUsers.map((u) => (
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
<Tag key={u.userId} label={u.displayName} color="auto" />
|
||||||
) : (
|
))}
|
||||||
(role.directUsers ?? []).map((u) => (
|
{directUsers.length === 0 && (
|
||||||
<Tag key={u.userId} label={u.displayName} />
|
<span className={styles.inheritedNote}>(none)</span>
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Effective principals */}
|
<SectionHeader>Effective principals</SectionHeader>
|
||||||
<div className={styles.sectionTitle}>Effective principals</div>
|
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{(role.effectivePrincipals ?? []).length === 0 ? (
|
{effectivePrincipals.map((u) => {
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
|
||||||
) : (
|
|
||||||
(role.effectivePrincipals ?? []).map((u) => {
|
|
||||||
const isDirect = directUserIds.has(u.userId);
|
const isDirect = directUserIds.has(u.userId);
|
||||||
return isDirect ? (
|
return isDirect ? (
|
||||||
<Badge key={u.userId} label={u.displayName} variant="filled" />
|
<Badge
|
||||||
|
key={u.userId}
|
||||||
|
label={u.displayName}
|
||||||
|
color="auto"
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Badge
|
<Badge
|
||||||
key={u.userId}
|
key={u.userId}
|
||||||
label={`↑ ${u.displayName}`}
|
label={u.displayName}
|
||||||
|
color="auto"
|
||||||
variant="dashed"
|
variant="dashed"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
{effectivePrincipals.length === 0 && (
|
||||||
|
<span className={styles.inheritedNote}>(none)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && (
|
{effectivePrincipals.some((u) => !directUserIds.has(u.userId)) && (
|
||||||
<div className={styles.inheritedNote}>
|
<span className={styles.inheritedNote}>
|
||||||
Dashed entries inherit this role through group membership
|
Dashed entries inherit this role through group membership
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,187 +5,149 @@
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitPane {
|
.tabContent {
|
||||||
display: grid;
|
margin-top: 16px;
|
||||||
grid-template-columns: 52fr 48fr;
|
|
||||||
gap: 1px;
|
|
||||||
background: var(--border-subtle);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
min-height: 500px;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.listPane {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailPane {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.listHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.listHeader input { flex: 1; }
|
|
||||||
|
|
||||||
.entityList {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityItem:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityItem:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityItemSelected {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.entityInfo {
|
.entityInfo {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entityName {
|
.entityName {
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
display: flex;
|
font-weight: 500;
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entityMeta {
|
.entityMeta {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entityTags {
|
.entityTags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
margin-top: 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 {
|
.detailHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
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 {
|
.metaGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 100px 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
gap: 6px 12px;
|
gap: 6px 16px;
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metaLabel {
|
.metaLabel {
|
||||||
font-weight: 700;
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.6px;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.metaValue {
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 8px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTags {
|
.sectionTags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
margin-bottom: 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 {
|
.inheritedNote {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-style: italic;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--font-body);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.providerBadge {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherited {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
.securitySection {
|
.securitySection {
|
||||||
padding: 12px;
|
margin-top: 8px;
|
||||||
border: 1px solid var(--border-subtle);
|
margin-bottom: 8px;
|
||||||
border-radius: var(--radius-lg);
|
}
|
||||||
margin-bottom: 16px;
|
|
||||||
|
.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 {
|
.resetForm {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyDetail {
|
.resetInput {
|
||||||
display: flex;
|
width: 200px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,24 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
MonoText,
|
MonoText,
|
||||||
|
SectionHeader,
|
||||||
Tag,
|
Tag,
|
||||||
InfoCallout,
|
|
||||||
ConfirmDialog,
|
|
||||||
Select,
|
|
||||||
Spinner,
|
|
||||||
InlineEdit,
|
InlineEdit,
|
||||||
|
RadioGroup,
|
||||||
|
RadioItem,
|
||||||
|
InfoCallout,
|
||||||
|
MultiSelect,
|
||||||
|
ConfirmDialog,
|
||||||
|
AlertDialog,
|
||||||
|
SplitPane,
|
||||||
|
EntityList,
|
||||||
|
Spinner,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import {
|
import {
|
||||||
useUsers,
|
useUsers,
|
||||||
useCreateUser,
|
useCreateUser,
|
||||||
|
useUpdateUser,
|
||||||
useDeleteUser,
|
useDeleteUser,
|
||||||
useAssignRoleToUser,
|
useAssignRoleToUser,
|
||||||
useRemoveRoleFromUser,
|
useRemoveRoleFromUser,
|
||||||
@@ -25,35 +32,37 @@ import {
|
|||||||
useGroups,
|
useGroups,
|
||||||
useRoles,
|
useRoles,
|
||||||
} from '../../api/queries/admin/rbac';
|
} from '../../api/queries/admin/rbac';
|
||||||
|
import type { UserDetail } from '../../api/queries/admin/rbac';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
import styles from './UserManagement.module.css';
|
import styles from './UserManagement.module.css';
|
||||||
|
|
||||||
export default function UsersTab() {
|
export default function UsersTab() {
|
||||||
|
const { toast } = useToast();
|
||||||
const { data: users, isLoading } = useUsers();
|
const { data: users, isLoading } = useUsers();
|
||||||
const { data: allGroups } = useGroups();
|
const { data: allGroups } = useGroups();
|
||||||
const { data: allRoles } = useRoles();
|
const { data: allRoles } = useRoles();
|
||||||
const currentUsername = useAuthStore((s) => s.username);
|
const currentUsername = useAuthStore((s) => s.username);
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<UserDetail | null>(null);
|
||||||
|
const [removeGroupTarget, setRemoveGroupTarget] = useState<string | null>(null);
|
||||||
|
|
||||||
// Create form state
|
// Create form state
|
||||||
const [createUsername, setCreateUsername] = useState('');
|
const [newUsername, setNewUsername] = useState('');
|
||||||
const [createDisplayName, setCreateDisplayName] = useState('');
|
const [newDisplay, setNewDisplay] = useState('');
|
||||||
const [createEmail, setCreateEmail] = useState('');
|
const [newEmail, setNewEmail] = useState('');
|
||||||
const [createPassword, setCreatePassword] = useState('');
|
|
||||||
|
|
||||||
// Detail pane state
|
|
||||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [addGroupId, setAddGroupId] = useState('');
|
const [newProvider, setNewProvider] = useState<'local' | 'oidc'>('local');
|
||||||
const [addRoleId, setAddRoleId] = useState('');
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
// Password reset state
|
||||||
|
const [resettingPassword, setResettingPassword] = useState(false);
|
||||||
|
const [newPw, setNewPw] = useState('');
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const createUser = useCreateUser();
|
const createUser = useCreateUser();
|
||||||
|
const updateUser = useUpdateUser();
|
||||||
const deleteUser = useDeleteUser();
|
const deleteUser = useDeleteUser();
|
||||||
const assignRole = useAssignRoleToUser();
|
const assignRole = useAssignRoleToUser();
|
||||||
const removeRole = useRemoveRoleFromUser();
|
const removeRole = useRemoveRoleFromUser();
|
||||||
@@ -61,120 +70,37 @@ export default function UsersTab() {
|
|||||||
const removeFromGroup = useRemoveUserFromGroup();
|
const removeFromGroup = useRemoveUserFromGroup();
|
||||||
const setPassword = useSetPassword();
|
const setPassword = useSetPassword();
|
||||||
|
|
||||||
// Filtered user list
|
const userList = users ?? [];
|
||||||
const filteredUsers = useMemo(() => {
|
|
||||||
if (!users) return [];
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return userList;
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
if (!q) return users;
|
return userList.filter(
|
||||||
return users.filter(
|
|
||||||
(u) =>
|
(u) =>
|
||||||
u.displayName.toLowerCase().includes(q) ||
|
u.displayName.toLowerCase().includes(q) ||
|
||||||
(u.email ?? '').toLowerCase().includes(q) ||
|
(u.email ?? '').toLowerCase().includes(q) ||
|
||||||
u.userId.toLowerCase().includes(q),
|
u.userId.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
}, [users, search]);
|
}, [userList, search]);
|
||||||
|
|
||||||
const selectedUser = useMemo(
|
const selected = userList.find((u) => u.userId === selectedId) ?? null;
|
||||||
() => users?.find((u) => u.userId === selectedUserId) ?? null,
|
|
||||||
[users, selectedUserId],
|
const isSelf =
|
||||||
|
currentUsername != null &&
|
||||||
|
selected != null &&
|
||||||
|
selected.displayName === currentUsername;
|
||||||
|
|
||||||
|
const duplicateUsername =
|
||||||
|
newUsername.trim() !== '' &&
|
||||||
|
userList.some(
|
||||||
|
(u) => u.displayName.toLowerCase() === newUsername.trim().toLowerCase(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Handlers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function handleCreateUser() {
|
|
||||||
if (!createUsername.trim() || !createPassword.trim()) return;
|
|
||||||
createUser.mutate(
|
|
||||||
{
|
|
||||||
username: createUsername.trim(),
|
|
||||||
displayName: createDisplayName.trim() || undefined,
|
|
||||||
email: createEmail.trim() || undefined,
|
|
||||||
password: createPassword,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({ title: 'User created', variant: 'success' });
|
|
||||||
setShowCreateForm(false);
|
|
||||||
setCreateUsername('');
|
|
||||||
setCreateDisplayName('');
|
|
||||||
setCreateEmail('');
|
|
||||||
setCreatePassword('');
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({ title: 'Failed to create user', variant: 'error' });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleResetPassword() {
|
|
||||||
if (!selectedUser || !newPassword.trim()) return;
|
|
||||||
setPassword.mutate(
|
|
||||||
{ userId: selectedUser.userId, password: newPassword },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({ title: 'Password updated', variant: 'success' });
|
|
||||||
setShowPasswordForm(false);
|
|
||||||
setNewPassword('');
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({ title: 'Failed to update password', variant: 'error' });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddGroup() {
|
|
||||||
if (!selectedUser || !addGroupId) return;
|
|
||||||
addToGroup.mutate(
|
|
||||||
{ userId: selectedUser.userId, groupId: addGroupId },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({ title: 'Added to group', variant: 'success' });
|
|
||||||
setAddGroupId('');
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({ title: 'Failed to add group', variant: 'error' });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddRole() {
|
|
||||||
if (!selectedUser || !addRoleId) return;
|
|
||||||
assignRole.mutate(
|
|
||||||
{ userId: selectedUser.userId, roleId: addRoleId },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({ title: 'Role assigned', variant: 'success' });
|
|
||||||
setAddRoleId('');
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({ title: 'Failed to assign role', variant: 'error' });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteUser() {
|
|
||||||
if (!selectedUser) return;
|
|
||||||
deleteUser.mutate(selectedUser.userId, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({ title: 'User deleted', variant: 'success' });
|
|
||||||
setSelectedUserId(null);
|
|
||||||
setShowDeleteDialog(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({ title: 'Failed to delete user', variant: 'error' });
|
|
||||||
setShowDeleteDialog(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derived data for detail pane
|
// Derived data for detail pane
|
||||||
const directGroupIds = new Set(selectedUser?.directGroups.map((g) => g.id) ?? []);
|
const directGroupIds = new Set(selected?.directGroups.map((g) => g.id) ?? []);
|
||||||
const directRoleIds = new Set(selectedUser?.directRoles.map((r) => r.id) ?? []);
|
const directRoleIds = new Set(selected?.directRoles.map((r) => r.id) ?? []);
|
||||||
|
const inheritedRoles =
|
||||||
const inheritedRoles = selectedUser?.effectiveRoles.filter((r) => !directRoleIds.has(r.id)) ?? [];
|
selected?.effectiveRoles.filter((r) => !directRoleIds.has(r.id)) ?? [];
|
||||||
|
|
||||||
const availableGroups = (allGroups ?? [])
|
const availableGroups = (allGroups ?? [])
|
||||||
.filter((g) => !directGroupIds.has(g.id))
|
.filter((g) => !directGroupIds.has(g.id))
|
||||||
@@ -184,93 +110,154 @@ export default function UsersTab() {
|
|||||||
.filter((r) => !directRoleIds.has(r.id))
|
.filter((r) => !directRoleIds.has(r.id))
|
||||||
.map((r) => ({ value: r.id, label: r.name }));
|
.map((r) => ({ value: r.id, label: r.name }));
|
||||||
|
|
||||||
// Find group name for inherited role display
|
function handleCreate() {
|
||||||
function findInheritingGroupName(roleId: string): string {
|
if (!newUsername.trim()) return;
|
||||||
if (!selectedUser) return '';
|
if (newProvider === 'local' && !newPassword.trim()) return;
|
||||||
for (const g of selectedUser.effectiveGroups) {
|
createUser.mutate(
|
||||||
// We don't have group→roles in the summary, so just show "group"
|
{
|
||||||
void roleId;
|
username: newUsername.trim(),
|
||||||
return g.name;
|
displayName: newDisplay.trim() || undefined,
|
||||||
}
|
email: newEmail.trim() || undefined,
|
||||||
return 'group';
|
password: newProvider === 'local' ? newPassword : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'User created',
|
||||||
|
description: newDisplay.trim() || newUsername.trim(),
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
setCreating(false);
|
||||||
|
setNewUsername('');
|
||||||
|
setNewDisplay('');
|
||||||
|
setNewEmail('');
|
||||||
|
setNewPassword('');
|
||||||
|
setNewProvider('local');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: 'Failed to create user', variant: 'error' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSelf =
|
function handleDelete() {
|
||||||
currentUsername != null &&
|
if (!deleteTarget) return;
|
||||||
selectedUser != null &&
|
deleteUser.mutate(deleteTarget.userId, {
|
||||||
selectedUser.displayName === currentUsername;
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'User deleted',
|
||||||
|
description: deleteTarget.displayName,
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
|
if (selectedId === deleteTarget.userId) setSelectedId(null);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: 'Failed to delete user', variant: 'error' });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────
|
function handleResetPassword() {
|
||||||
|
if (!selected || !newPw.trim()) return;
|
||||||
|
setPassword.mutate(
|
||||||
|
{ userId: selected.userId, password: newPw },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Password updated',
|
||||||
|
description: selected.displayName,
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
setResettingPassword(false);
|
||||||
|
setNewPw('');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: 'Failed to update password', variant: 'error' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserGroupPath(user: UserDetail): string {
|
||||||
|
if (user.directGroups.length === 0) return 'no groups';
|
||||||
|
return user.directGroups.map((g) => g.name).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner size="md" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.splitPane}>
|
<>
|
||||||
{/* ── Left pane ── */}
|
<SplitPane
|
||||||
<div className={styles.listPane}>
|
list={
|
||||||
<div className={styles.listHeader}>
|
<>
|
||||||
<Input
|
{creating && (
|
||||||
placeholder="Search users…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
onClear={() => setSearch('')}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowCreateForm((v) => !v)}
|
|
||||||
>
|
|
||||||
{showCreateForm ? '✕ Cancel' : '+ Add User'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCreateForm && (
|
|
||||||
<div className={styles.createForm}>
|
<div className={styles.createForm}>
|
||||||
|
<RadioGroup
|
||||||
|
name="provider"
|
||||||
|
value={newProvider}
|
||||||
|
onChange={(v) => setNewProvider(v as 'local' | 'oidc')}
|
||||||
|
orientation="horizontal"
|
||||||
|
>
|
||||||
|
<RadioItem value="local" label="Local" />
|
||||||
|
<RadioItem value="oidc" label="OIDC" />
|
||||||
|
</RadioGroup>
|
||||||
|
<div className={styles.createFormRow}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username (required)"
|
placeholder="Username *"
|
||||||
value={createUsername}
|
value={newUsername}
|
||||||
onChange={(e) => setCreateUsername(e.target.value)}
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
style={{ marginBottom: 6 }}
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Display Name"
|
placeholder="Display name"
|
||||||
value={createDisplayName}
|
value={newDisplay}
|
||||||
onChange={(e) => setCreateDisplayName(e.target.value)}
|
onChange={(e) => setNewDisplay(e.target.value)}
|
||||||
style={{ marginBottom: 6 }}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
{duplicateUsername && (
|
||||||
|
<span style={{ color: 'var(--error)', fontSize: 11 }}>
|
||||||
|
Username already exists
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
type="email"
|
value={newEmail}
|
||||||
value={createEmail}
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
onChange={(e) => setCreateEmail(e.target.value)}
|
|
||||||
style={{ marginBottom: 6 }}
|
|
||||||
/>
|
/>
|
||||||
|
{newProvider === 'local' && (
|
||||||
<Input
|
<Input
|
||||||
placeholder="Password (required)"
|
placeholder="Password *"
|
||||||
type="password"
|
type="password"
|
||||||
value={createPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setCreatePassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
style={{ marginBottom: 6 }}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{newProvider === 'oidc' && (
|
||||||
|
<InfoCallout variant="amber">
|
||||||
|
OIDC users authenticate via the configured identity provider.
|
||||||
|
Pre-register to assign roles/groups before their first login.
|
||||||
|
</InfoCallout>
|
||||||
|
)}
|
||||||
<div className={styles.createFormActions}>
|
<div className={styles.createFormActions}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
variant="ghost"
|
||||||
setShowCreateForm(false);
|
onClick={() => setCreating(false)}
|
||||||
setCreateUsername('');
|
|
||||||
setCreateDisplayName('');
|
|
||||||
setCreateEmail('');
|
|
||||||
setCreatePassword('');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleCreate}
|
||||||
loading={createUser.isPending}
|
loading={createUser.isPending}
|
||||||
disabled={!createUsername.trim() || !createPassword.trim()}
|
disabled={
|
||||||
onClick={handleCreateUser}
|
!newUsername.trim() ||
|
||||||
|
(newProvider === 'local' && !newPassword.trim()) ||
|
||||||
|
duplicateUsername
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
@@ -278,138 +265,154 @@ export default function UsersTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && <Spinner size="md" />}
|
<EntityList
|
||||||
|
items={filtered}
|
||||||
<div className={styles.entityList} role="listbox">
|
renderItem={(user) => (
|
||||||
{filteredUsers.map((user) => (
|
<>
|
||||||
<div
|
|
||||||
key={user.userId}
|
|
||||||
className={
|
|
||||||
styles.entityItem +
|
|
||||||
(user.userId === selectedUserId ? ' ' + styles.entityItemSelected : '')
|
|
||||||
}
|
|
||||||
role="option"
|
|
||||||
aria-selected={user.userId === selectedUserId}
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => setSelectedUserId(user.userId)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedUserId(user.userId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Avatar name={user.displayName} size="sm" />
|
<Avatar name={user.displayName} size="sm" />
|
||||||
<div className={styles.entityInfo}>
|
<div className={styles.entityInfo}>
|
||||||
<div className={styles.entityName}>
|
<div className={styles.entityName}>
|
||||||
{user.displayName}
|
{user.displayName}
|
||||||
{user.provider !== 'local' && (
|
{user.provider !== 'local' && (
|
||||||
<Badge label={user.provider} variant="outlined" />
|
<Badge
|
||||||
|
label={user.provider}
|
||||||
|
color="running"
|
||||||
|
variant="outlined"
|
||||||
|
className={styles.providerBadge}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.entityMeta}>
|
<div className={styles.entityMeta}>
|
||||||
{user.email || user.userId}
|
{user.email || user.userId} ·{' '}
|
||||||
{user.directGroups.length > 0 && ` · ${user.directGroups.map((g) => g.name).join(', ')}`}
|
{getUserGroupPath(user)}
|
||||||
{user.directGroups.length === 0 && ' · no groups'}
|
|
||||||
</div>
|
</div>
|
||||||
{(user.directRoles.length > 0 || user.directGroups.length > 0) && (
|
|
||||||
<div className={styles.entityTags}>
|
<div className={styles.entityTags}>
|
||||||
{user.directRoles.map((r) => (
|
{user.directRoles.map((r) => (
|
||||||
<Badge key={r.id} label={r.name} variant="filled" color="primary" />
|
<Badge key={r.id} label={r.name} color="warning" />
|
||||||
))}
|
))}
|
||||||
{user.directGroups.map((g) => (
|
{user.directGroups.map((g) => (
|
||||||
<Badge key={g.id} label={g.name} variant="outlined" />
|
<Badge key={g.id} label={g.name} color="success" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
getItemId={(user) => user.userId}
|
||||||
</div>
|
selectedId={selectedId ?? undefined}
|
||||||
))}
|
onSelect={(id) => {
|
||||||
</div>
|
setSelectedId(id);
|
||||||
</div>
|
setResettingPassword(false);
|
||||||
|
|
||||||
{/* ── Right pane ── */}
|
|
||||||
<div className={styles.detailPane}>
|
|
||||||
{!selectedUser ? (
|
|
||||||
<div className={styles.emptyDetail}>Select a user to view details</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Header */}
|
|
||||||
<div className={styles.detailHeader}>
|
|
||||||
<Avatar name={selectedUser.displayName} size="lg" />
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<InlineEdit
|
|
||||||
value={selectedUser.displayName}
|
|
||||||
onSave={(val) => {
|
|
||||||
// useUpdateUser not imported here to keep things clean;
|
|
||||||
// display only — wired via displayName update if desired
|
|
||||||
void val;
|
|
||||||
}}
|
}}
|
||||||
|
searchPlaceholder="Search users..."
|
||||||
|
onSearch={setSearch}
|
||||||
|
addLabel="+ Add user"
|
||||||
|
onAdd={() => setCreating(true)}
|
||||||
|
emptyMessage="No users match your search"
|
||||||
/>
|
/>
|
||||||
{selectedUser.email && (
|
</>
|
||||||
<div className={styles.entityMeta}>{selectedUser.email}</div>
|
}
|
||||||
)}
|
detail={
|
||||||
|
selected ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<Avatar name={selected.displayName} size="lg" />
|
||||||
|
<div className={styles.detailHeaderInfo}>
|
||||||
|
<div className={styles.detailName}>
|
||||||
|
<InlineEdit
|
||||||
|
value={selected.displayName}
|
||||||
|
onSave={(v) =>
|
||||||
|
updateUser.mutate(
|
||||||
|
{ userId: selected.userId, displayName: v },
|
||||||
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
toast({
|
||||||
|
title: 'Display name updated',
|
||||||
|
variant: 'success',
|
||||||
|
}),
|
||||||
|
onError: () =>
|
||||||
|
toast({
|
||||||
|
title: 'Failed to update name',
|
||||||
|
variant: 'error',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailEmail}>
|
||||||
|
{selected.email || selected.userId}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => setDeleteTarget(selected)}
|
||||||
disabled={isSelf}
|
disabled={isSelf}
|
||||||
onClick={() => setShowDeleteDialog(true)}
|
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata grid */}
|
<SectionHeader>Status</SectionHeader>
|
||||||
<div className={styles.metaGrid}>
|
<div className={styles.sectionTags}>
|
||||||
<span className={styles.metaLabel}>User ID</span>
|
<Tag label="Active" color="success" />
|
||||||
<MonoText size="sm">{selectedUser.userId}</MonoText>
|
|
||||||
|
|
||||||
<span className={styles.metaLabel}>Created</span>
|
|
||||||
<span>{new Date(selectedUser.createdAt).toLocaleString()}</span>
|
|
||||||
|
|
||||||
<span className={styles.metaLabel}>Provider</span>
|
|
||||||
<span>{selectedUser.provider}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security section */}
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>ID</span>
|
||||||
|
<MonoText size="xs">{selected.userId}</MonoText>
|
||||||
|
<span className={styles.metaLabel}>Created</span>
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
{new Date(selected.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span className={styles.metaLabel}>Provider</span>
|
||||||
|
<span className={styles.metaValue}>{selected.provider}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Security</SectionHeader>
|
||||||
<div className={styles.securitySection}>
|
<div className={styles.securitySection}>
|
||||||
<div className={styles.sectionTitle}>Security</div>
|
{selected.provider === 'local' ? (
|
||||||
{selectedUser.provider === 'local' ? (
|
|
||||||
<>
|
<>
|
||||||
{!showPasswordForm ? (
|
<div className={styles.securityRow}>
|
||||||
|
<span className={styles.metaLabel}>Password</span>
|
||||||
|
<span className={styles.passwordDots}>
|
||||||
|
••••••••
|
||||||
|
</span>
|
||||||
|
{!resettingPassword && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowPasswordForm(true)}
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setResettingPassword(true);
|
||||||
|
setNewPw('');
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Reset password
|
Reset password
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
)}
|
||||||
|
</div>
|
||||||
|
{resettingPassword && (
|
||||||
<div className={styles.resetForm}>
|
<div className={styles.resetForm}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="New password"
|
placeholder="New password"
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPw}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPw(e.target.value)}
|
||||||
style={{ flex: 1 }}
|
className={styles.resetInput}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
variant="ghost"
|
||||||
setShowPasswordForm(false);
|
onClick={() => setResettingPassword(false)}
|
||||||
setNewPassword('');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
loading={setPassword.isPending}
|
variant="primary"
|
||||||
disabled={!newPassword.trim()}
|
|
||||||
onClick={handleResetPassword}
|
onClick={handleResetPassword}
|
||||||
|
loading={setPassword.isPending}
|
||||||
|
disabled={!newPw.trim()}
|
||||||
>
|
>
|
||||||
Set
|
Set
|
||||||
</Button>
|
</Button>
|
||||||
@@ -417,119 +420,180 @@ export default function UsersTab() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<InfoCallout variant="info">
|
<>
|
||||||
Password managed by identity provider
|
<div className={styles.securityRow}>
|
||||||
|
<span className={styles.metaLabel}>Authentication</span>
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
OIDC ({selected.provider})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<InfoCallout variant="amber">
|
||||||
|
Password managed by the identity provider.
|
||||||
</InfoCallout>
|
</InfoCallout>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group membership */}
|
<SectionHeader>Group membership (direct only)</SectionHeader>
|
||||||
<div className={styles.sectionTitle}>Group Membership</div>
|
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{selectedUser.directGroups.map((g) => (
|
{selected.directGroups.map((g) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={g.id}
|
key={g.id}
|
||||||
label={g.name}
|
label={g.name}
|
||||||
onRemove={() =>
|
color="success"
|
||||||
|
onRemove={() => {
|
||||||
removeFromGroup.mutate(
|
removeFromGroup.mutate(
|
||||||
{ userId: selectedUser.userId, groupId: g.id },
|
{ userId: selected.userId, groupId: g.id },
|
||||||
{
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
toast({ title: 'Group removed', variant: 'success' }),
|
||||||
onError: () =>
|
onError: () =>
|
||||||
toast({ title: 'Failed to remove group', variant: 'error' }),
|
toast({
|
||||||
|
title: 'Failed to remove group',
|
||||||
|
variant: 'error',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
{selected.directGroups.length === 0 && (
|
||||||
{availableGroups.length > 0 && (
|
<span className={styles.inheritedNote}>(no groups)</span>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
|
||||||
<Select
|
|
||||||
options={[{ value: '', label: 'Add to group…' }, ...availableGroups]}
|
|
||||||
value={addGroupId}
|
|
||||||
onChange={(e) => setAddGroupId(e.target.value)}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={!addGroupId}
|
|
||||||
onClick={handleAddGroup}
|
|
||||||
loading={addToGroup.isPending}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<MultiSelect
|
||||||
|
options={availableGroups}
|
||||||
|
value={[]}
|
||||||
|
onChange={(ids) => {
|
||||||
|
for (const groupId of ids) {
|
||||||
|
addToGroup.mutate(
|
||||||
|
{ userId: selected.userId, groupId },
|
||||||
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
toast({ title: 'Added to group', variant: 'success' }),
|
||||||
|
onError: () =>
|
||||||
|
toast({
|
||||||
|
title: 'Failed to add group',
|
||||||
|
variant: 'error',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Effective roles */}
|
<SectionHeader>
|
||||||
<div className={styles.sectionTitle}>Roles</div>
|
Effective roles (direct + inherited)
|
||||||
|
</SectionHeader>
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{selectedUser.directRoles.map((r) => (
|
{selected.directRoles.map((r) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={r.id}
|
key={r.id}
|
||||||
label={r.name}
|
label={r.name}
|
||||||
color="warning"
|
color="warning"
|
||||||
onRemove={() =>
|
onRemove={() => {
|
||||||
removeRole.mutate(
|
removeRole.mutate(
|
||||||
{ userId: selectedUser.userId, roleId: r.id },
|
{ userId: selected.userId, roleId: r.id },
|
||||||
{
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
toast({
|
||||||
|
title: 'Role removed',
|
||||||
|
description: r.name,
|
||||||
|
variant: 'success',
|
||||||
|
}),
|
||||||
onError: () =>
|
onError: () =>
|
||||||
toast({ title: 'Failed to remove role', variant: 'error' }),
|
toast({
|
||||||
|
title: 'Failed to remove role',
|
||||||
|
variant: 'error',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{inheritedRoles.map((r) => (
|
{inheritedRoles.map((r) => (
|
||||||
<span key={r.id} style={{ opacity: 0.65 }}>
|
|
||||||
<Badge
|
<Badge
|
||||||
label={`↑ ${findInheritingGroupName(r.id)} / ${r.name}`}
|
key={r.id}
|
||||||
|
label={`${r.name} ↑ group`}
|
||||||
|
color="warning"
|
||||||
variant="dashed"
|
variant="dashed"
|
||||||
|
className={styles.inherited}
|
||||||
/>
|
/>
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
|
{selected.directRoles.length === 0 &&
|
||||||
|
inheritedRoles.length === 0 && (
|
||||||
|
<span className={styles.inheritedNote}>(no roles)</span>
|
||||||
|
)}
|
||||||
|
<MultiSelect
|
||||||
|
options={availableRoles}
|
||||||
|
value={[]}
|
||||||
|
onChange={(roleIds) => {
|
||||||
|
for (const roleId of roleIds) {
|
||||||
|
assignRole.mutate(
|
||||||
|
{ userId: selected.userId, roleId },
|
||||||
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
toast({
|
||||||
|
title: 'Role assigned',
|
||||||
|
variant: 'success',
|
||||||
|
}),
|
||||||
|
onError: () =>
|
||||||
|
toast({
|
||||||
|
title: 'Failed to assign role',
|
||||||
|
variant: 'error',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{inheritedRoles.length > 0 && (
|
{inheritedRoles.length > 0 && (
|
||||||
<div className={styles.inheritedNote}>
|
<span className={styles.inheritedNote}>
|
||||||
Roles with ↑ are inherited through group membership
|
Roles with ↑ are inherited through group membership
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
{availableRoles.length > 0 && (
|
</>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
) : null
|
||||||
<Select
|
}
|
||||||
options={[{ value: '', label: 'Assign role…' }, ...availableRoles]}
|
emptyMessage="Select a user to view details"
|
||||||
value={addRoleId}
|
|
||||||
onChange={(e) => setAddRoleId(e.target.value)}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={!addRoleId}
|
|
||||||
onClick={handleAddRole}
|
|
||||||
loading={assignRole.isPending}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showDeleteDialog}
|
open={deleteTarget !== null}
|
||||||
onClose={() => setShowDeleteDialog(false)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
onConfirm={handleDeleteUser}
|
onConfirm={handleDelete}
|
||||||
title="Delete user"
|
message={`Delete user "${deleteTarget?.displayName}"? This cannot be undone.`}
|
||||||
message={`This will permanently delete the user "${selectedUser.displayName}". Type their username to confirm.`}
|
confirmText={deleteTarget?.displayName ?? ''}
|
||||||
confirmText={selectedUser.displayName}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
variant="danger"
|
|
||||||
loading={deleteUser.isPending}
|
loading={deleteUser.isPending}
|
||||||
/>
|
/>
|
||||||
|
<AlertDialog
|
||||||
|
open={removeGroupTarget !== null}
|
||||||
|
onClose={() => setRemoveGroupTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (removeGroupTarget && selected) {
|
||||||
|
removeFromGroup.mutate(
|
||||||
|
{ userId: selected.userId, groupId: removeGroupTarget },
|
||||||
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
toast({ title: 'Group removed', variant: 'success' }),
|
||||||
|
onError: () =>
|
||||||
|
toast({
|
||||||
|
title: 'Failed to remove group',
|
||||||
|
variant: 'error',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setRemoveGroupTarget(null);
|
||||||
|
}}
|
||||||
|
title="Remove group membership"
|
||||||
|
description="Removing this group may also revoke inherited roles. Continue?"
|
||||||
|
confirmLabel="Remove"
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
.statStrip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
@@ -5,13 +15,66 @@
|
|||||||
margin-bottom: 16px;
|
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 {
|
.scopeTrail {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
margin-bottom: 16px;
|
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 {
|
.groupGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -19,115 +82,131 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GroupCard meta strip */
|
.groupGridSingle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group meta row */
|
||||||
.groupMeta {
|
.groupMeta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 12px;
|
gap: 16px;
|
||||||
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupMeta strong {
|
.groupMeta strong {
|
||||||
color: var(--text-primary);
|
font-family: var(--font-mono);
|
||||||
}
|
color: var(--text-secondary);
|
||||||
|
|
||||||
/* Instance table */
|
|
||||||
.instanceTable {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceTable thead tr {
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceTable thead th {
|
|
||||||
padding: 6px 8px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thStatus {
|
/* Alert banner in group footer */
|
||||||
width: 24px;
|
.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 {
|
.alertIcon {
|
||||||
width: 24px;
|
font-size: 14px;
|
||||||
padding: 0 4px 0 8px;
|
flex-shrink: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Instance fields */
|
||||||
.instanceName {
|
.instanceName {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceMeta {
|
.instanceMeta {
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceError {
|
.instanceError {
|
||||||
font-size: 11px;
|
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
font-family: var(--font-mono);
|
white-space: nowrap;
|
||||||
}
|
|
||||||
|
|
||||||
.instanceHeartbeatDead {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--error);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceHeartbeatStale {
|
.instanceHeartbeatStale {
|
||||||
font-size: 11px;
|
|
||||||
color: var(--warning);
|
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);
|
color: var(--text-muted);
|
||||||
text-decoration: none;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
|
||||||
padding: 4px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceLink:hover {
|
.detailProgress {
|
||||||
color: var(--text-primary);
|
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 {
|
.eventCard {
|
||||||
|
margin-top: 20px;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -144,136 +223,4 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
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 { useState, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, Link } from 'react-router';
|
||||||
import {
|
import {
|
||||||
StatCard, StatusDot, Badge, MonoText,
|
StatCard, StatusDot, Badge, MonoText, ProgressBar,
|
||||||
GroupCard, EventFeed, Alert,
|
GroupCard, DataTable, LineChart, EventFeed, DetailPanel,
|
||||||
DetailPanel, ProgressBar, LineChart,
|
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
|
import type { Column, FeedEvent } from '@cameleer/design-system';
|
||||||
import styles from './AgentHealth.module.css';
|
import styles from './AgentHealth.module.css';
|
||||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
|
||||||
import { useAgentMetrics } from '../../api/queries/agent-metrics';
|
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 {
|
function formatUptime(seconds?: number): string {
|
||||||
if (!seconds) return '—';
|
if (!seconds) return '\u2014';
|
||||||
const days = Math.floor(seconds / 86400);
|
const days = Math.floor(seconds / 86400);
|
||||||
const hours = Math.floor((seconds % 86400) / 3600);
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
const mins = Math.floor((seconds % 3600) / 60);
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
@@ -20,18 +34,65 @@ function formatUptime(seconds?: number): string {
|
|||||||
return `${mins}m`;
|
return `${mins}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(iso?: string): string {
|
function formatErrorRate(rate?: number): string {
|
||||||
if (!iso) return '—';
|
if (rate == null) return '\u2014';
|
||||||
const diff = Date.now() - new Date(iso).getTime();
|
return `${(rate * 100).toFixed(1)}%`;
|
||||||
const mins = Math.floor(diff / 60000);
|
|
||||||
if (mins < 1) return 'just now';
|
|
||||||
if (mins < 60) return `${mins}m ago`;
|
|
||||||
const hours = Math.floor(mins / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
return `${Math.floor(hours / 24)}d ago`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentOverviewContent({ agent }: { agent: any }) {
|
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(
|
const { data: memMetrics } = useAgentMetrics(
|
||||||
agent.id,
|
agent.id,
|
||||||
['jvm.memory.heap.used', 'jvm.memory.heap.max'],
|
['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 heapUsed = memMetrics?.metrics?.['jvm.memory.heap.used']?.[0]?.value;
|
||||||
const heapMax = memMetrics?.metrics?.['jvm.memory.heap.max']?.[0]?.value;
|
const heapMax = memMetrics?.metrics?.['jvm.memory.heap.max']?.[0]?.value;
|
||||||
|
|
||||||
const heapPercent = heapUsed != null && heapMax != null && heapMax > 0
|
const heapPercent =
|
||||||
|
heapUsed != null && heapMax != null && heapMax > 0
|
||||||
? Math.round((heapUsed / heapMax) * 100)
|
? Math.round((heapUsed / heapMax) * 100)
|
||||||
: undefined;
|
: undefined;
|
||||||
const cpuPercent = cpuValue != null ? Math.round(cpuValue * 100) : undefined;
|
const cpuPercent = cpuValue != null ? Math.round(cpuValue * 100) : undefined;
|
||||||
|
|
||||||
const statusVariant: 'live' | 'stale' | 'dead' =
|
const ns = normalizeStatus(agent.status);
|
||||||
agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead';
|
|
||||||
const statusColor: 'success' | 'warning' | 'error' =
|
|
||||||
agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.overviewContent}>
|
<div className={styles.detailContent}>
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<StatusDot variant={statusVariant} />
|
|
||||||
<Badge label={agent.status} color={statusColor} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className={styles.detailList}>
|
|
||||||
<div className={styles.detailRow}>
|
<div className={styles.detailRow}>
|
||||||
<dt>Application</dt>
|
<span className={styles.detailLabel}>Status</span>
|
||||||
<dd><MonoText>{agent.application ?? '—'}</MonoText></dd>
|
<Badge label={agent.status} color={statusColor(ns)} variant="filled" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailRow}>
|
<div className={styles.detailRow}>
|
||||||
<dt>Version</dt>
|
<span className={styles.detailLabel}>Application</span>
|
||||||
<dd><MonoText>{agent.version ?? '—'}</MonoText></dd>
|
<MonoText size="xs">{agent.application}</MonoText>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailRow}>
|
<div className={styles.detailRow}>
|
||||||
<dt>Uptime</dt>
|
<span className={styles.detailLabel}>Uptime</span>
|
||||||
<dd>{formatUptime(agent.uptimeSeconds)}</dd>
|
<MonoText size="xs">{formatUptime(agent.uptimeSeconds)}</MonoText>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailRow}>
|
<div className={styles.detailRow}>
|
||||||
<dt>Last Heartbeat</dt>
|
<span className={styles.detailLabel}>Last Seen</span>
|
||||||
<dd>{formatRelativeTime(agent.lastHeartbeat)}</dd>
|
<MonoText size="xs">{timeAgo(agent.lastHeartbeat)}</MonoText>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailRow}>
|
<div className={styles.detailRow}>
|
||||||
<dt>TPS</dt>
|
<span className={styles.detailLabel}>Throughput</span>
|
||||||
<dd>{agent.tps != null ? (agent.tps as number).toFixed(2) : '—'}</dd>
|
<MonoText size="xs">{agent.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'}</MonoText>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailRow}>
|
<div className={styles.detailRow}>
|
||||||
<dt>Error Rate</dt>
|
<span className={styles.detailLabel}>Errors</span>
|
||||||
<dd>{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}</dd>
|
<MonoText size="xs" className={agent.errorRate ? styles.instanceError : undefined}>
|
||||||
|
{formatErrorRate(agent.errorRate)}
|
||||||
|
</MonoText>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailRow}>
|
<div className={styles.detailRow}>
|
||||||
<dt>Routes</dt>
|
<span className={styles.detailLabel}>Routes</span>
|
||||||
<dd>{agent.activeRoutes ?? '—'} active / {agent.totalRoutes ?? '—'} total</dd>
|
<span>{agent.activeRoutes ?? 0}/{agent.totalRoutes ?? 0} active</span>
|
||||||
</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>
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Heap Memory</span>
|
||||||
|
<div className={styles.detailProgress}>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
value={heapPercent}
|
value={heapPercent}
|
||||||
variant={heapPercent == null ? 'primary' : heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
|
variant={heapPercent == null ? 'primary' : heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
|
||||||
indeterminate={heapPercent == null}
|
indeterminate={heapPercent == null}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
<MonoText size="xs">{heapPercent != null ? `${heapPercent}%` : '\u2014'}</MonoText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.metricsSection}>
|
|
||||||
<div className={styles.metricLabel}>
|
|
||||||
CPU Usage{cpuPercent != null ? ` — ${cpuPercent}%` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>CPU</span>
|
||||||
|
<div className={styles.detailProgress}>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
value={cpuPercent}
|
value={cpuPercent}
|
||||||
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
|
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
|
||||||
indeterminate={cpuPercent == null}
|
indeterminate={cpuPercent == null}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
<MonoText size="xs">{cpuPercent != null ? `${cpuPercent}%` : '\u2014'}</MonoText>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentPerformanceContent({ agent }: { agent: any }) {
|
function AgentPerformanceContent({ agent }: { agent: AgentInstance }) {
|
||||||
const { data: tpsMetrics } = useAgentMetrics(agent.id, ['cameleer.tps'], 60);
|
const { data: tpsMetrics } = useAgentMetrics(agent.id, ['cameleer.tps'], 60);
|
||||||
const { data: errMetrics } = useAgentMetrics(agent.id, ['cameleer.error.rate'], 60);
|
const { data: errMetrics } = useAgentMetrics(agent.id, ['cameleer.error.rate'], 60);
|
||||||
|
|
||||||
const tpsSeries = useMemo(() => {
|
const tpsSeries = useMemo(() => {
|
||||||
const raw = tpsMetrics?.metrics?.['cameleer.tps'] ?? [];
|
const raw = tpsMetrics?.metrics?.['cameleer.tps'] ?? [];
|
||||||
return [{
|
return [{ label: 'TPS', data: raw.map((p) => ({ x: new Date(p.time), y: p.value })) }];
|
||||||
label: 'TPS',
|
|
||||||
data: raw.map((p) => ({ x: new Date(p.time), y: p.value })),
|
|
||||||
}];
|
|
||||||
}, [tpsMetrics]);
|
}, [tpsMetrics]);
|
||||||
|
|
||||||
const errSeries = useMemo(() => {
|
const errSeries = useMemo(() => {
|
||||||
@@ -137,24 +186,24 @@ function AgentPerformanceContent({ agent }: { agent: any }) {
|
|||||||
return [{
|
return [{
|
||||||
label: 'Error Rate',
|
label: 'Error Rate',
|
||||||
data: raw.map((p) => ({ x: new Date(p.time), y: p.value * 100 })),
|
data: raw.map((p) => ({ x: new Date(p.time), y: p.value * 100 })),
|
||||||
|
color: 'var(--error)',
|
||||||
}];
|
}];
|
||||||
}, [errMetrics]);
|
}, [errMetrics]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.performanceContent}>
|
<div className={styles.detailContent}>
|
||||||
<div className={styles.chartSection}>
|
<div className={styles.chartPanel}>
|
||||||
<div className={styles.chartLabel}>Throughput (TPS)</div>
|
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||||
{tpsSeries[0].data.length > 0 ? (
|
{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 className={styles.emptyChart}>No data available</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.chartPanel}>
|
||||||
<div className={styles.chartSection}>
|
<div className={styles.chartTitle}>Error Rate (%)</div>
|
||||||
<div className={styles.chartLabel}>Error Rate (%)</div>
|
|
||||||
{errSeries[0].data.length > 0 ? (
|
{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>
|
<div className={styles.emptyChart}>No data available</div>
|
||||||
)}
|
)}
|
||||||
@@ -163,197 +212,308 @@ function AgentPerformanceContent({ agent }: { agent: any }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AgentHealth page ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AgentHealth() {
|
export default function AgentHealth() {
|
||||||
const { appId } = useParams();
|
const { appId } = useParams();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data: agents } = useAgents(undefined, appId);
|
const { data: agents } = useAgents(undefined, appId);
|
||||||
const { data: catalog } = useRouteCatalog();
|
|
||||||
const { data: events } = useAgentEvents(appId);
|
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 agentList = agents ?? [];
|
||||||
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 liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length;
|
const groups = useMemo(() => groupByApp(agentList), [agentList]);
|
||||||
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 feedEvents = useMemo(() =>
|
// Aggregate stats
|
||||||
(events || []).map((e: any) => ({
|
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),
|
id: String(e.id),
|
||||||
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
|
severity:
|
||||||
: e.eventType === 'WENT_STALE' ? 'warning' as const
|
e.eventType === 'WENT_DEAD'
|
||||||
: e.eventType === 'RECOVERED' ? 'success' as const
|
? ('error' as const)
|
||||||
: 'running' as const,
|
: e.eventType === 'WENT_STALE'
|
||||||
message: `${e.agentId}: ${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
|
? ('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),
|
timestamp: new Date(e.timestamp),
|
||||||
})),
|
})),
|
||||||
[events],
|
[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 (
|
return (
|
||||||
<div>
|
<div className={styles.content}>
|
||||||
|
{/* Stat strip */}
|
||||||
<div className={styles.statStrip}>
|
<div className={styles.statStrip}>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Total Agents"
|
label="Total Agents"
|
||||||
value={(agents || []).length}
|
value={String(totalInstances)}
|
||||||
|
accent={deadCount > 0 ? 'warning' : 'amber'}
|
||||||
detail={
|
detail={
|
||||||
<span className={styles.statusBreakdown}>
|
<span className={styles.breakdown}>
|
||||||
<span className={styles.statusLive}>{liveCount} live</span>
|
<span className={styles.bpLive}><StatusDot variant="live" /> {liveCount} live</span>
|
||||||
<span className={styles.statusStale}>{staleCount} stale</span>
|
<span className={styles.bpStale}><StatusDot variant="stale" /> {staleCount} stale</span>
|
||||||
<span className={styles.statusDead}>{deadCount} dead</span>
|
<span className={styles.bpDead}><StatusDot variant="dead" /> {deadCount} dead</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StatCard label="Applications" value={uniqueApps} />
|
<StatCard
|
||||||
<StatCard label="Active Routes" value={activeRoutes} />
|
label="Applications"
|
||||||
<StatCard label="Total TPS" value={totalTps.toFixed(1)} detail="msg/s" />
|
value={String(groups.length)}
|
||||||
<StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} detail={deadCount > 0 ? 'requires attention' : undefined} />
|
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'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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>
|
</div>
|
||||||
|
|
||||||
|
{/* Scope trail + badges */}
|
||||||
<div className={styles.scopeTrail}>
|
<div className={styles.scopeTrail}>
|
||||||
<span className={styles.scopeLabel}>{liveCount}/{(agents || []).length} live</span>
|
{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>
|
</div>
|
||||||
|
|
||||||
<div className={styles.groupGrid}>
|
{/* Group cards grid */}
|
||||||
{Object.entries(apps).map(([group, groupAgents]) => {
|
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
|
||||||
const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD');
|
{groups.map((group) => (
|
||||||
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
|
<GroupCard
|
||||||
key={group}
|
key={group.appId}
|
||||||
title={group}
|
title={group.appId}
|
||||||
|
accent={appHealth(group)}
|
||||||
headerRight={
|
headerRight={
|
||||||
<Badge
|
<Badge
|
||||||
label={`${liveInGroup}/${groupAgents?.length ?? 0} LIVE`}
|
label={`${group.liveCount}/${group.instances.length} LIVE`}
|
||||||
color={
|
color={appHealth(group)}
|
||||||
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
|
|
||||||
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
|
|
||||||
: 'success'
|
|
||||||
}
|
|
||||||
variant="filled"
|
variant="filled"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
meta={
|
meta={
|
||||||
<div className={styles.groupMeta}>
|
<div className={styles.groupMeta}>
|
||||||
<span><strong>{groupTps.toFixed(1)}</strong> msg/s</span>
|
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
|
||||||
<span><strong>{groupActiveRoutes}</strong>/{groupTotalRoutes} routes</span>
|
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
|
||||||
|
<span>
|
||||||
|
<StatusDot
|
||||||
|
variant={
|
||||||
|
appHealth(group) === 'success'
|
||||||
|
? 'live'
|
||||||
|
: appHealth(group) === 'warning'
|
||||||
|
? 'stale'
|
||||||
|
: 'dead'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
accent={
|
footer={
|
||||||
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
|
group.deadCount > 0 ? (
|
||||||
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
|
<div className={styles.alertBanner}>
|
||||||
: 'success'
|
<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
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{deadInGroup.length > 0 && (
|
<DataTable<AgentInstance>
|
||||||
<Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
|
columns={instanceColumns}
|
||||||
)}
|
data={group.instances}
|
||||||
<table className={styles.instanceTable}>
|
onRowClick={handleInstanceClick}
|
||||||
<thead>
|
selectedId={panelOpen ? selectedInstance?.id : undefined}
|
||||||
<tr>
|
pageSize={50}
|
||||||
<th className={styles.thStatus} />
|
flush
|
||||||
<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>
|
</GroupCard>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* EventFeed */}
|
||||||
{feedEvents.length > 0 && (
|
{feedEvents.length > 0 && (
|
||||||
<div className={styles.eventCard}>
|
<div className={styles.eventCard}>
|
||||||
<div className={styles.eventCardHeader}>
|
<div className={styles.eventCardHeader}>
|
||||||
<span>Timeline</span>
|
<span className={styles.sectionTitle}>Timeline</span>
|
||||||
<Badge label={`${feedEvents.length} events`} variant="outlined" />
|
<span className={styles.sectionMeta}>{feedEvents.length} events</span>
|
||||||
</div>
|
</div>
|
||||||
<EventFeed events={feedEvents} maxItems={100} />
|
<EventFeed events={feedEvents} maxItems={100} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedAgent && (
|
{/* Detail panel */}
|
||||||
|
{selectedInstance && (
|
||||||
<DetailPanel
|
<DetailPanel
|
||||||
key={selectedAgent.id}
|
open={panelOpen}
|
||||||
open={true}
|
onClose={() => {
|
||||||
title={selectedAgent.name ?? selectedAgent.id}
|
setPanelOpen(false);
|
||||||
onClose={() => setSelectedAgent(null)}
|
setSelectedInstance(null);
|
||||||
className={styles.detailPanelOverride}
|
}}
|
||||||
>
|
title={selectedInstance.name ?? selectedInstance.id}
|
||||||
<AgentOverviewContent agent={selectedAgent} />
|
tabs={detailTabs}
|
||||||
<div className={styles.panelDivider} />
|
/>
|
||||||
<AgentPerformanceContent agent={selectedAgent} />
|
|
||||||
</DetailPanel>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 {
|
.statStrip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
@@ -5,18 +14,67 @@
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agentHeader {
|
/* Scope trail — matches /agents */
|
||||||
|
.scopeTrail {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 6px;
|
||||||
margin: 16px 0;
|
margin-bottom: 12px;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agentHeader h2 {
|
.scopeLink {
|
||||||
font-size: 18px;
|
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-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 {
|
.routeBadges {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -24,9 +82,10 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Charts 3x2 grid */
|
||||||
.chartsGrid {
|
.chartsGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
@@ -53,14 +112,46 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.chartMeta {
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
color: var(--text-muted);
|
||||||
color: var(--text-primary);
|
font-family: var(--font-mono);
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -69,107 +160,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eventCardHeader {
|
.timelineHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
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 { useMemo, useState } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams, Link } from 'react-router';
|
||||||
import {
|
import {
|
||||||
StatCard, StatusDot, Badge, Card,
|
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
|
||||||
LineChart, AreaChart, BarChart, EventFeed, Breadcrumb, Spinner, EmptyState,
|
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
|
||||||
|
LogViewer, Tabs, useGlobalFilters,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
|
import type { FeedEvent, LogEntry } from '@cameleer/design-system';
|
||||||
import styles from './AgentInstance.module.css';
|
import styles from './AgentInstance.module.css';
|
||||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||||
import { useStatsTimeseries } from '../../api/queries/executions';
|
import { useStatsTimeseries } from '../../api/queries/executions';
|
||||||
import { useAgentMetrics } from '../../api/queries/agent-metrics';
|
import { 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() {
|
export default function AgentInstance() {
|
||||||
const { appId, instanceId } = useParams();
|
const { appId, instanceId } = useParams();
|
||||||
const { timeRange } = useGlobalFilters();
|
const { timeRange } = useGlobalFilters();
|
||||||
|
const [logFilter, setLogFilter] = useState('all');
|
||||||
const timeFrom = timeRange.start.toISOString();
|
const timeFrom = timeRange.start.toISOString();
|
||||||
const timeTo = timeRange.end.toISOString();
|
const timeTo = timeRange.end.toISOString();
|
||||||
|
|
||||||
@@ -20,8 +28,8 @@ export default function AgentInstance() {
|
|||||||
const { data: events } = useAgentEvents(appId, instanceId);
|
const { data: events } = useAgentEvents(appId, instanceId);
|
||||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
||||||
|
|
||||||
const agent = useMemo(() =>
|
const agent = useMemo(
|
||||||
(agents || []).find((a: any) => a.id === instanceId) as any,
|
() => (agents || []).find((a: any) => a.id === instanceId) as any,
|
||||||
[agents, instanceId],
|
[agents, instanceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -43,7 +51,8 @@ export default function AgentInstance() {
|
|||||||
60,
|
60,
|
||||||
);
|
);
|
||||||
|
|
||||||
const chartData = useMemo(() =>
|
const chartData = useMemo(
|
||||||
|
() =>
|
||||||
(timeseries?.buckets || []).map((b: any) => ({
|
(timeseries?.buckets || []).map((b: any) => ({
|
||||||
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
throughput: b.totalCount,
|
throughput: b.totalCount,
|
||||||
@@ -53,14 +62,21 @@ export default function AgentInstance() {
|
|||||||
[timeseries],
|
[timeseries],
|
||||||
);
|
);
|
||||||
|
|
||||||
const feedEvents = useMemo(() =>
|
const feedEvents = useMemo<FeedEvent[]>(
|
||||||
(events || []).filter((e: any) => !instanceId || e.agentId === instanceId).map((e: any) => ({
|
() =>
|
||||||
|
(events || [])
|
||||||
|
.filter((e: any) => !instanceId || e.agentId === instanceId)
|
||||||
|
.map((e: any) => ({
|
||||||
id: String(e.id),
|
id: String(e.id),
|
||||||
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
|
severity:
|
||||||
: e.eventType === 'WENT_STALE' ? 'warning' as const
|
e.eventType === 'WENT_DEAD'
|
||||||
: e.eventType === 'RECOVERED' ? 'success' as const
|
? ('error' as const)
|
||||||
: 'running' as const,
|
: e.eventType === 'WENT_STALE'
|
||||||
message: `${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
|
? ('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),
|
timestamp: new Date(e.timestamp),
|
||||||
})),
|
})),
|
||||||
[events, instanceId],
|
[events, instanceId],
|
||||||
@@ -88,110 +104,167 @@ export default function AgentInstance() {
|
|||||||
const gcSeries = useMemo(() => {
|
const gcSeries = useMemo(() => {
|
||||||
const pts = jvmMetrics?.metrics?.['jvm.gc.time'];
|
const pts = jvmMetrics?.metrics?.['jvm.gc.time'];
|
||||||
if (!pts?.length) return null;
|
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]);
|
}, [jvmMetrics]);
|
||||||
|
|
||||||
const throughputSeries = useMemo(() =>
|
const throughputSeries = useMemo(
|
||||||
chartData.length ? [{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }] : null,
|
() =>
|
||||||
|
chartData.length
|
||||||
|
? [{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]
|
||||||
|
: null,
|
||||||
[chartData],
|
[chartData],
|
||||||
);
|
);
|
||||||
|
|
||||||
const errorSeries = useMemo(() =>
|
const errorSeries = useMemo(
|
||||||
chartData.length ? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }] : null,
|
() =>
|
||||||
|
chartData.length
|
||||||
|
? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }]
|
||||||
|
: null,
|
||||||
[chartData],
|
[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" />;
|
if (isLoading) return <Spinner size="lg" />;
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div className={styles.content}>
|
||||||
<Breadcrumb items={[
|
{/* Stat strip — 5 columns */}
|
||||||
{ label: 'Agents', href: '/agents' },
|
|
||||||
{ label: appId || '', href: `/agents/${appId}` },
|
|
||||||
{ label: agent?.name || instanceId || '' },
|
|
||||||
]} />
|
|
||||||
|
|
||||||
{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}>
|
<div className={styles.statStrip}>
|
||||||
<StatCard label="CPU" value={cpuPct != null ? `${(cpuPct * 100).toFixed(0)}%` : '—'} />
|
<StatCard
|
||||||
|
label="CPU"
|
||||||
|
value={cpuDisplay != null ? `${cpuDisplay}%` : '\u2014'}
|
||||||
|
accent={
|
||||||
|
cpuDisplay != null
|
||||||
|
? Number(cpuDisplay) > 85
|
||||||
|
? 'error'
|
||||||
|
: Number(cpuDisplay) > 70
|
||||||
|
? 'warning'
|
||||||
|
: 'success'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Memory"
|
label="Memory"
|
||||||
value={memPct != null ? `${memPct.toFixed(0)}%` : '—'}
|
value={memPct != null ? `${memPct.toFixed(0)}%` : '\u2014'}
|
||||||
detail={heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : undefined}
|
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="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
|
<StatCard
|
||||||
label="Uptime"
|
label="Uptime"
|
||||||
value={formatUptime(agent?.uptimeSeconds)}
|
value={formatUptime(agent?.uptimeSeconds)}
|
||||||
detail={agent?.registeredAt ? `since ${new Date(agent.registeredAt).toLocaleDateString()}` : undefined}
|
accent="running"
|
||||||
|
detail={
|
||||||
|
agent?.registeredAt
|
||||||
|
? `since ${new Date(agent.registeredAt).toLocaleDateString()}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scope trail + badges */}
|
||||||
|
{agent && (
|
||||||
|
<>
|
||||||
<div className={styles.scopeTrail}>
|
<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>
|
<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.scopeSep}>▸</span>
|
||||||
<span className={styles.scopeCurrent}>{agent.name}</span>
|
<span className={styles.scopeCurrent}>{agent.name}</span>
|
||||||
<Badge
|
<StatusDot variant={statusVariant} />
|
||||||
label={agent.status.toUpperCase()}
|
<Badge label={agent.status} color={statusColor} />
|
||||||
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
|
{agent.version && <Badge label={agent.version} variant="outlined" color="auto" />}
|
||||||
/>
|
|
||||||
{agent.version && <Badge label={agent.version} variant="outlined" />}
|
|
||||||
<Badge
|
<Badge
|
||||||
label={`${agent.activeRoutes ?? (agent.routeIds?.length ?? 0)}/${agent.totalRoutes ?? (agent.routeIds?.length ?? 0)} routes`}
|
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>
|
</div>
|
||||||
|
|
||||||
<Card className={styles.infoCard}>
|
{/* Process info card */}
|
||||||
<div className={styles.paneTitle}>Process Information</div>
|
<div className={styles.processCard}>
|
||||||
<div className={styles.infoGrid}>
|
<SectionHeader>Process Information</SectionHeader>
|
||||||
{agent?.capabilities?.jvmVersion && (
|
<div className={styles.processGrid}>
|
||||||
<div>
|
{agent.capabilities?.jvmVersion && (
|
||||||
<span className={styles.infoLabel}>JVM</span>
|
<>
|
||||||
<span>{agent.capabilities.jvmVersion}</span>
|
<span className={styles.processLabel}>JVM</span>
|
||||||
</div>
|
<MonoText size="xs">{agent.capabilities.jvmVersion}</MonoText>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{agent?.capabilities?.camelVersion && (
|
{agent.capabilities?.camelVersion && (
|
||||||
<div>
|
<>
|
||||||
<span className={styles.infoLabel}>Camel</span>
|
<span className={styles.processLabel}>Camel</span>
|
||||||
<span>{agent.capabilities.camelVersion}</span>
|
<MonoText size="xs">{agent.capabilities.camelVersion}</MonoText>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
{agent?.capabilities?.springBootVersion && (
|
{agent.capabilities?.springBootVersion && (
|
||||||
<div>
|
<>
|
||||||
<span className={styles.infoLabel}>Spring Boot</span>
|
<span className={styles.processLabel}>Spring Boot</span>
|
||||||
<span>{agent.capabilities.springBootVersion}</span>
|
<MonoText size="xs">{agent.capabilities.springBootVersion}</MonoText>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
<div>
|
<span className={styles.processLabel}>Started</span>
|
||||||
<span className={styles.infoLabel}>Started</span>
|
<MonoText size="xs">
|
||||||
<span>{agent?.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '—'}</span>
|
{agent.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '\u2014'}
|
||||||
</div>
|
</MonoText>
|
||||||
<div>
|
{agent.capabilities && (
|
||||||
<span className={styles.infoLabel}>Capabilities</span>
|
<>
|
||||||
|
<span className={styles.processLabel}>Capabilities</span>
|
||||||
<span className={styles.capTags}>
|
<span className={styles.capTags}>
|
||||||
{Object.entries(agent?.capabilities || {})
|
{Object.entries(agent.capabilities)
|
||||||
.filter(([, v]) => typeof v === 'boolean' && v)
|
.filter(([, v]) => typeof v === 'boolean' && v)
|
||||||
.map(([k]) => (
|
.map(([k]) => (
|
||||||
<Badge key={k} label={k} variant="outlined" />
|
<Badge key={k} label={k} variant="outlined" />
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className={styles.sectionTitle}>Routes</div>
|
{/* Routes */}
|
||||||
|
{(agent.routeIds?.length ?? 0) > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionHeader>Routes</SectionHeader>
|
||||||
<div className={styles.routeBadges}>
|
<div className={styles.routeBadges}>
|
||||||
{(agent.routeIds || []).map((r: string) => (
|
{(agent.routeIds || []).map((r: string) => (
|
||||||
<Badge key={r} label={r} color="auto" />
|
<Badge key={r} label={r} color="auto" />
|
||||||
@@ -199,83 +272,137 @@ export default function AgentInstance() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Charts grid — 3x2 */}
|
||||||
<div className={styles.chartsGrid}>
|
<div className={styles.chartsGrid}>
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartHeader}>
|
<div className={styles.chartHeader}>
|
||||||
<div className={styles.chartTitle}>CPU Usage</div>
|
<span className={styles.chartTitle}>CPU Usage</span>
|
||||||
<div className={styles.chartMeta}>{cpuPct != null ? `${(cpuPct * 100).toFixed(0)}% current` : ''}</div>
|
<span className={styles.chartMeta}>
|
||||||
|
{cpuDisplay != null ? `${cpuDisplay}% current` : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{cpuSeries
|
{cpuSeries ? (
|
||||||
? <AreaChart series={cpuSeries} yLabel="%" height={200} />
|
<AreaChart
|
||||||
: <EmptyState title="No data" description="No CPU metrics available" />}
|
series={cpuSeries}
|
||||||
|
height={160}
|
||||||
|
yLabel="%"
|
||||||
|
threshold={{ value: 85, label: 'Alert' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No CPU metrics available" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartHeader}>
|
<div className={styles.chartHeader}>
|
||||||
<div className={styles.chartTitle}>Memory (Heap)</div>
|
<span className={styles.chartTitle}>Memory (Heap)</span>
|
||||||
<div className={styles.chartMeta}>{heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : ''}</div>
|
<span className={styles.chartMeta}>
|
||||||
|
{heapUsedMB != null && heapMaxMB != null
|
||||||
|
? `${heapUsedMB} MB / ${heapMaxMB} MB`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{heapSeries
|
{heapSeries ? (
|
||||||
? <AreaChart series={heapSeries} yLabel="MB" height={200} />
|
<AreaChart series={heapSeries} height={160} yLabel="MB" />
|
||||||
: <EmptyState title="No data" description="No heap metrics available" />}
|
) : (
|
||||||
|
<EmptyState title="No data" description="No heap metrics available" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartHeader}>
|
<div className={styles.chartHeader}>
|
||||||
<div className={styles.chartTitle}>Throughput</div>
|
<span className={styles.chartTitle}>Throughput</span>
|
||||||
<div className={styles.chartMeta}>{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}</div>
|
<span className={styles.chartMeta}>
|
||||||
|
{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{throughputSeries
|
{throughputSeries ? (
|
||||||
? <AreaChart series={throughputSeries} yLabel="msg/s" height={200} />
|
<LineChart series={throughputSeries} height={160} yLabel="msg/s" />
|
||||||
: <EmptyState title="No data" description="No throughput data in range" />}
|
) : (
|
||||||
|
<EmptyState title="No data" description="No throughput data in range" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartHeader}>
|
<div className={styles.chartHeader}>
|
||||||
<div className={styles.chartTitle}>Error Rate</div>
|
<span className={styles.chartTitle}>Error Rate</span>
|
||||||
<div className={styles.chartMeta}>{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}</div>
|
<span className={styles.chartMeta}>
|
||||||
|
{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{errorSeries
|
{errorSeries ? (
|
||||||
? <LineChart series={errorSeries} yLabel="%" height={200} />
|
<LineChart series={errorSeries} height={160} yLabel="err/h" />
|
||||||
: <EmptyState title="No data" description="No error data in range" />}
|
) : (
|
||||||
|
<EmptyState title="No data" description="No error data in range" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartHeader}>
|
<div className={styles.chartHeader}>
|
||||||
<div className={styles.chartTitle}>Thread Count</div>
|
<span className={styles.chartTitle}>Thread Count</span>
|
||||||
{threadSeries && <div className={styles.chartMeta}>{threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active</div>}
|
<span className={styles.chartMeta}>
|
||||||
</div>
|
|
||||||
{threadSeries
|
{threadSeries
|
||||||
? <LineChart series={threadSeries} yLabel="threads" height={200} />
|
? `${threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active`
|
||||||
: <EmptyState title="No data" description="No thread metrics available" />}
|
: ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{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.chartCard}>
|
||||||
<div className={styles.chartHeader}>
|
<div className={styles.chartHeader}>
|
||||||
<div className={styles.chartTitle}>GC Pauses</div>
|
<span className={styles.chartTitle}>GC Pauses</span>
|
||||||
|
<span className={styles.chartMeta} />
|
||||||
</div>
|
</div>
|
||||||
{gcSeries
|
{gcSeries ? (
|
||||||
? <BarChart series={gcSeries} yLabel="ms" height={200} />
|
<BarChart series={gcSeries} height={160} yLabel="ms" />
|
||||||
: <EmptyState title="No data" description="No GC metrics available" />}
|
) : (
|
||||||
|
<EmptyState title="No data" description="No GC metrics available" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.bottomSection}>
|
{/* Log + Timeline side by side */}
|
||||||
<EmptyState title="Application Log" description="Application log streaming is not yet available" />
|
<div className={styles.bottomRow}>
|
||||||
|
<div className={styles.logCard}>
|
||||||
<div className={styles.eventCard}>
|
<div className={styles.logHeader}>
|
||||||
<div className={styles.eventCardHeader}>
|
<SectionHeader>Application Log</SectionHeader>
|
||||||
<span>Timeline</span>
|
<Tabs tabs={LOG_TABS} active={logFilter} onChange={setLogFilter} />
|
||||||
<span className={styles.eventCount}>{feedEvents.length} events</span>
|
|
||||||
</div>
|
</div>
|
||||||
{feedEvents.length > 0
|
{filteredLogs.length > 0 ? (
|
||||||
? <EventFeed events={feedEvents} maxItems={50} />
|
<LogViewer entries={filteredLogs} maxHeight={360} />
|
||||||
: <div className={styles.emptyEvents}>No events in the selected time range.</div>}
|
) : (
|
||||||
|
<div className={styles.logEmpty}>
|
||||||
|
Application log streaming is not yet available.
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUptime(seconds?: number): string {
|
function formatUptime(seconds?: number): string {
|
||||||
if (!seconds) return '—';
|
if (!seconds) return '\u2014';
|
||||||
const days = Math.floor(seconds / 86400);
|
const days = Math.floor(seconds / 86400);
|
||||||
const hours = Math.floor((seconds % 86400) / 3600);
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
const mins = Math.floor((seconds % 3600) / 60);
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
.healthStrip {
|
/* Scrollable content area */
|
||||||
display: grid;
|
.content {
|
||||||
grid-template-columns: repeat(5, 1fr);
|
flex: 1;
|
||||||
gap: 10px;
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px 40px;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter bar spacing */
|
||||||
|
.filterBar {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Table section */
|
||||||
.tableSection {
|
.tableSection {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@@ -39,6 +47,93 @@
|
|||||||
font-family: var(--font-mono);
|
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 {
|
.panelSection {
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -59,19 +154,21 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelSectionMeta {
|
.panelSectionMeta {
|
||||||
font-size: 11px;
|
margin-left: auto;
|
||||||
font-weight: 400;
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-faint);
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Overview grid */
|
||||||
.overviewGrid {
|
.overviewGrid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -95,45 +192,67 @@
|
|||||||
padding-top: 2px;
|
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 {
|
.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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 24px;
|
transition: color 0.15s, opacity 0.15s;
|
||||||
height: 24px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 4px;
|
|
||||||
transition: color 0.15s, background 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inspectLink:hover {
|
.inspectLink:hover {
|
||||||
color: var(--accent, #c6820e);
|
color: var(--text-primary);
|
||||||
background: var(--bg-hover);
|
opacity: 1;
|
||||||
}
|
|
||||||
|
|
||||||
.detailPanelOverride {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 100vh;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Open full details link in panel */
|
||||||
.openDetailLink {
|
.openDetailLink {
|
||||||
display: inline-block;
|
background: transparent;
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent, #c6820e);
|
|
||||||
cursor: pointer;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
border: none;
|
||||||
|
color: var(--amber);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-decoration: none;
|
font-family: var(--font-body);
|
||||||
|
transition: color 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openDetailLink:hover {
|
.openDetailLink:hover {
|
||||||
|
color: var(--amber-deep);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,186 +1,417 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
StatCard, StatusDot, Badge, MonoText,
|
DataTable,
|
||||||
DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
|
DetailPanel,
|
||||||
Alert, Collapsible, CodeBlock, ShortcutsBar,
|
ShortcutsBar,
|
||||||
} from '@cameleer/design-system';
|
ProcessorTimeline,
|
||||||
import type { Column } from '@cameleer/design-system';
|
RouteFlow,
|
||||||
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail } from '../../api/queries/executions';
|
KpiStrip,
|
||||||
import { useDiagramLayout } from '../../api/queries/diagrams';
|
StatusDot,
|
||||||
import { useGlobalFilters } from '@cameleer/design-system';
|
MonoText,
|
||||||
import type { ExecutionSummary } from '../../api/types';
|
Badge,
|
||||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
useGlobalFilters,
|
||||||
import styles from './Dashboard.module.css';
|
} 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 }
|
// Row type extends ExecutionSummary with an `id` field for DataTable
|
||||||
|
interface Row extends ExecutionSummary {
|
||||||
function formatDuration(ms: number): string {
|
id: string
|
||||||
if (ms < 1000) return `${ms}ms`;
|
|
||||||
return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
const { appId, routeId } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { timeRange } = useGlobalFilters();
|
|
||||||
const timeFrom = timeRange.start.toISOString();
|
|
||||||
const timeTo = timeRange.end.toISOString();
|
|
||||||
|
|
||||||
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);
|
function statusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
|
||||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
switch (status) {
|
||||||
const { data: searchResult } = useSearchExecutions({
|
case 'COMPLETED': return 'success'
|
||||||
timeFrom, timeTo,
|
case 'FAILED': return 'error'
|
||||||
routeId: routeId || undefined,
|
case 'RUNNING': return 'running'
|
||||||
application: appId || undefined,
|
default: return 'warning'
|
||||||
offset: 0, limit: 50,
|
}
|
||||||
}, true);
|
}
|
||||||
const { data: detail } = useExecutionDetail(selectedId);
|
|
||||||
|
|
||||||
const rows: Row[] = useMemo(() =>
|
function statusLabel(status: string): string {
|
||||||
(searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
switch (status) {
|
||||||
[searchResult],
|
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;
|
function flattenProcessors(nodes: any[]): any[] {
|
||||||
const failedCount = stats?.failedCount ?? 0;
|
const result: any[] = []
|
||||||
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount * 100) : 100;
|
let offset = 0
|
||||||
const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 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(() =>
|
// ─── Table columns (base, without inspect action) ────────────────────────────
|
||||||
(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 prevTotal = stats?.prevTotalCount ?? 0;
|
function buildBaseColumns(): Column<Row>[] {
|
||||||
const prevFailed = stats?.prevFailedCount ?? 0;
|
return [
|
||||||
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>[] = [
|
|
||||||
{
|
{
|
||||||
key: 'status', header: 'Status', width: '80px',
|
key: 'status',
|
||||||
render: (v, row) => (
|
header: 'Status',
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
width: '80px',
|
||||||
<StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />
|
render: (_: unknown, row: Row) => (
|
||||||
<MonoText size="xs">{v === 'COMPLETED' ? 'OK' : v === 'FAILED' ? 'ERR' : 'RUN'}</MonoText>
|
<span className={styles.statusCell}>
|
||||||
|
<StatusDot variant={statusToVariant(row.status)} />
|
||||||
|
<MonoText size="xs">{statusLabel(row.status)}</MonoText>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '_inspect' as any, header: '', width: '36px',
|
key: 'routeId',
|
||||||
render: (_v, row) => (
|
header: 'Route',
|
||||||
<a
|
sortable: true,
|
||||||
href={`/exchanges/${row.executionId}`}
|
render: (_: unknown, row: Row) => (
|
||||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); navigate(`/exchanges/${row.executionId}`); }}
|
<span className={styles.routeName}>{row.routeId}</span>
|
||||||
className={styles.inspectLink}
|
|
||||||
title="Open full details"
|
|
||||||
>↗</a>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ 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,
|
key: 'applicationName',
|
||||||
render: (v) => <MonoText size="sm">{formatDuration(v as number)}</MonoText>,
|
header: 'Application',
|
||||||
|
sortable: true,
|
||||||
|
render: (_: unknown, row: Row) => (
|
||||||
|
<span className={styles.appName}>{row.applicationName ?? ''}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'agentId', header: 'Agent',
|
key: 'executionId',
|
||||||
render: (v) => v ? <Badge label={String(v)} color="auto" /> : null,
|
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 (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className={styles.healthStrip}>
|
{/* Scrollable content */}
|
||||||
<StatCard
|
<div className={styles.content}>
|
||||||
label="Exchanges"
|
{/* KPI strip */}
|
||||||
value={totalCount.toLocaleString()}
|
<KpiStrip items={kpiItems} />
|
||||||
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>
|
|
||||||
|
|
||||||
|
{/* Exchanges table */}
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
<div className={styles.tableHeader}>
|
<div className={styles.tableHeader}>
|
||||||
<span className={styles.tableTitle}>Recent Exchanges</span>
|
<span className={styles.tableTitle}>Recent Exchanges</span>
|
||||||
<div className={styles.tableRight}>
|
<div className={styles.tableRight}>
|
||||||
<span className={styles.tableMeta}>{rows.length} of {searchResult?.total ?? 0} exchanges</span>
|
<span className={styles.tableMeta}>
|
||||||
|
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
|
||||||
|
</span>
|
||||||
<Badge label="LIVE" color="success" />
|
<Badge label="LIVE" color="success" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
onRowClick={(row) => { setSelectedId(row.id); }}
|
onRowClick={handleRowClick}
|
||||||
selectedId={selectedId ?? undefined}
|
selectedId={selectedId}
|
||||||
sortable
|
sortable
|
||||||
pageSize={25}
|
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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedId && detail && (
|
{/* Shortcuts bar */}
|
||||||
|
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||||
|
|
||||||
|
{/* Detail panel */}
|
||||||
|
{selectedRow && detail && (
|
||||||
<DetailPanel
|
<DetailPanel
|
||||||
key={selectedId}
|
open={panelOpen}
|
||||||
open={true}
|
onClose={() => setPanelOpen(false)}
|
||||||
onClose={() => setSelectedId(null)}
|
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
|
||||||
title={`${detail.routeId} — ${selectedId.slice(0, 12)}`}
|
|
||||||
className={styles.detailPanelOverride}
|
|
||||||
>
|
>
|
||||||
{/* Open full details link */}
|
{/* Link to full detail page */}
|
||||||
<div className={styles.panelSection}>
|
<div className={styles.panelSection}>
|
||||||
<button
|
<button
|
||||||
className={styles.openDetailLink}
|
className={styles.openDetailLink}
|
||||||
@@ -196,9 +427,9 @@ export default function Dashboard() {
|
|||||||
<div className={styles.overviewGrid}>
|
<div className={styles.overviewGrid}>
|
||||||
<div className={styles.overviewRow}>
|
<div className={styles.overviewRow}>
|
||||||
<span className={styles.overviewLabel}>Status</span>
|
<span className={styles.overviewLabel}>Status</span>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<span className={styles.statusCell}>
|
||||||
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : 'error'} />
|
<StatusDot variant={statusToVariant(detail.status)} />
|
||||||
<span>{detail.status}</span>
|
<span>{statusLabel(detail.status)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.overviewRow}>
|
<div className={styles.overviewRow}>
|
||||||
@@ -211,44 +442,38 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.overviewRow}>
|
<div className={styles.overviewRow}>
|
||||||
<span className={styles.overviewLabel}>Agent</span>
|
<span className={styles.overviewLabel}>Agent</span>
|
||||||
<MonoText size="sm">{detail.agentId ?? '—'}</MonoText>
|
<MonoText size="sm">{detail.agentId ?? '\u2014'}</MonoText>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.overviewRow}>
|
<div className={styles.overviewRow}>
|
||||||
<span className={styles.overviewLabel}>Correlation</span>
|
<span className={styles.overviewLabel}>Correlation</span>
|
||||||
<MonoText size="xs">{detail.correlationId ?? '—'}</MonoText>
|
<MonoText size="xs">{detail.correlationId ?? '\u2014'}</MonoText>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.overviewRow}>
|
<div className={styles.overviewRow}>
|
||||||
<span className={styles.overviewLabel}>Timestamp</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Errors */}
|
{/* Errors */}
|
||||||
{detail.errorMessage && (
|
{errorMsg && (
|
||||||
<div className={styles.panelSection}>
|
<div className={styles.panelSection}>
|
||||||
<div className={styles.panelSectionTitle}>Errors</div>
|
<div className={styles.panelSectionTitle}>Errors</div>
|
||||||
<Alert variant="error">
|
<div className={styles.errorBlock}>
|
||||||
<strong>{detail.errorMessage.split(':')[0]}</strong>
|
<div className={styles.errorClass}>{errorClass}</div>
|
||||||
<div>{detail.errorMessage.includes(':') ? detail.errorMessage.substring(detail.errorMessage.indexOf(':') + 1).trim() : ''}</div>
|
<div className={styles.errorMessage}>{errorMsg}</div>
|
||||||
</Alert>
|
</div>
|
||||||
{detail.errorStackTrace && (
|
|
||||||
<Collapsible title="Stack Trace">
|
|
||||||
<CodeBlock content={detail.errorStackTrace} />
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Route Flow */}
|
{/* Route Flow */}
|
||||||
<div className={styles.panelSection}>
|
<div className={styles.panelSection}>
|
||||||
<div className={styles.panelSectionTitle}>Route Flow</div>
|
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||||
{diagram ? (
|
{routeNodes.length > 0 ? (
|
||||||
<RouteFlow
|
<RouteFlow nodes={routeNodes} />
|
||||||
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
|
) : (
|
||||||
onNodeClick={(_node, _i) => {}}
|
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
|
||||||
/>
|
)}
|
||||||
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Processor Timeline */}
|
{/* Processor Timeline */}
|
||||||
@@ -257,33 +482,17 @@ export default function Dashboard() {
|
|||||||
Processor Timeline
|
Processor Timeline
|
||||||
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
|
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
|
||||||
</div>
|
</div>
|
||||||
{procList.length ? (
|
{flatProcs.length > 0 ? (
|
||||||
<ProcessorTimeline
|
<ProcessorTimeline
|
||||||
processors={flattenProcessors(procList)}
|
processors={flatProcs}
|
||||||
totalMs={detail.durationMs}
|
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>
|
</div>
|
||||||
</DetailPanel>
|
</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 {
|
.exchangeHeader {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@@ -38,14 +56,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.routeLink {
|
.routeLink {
|
||||||
color: var(--accent, #c6820e);
|
color: var(--amber);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.routeLink:hover {
|
.routeLink:hover {
|
||||||
color: var(--amber-deep, #a36b0b);
|
color: var(--amber-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerDivider {
|
.headerDivider {
|
||||||
@@ -78,7 +96,9 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Correlation Chain */
|
/* ==========================================================================
|
||||||
|
CORRELATION CHAIN
|
||||||
|
========================================================================== */
|
||||||
.correlationChain {
|
.correlationChain {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -104,7 +124,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -120,20 +140,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chainNodeCurrent {
|
.chainNodeCurrent {
|
||||||
background: var(--amber-bg, rgba(198, 130, 14, 0.08));
|
background: var(--amber-bg);
|
||||||
border-color: var(--accent, #c6820e);
|
border-color: var(--amber-light);
|
||||||
color: var(--accent, #c6820e);
|
color: var(--amber-deep);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chainNodeSuccess { border-left: 3px solid var(--success); }
|
.chainNodeSuccess {
|
||||||
.chainNodeError { border-left: 3px solid var(--error); }
|
border-left: 3px solid var(--success);
|
||||||
.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; }
|
.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 {
|
.timelineSection {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@@ -174,7 +211,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,20 +231,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toggleBtnActive {
|
.toggleBtnActive {
|
||||||
background: var(--accent, #c6820e);
|
background: var(--amber);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggleBtnActive:hover {
|
.toggleBtnActive:hover {
|
||||||
background: var(--amber-deep, #a36b0b);
|
background: var(--amber-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timelineBody {
|
.timelineBody {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail Split (IN / OUT panels) */
|
/* ==========================================================================
|
||||||
|
DETAIL SPLIT (IN / OUT panels)
|
||||||
|
========================================================================== */
|
||||||
.detailSplit {
|
.detailSplit {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -224,7 +263,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detailPanelError {
|
.detailPanelError {
|
||||||
border-color: var(--error-border, rgba(220, 38, 38, 0.3));
|
border-color: var(--error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelHeader {
|
.panelHeader {
|
||||||
@@ -238,8 +277,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detailPanelError .panelHeader {
|
.detailPanelError .panelHeader {
|
||||||
background: var(--error-bg, rgba(220, 38, 38, 0.06));
|
background: var(--error-bg);
|
||||||
border-bottom-color: var(--error-border, rgba(220, 38, 38, 0.3));
|
border-bottom-color: var(--error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelTitle {
|
.panelTitle {
|
||||||
@@ -350,14 +389,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Error panel styles */
|
/* 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 {
|
.errorMessageBox {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--error-bg, rgba(220, 38, 38, 0.06));
|
background: var(--error-bg);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--error-border, rgba(220, 38, 38, 0.3));
|
border: 1px solid var(--error-border);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -382,3 +440,11 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
word-break: break-all;
|
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 { useState, useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
|
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
|
||||||
ProcessorTimeline, Breadcrumb, Spinner, RouteFlow,
|
ProcessorTimeline, Breadcrumb, Spinner, RouteFlow,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system'
|
||||||
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
|
import type { ProcessorStep, RouteNode } from '@cameleer/design-system'
|
||||||
import { useCorrelationChain } from '../../api/queries/correlation';
|
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
|
||||||
import { useDiagramLayout } from '../../api/queries/diagrams';
|
import { useCorrelationChain } from '../../api/queries/correlation'
|
||||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
import { useDiagramLayout } from '../../api/queries/diagrams'
|
||||||
import styles from './ExchangeDetail.module.css';
|
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
|
||||||
|
import styles from './ExchangeDetail.module.css'
|
||||||
|
|
||||||
function countProcessors(nodes: any[]): number {
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0);
|
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 {
|
function backendStatusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
|
||||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
|
switch (status.toUpperCase()) {
|
||||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
|
case 'COMPLETED': return 'success'
|
||||||
return `${ms}ms`;
|
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> {
|
function parseHeaders(raw: string | undefined | null): Record<string, string> {
|
||||||
if (!raw) return {};
|
if (!raw) return {}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw)
|
||||||
if (typeof parsed === 'object' && parsed !== null) {
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {}
|
||||||
for (const [k, v] of Object.entries(parsed)) {
|
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 */ }
|
} 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() {
|
export default function ExchangeDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate();
|
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 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 [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
|
||||||
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 [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null);
|
const procList = detail
|
||||||
const activeIndex = selectedProcessorIndex ?? defaultIndex;
|
? (detail.processors?.length ? detail.processors : (detail.children ?? []))
|
||||||
|
: []
|
||||||
|
|
||||||
const { data: snapshot } = useProcessorSnapshot(id ?? null, procList.length > 0 ? activeIndex : null);
|
// Flatten processor tree into ProcessorStep[]
|
||||||
|
const processors: ProcessorStep[] = useMemo(() => {
|
||||||
const processors = useMemo(() => {
|
if (!procList.length) return []
|
||||||
if (!procList.length) return [];
|
const result: ProcessorStep[] = []
|
||||||
const result: any[] = [];
|
let offset = 0
|
||||||
let offset = 0;
|
|
||||||
function walk(node: any) {
|
function walk(node: any) {
|
||||||
result.push({
|
result.push({
|
||||||
name: node.processorId || node.processorType,
|
name: node.processorId || node.processorType,
|
||||||
type: node.processorType,
|
type: node.processorType,
|
||||||
durationMs: node.durationMs ?? 0,
|
durationMs: node.durationMs ?? 0,
|
||||||
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
|
status: procStatusToStep(node.status ?? ''),
|
||||||
startMs: offset,
|
startMs: offset,
|
||||||
});
|
})
|
||||||
offset += node.durationMs ?? 0;
|
offset += node.durationMs ?? 0
|
||||||
if (node.children) node.children.forEach(walk);
|
if (node.children) node.children.forEach(walk)
|
||||||
}
|
}
|
||||||
procList.forEach(walk);
|
procList.forEach(walk)
|
||||||
return result;
|
return result
|
||||||
}, [procList]);
|
}, [procList])
|
||||||
|
|
||||||
const selectedProc = processors[activeIndex];
|
// Default selected processor: first failed, or 0
|
||||||
const isSelectedFailed = selectedProc?.status === 'fail';
|
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 [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null)
|
||||||
const inputHeaders = parseHeaders(snapshot?.inputHeaders);
|
const activeIndex = selectedProcessorIndex ?? defaultIndex
|
||||||
const outputHeaders = parseHeaders(snapshot?.outputHeaders);
|
|
||||||
const inputBody = snapshot?.inputBody ?? null;
|
|
||||||
const outputBody = snapshot?.outputBody ?? null;
|
|
||||||
|
|
||||||
if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
|
const { data: snapshot } = useProcessorSnapshot(
|
||||||
if (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>;
|
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 (
|
return (
|
||||||
<div>
|
<div className={styles.content}>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
<Breadcrumb items={[
|
<Breadcrumb items={[
|
||||||
{ label: 'Dashboard', href: '/apps' },
|
{ label: 'Applications', href: '/apps' },
|
||||||
{ label: detail.applicationName || 'App', href: `/apps/${detail.applicationName}` },
|
{ 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 */}
|
{/* Exchange header card */}
|
||||||
<div className={styles.exchangeHeader}>
|
<div className={styles.exchangeHeader}>
|
||||||
<div className={styles.headerRow}>
|
<div className={styles.headerRow}>
|
||||||
<div className={styles.headerLeft}>
|
<div className={styles.headerLeft}>
|
||||||
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
|
<StatusDot variant={statusVariant} />
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.exchangeId}>
|
<div className={styles.exchangeId}>
|
||||||
<MonoText size="md">{id}</MonoText>
|
<MonoText size="md">{detail.executionId}</MonoText>
|
||||||
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} variant="filled" />
|
<Badge label={statusLabel} color={statusVariant} variant="filled" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.exchangeRoute}>
|
<div className={styles.exchangeRoute}>
|
||||||
Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${detail.applicationName}/${detail.routeId}`)}>{detail.routeId}</span>
|
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>
|
App: <MonoText size="xs">{detail.applicationName}</MonoText>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{detail.correlationId && (
|
||||||
|
<>
|
||||||
|
<span className={styles.headerDivider}>·</span>
|
||||||
|
Correlation: <MonoText size="xs">{detail.correlationId}</MonoText>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +212,9 @@ export default function ExchangeDetail() {
|
|||||||
<div className={styles.headerStat}>
|
<div className={styles.headerStat}>
|
||||||
<div className={styles.headerStatLabel}>Started</div>
|
<div className={styles.headerStatLabel}>Started</div>
|
||||||
<div className={styles.headerStatValue}>
|
<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>
|
</div>
|
||||||
<div className={styles.headerStat}>
|
<div className={styles.headerStat}>
|
||||||
@@ -142,43 +225,39 @@ export default function ExchangeDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Correlation Chain */}
|
{/* Correlation Chain */}
|
||||||
{correlationData?.data && correlationData.data.length > 1 && (
|
{correlatedExchanges.length > 1 && (
|
||||||
<div className={styles.correlationChain}>
|
<div className={styles.correlationChain}>
|
||||||
<span className={styles.chainLabel}>Correlated Exchanges</span>
|
<span className={styles.chainLabel}>Correlated Exchanges</span>
|
||||||
{correlationData.data.map((exec: any) => {
|
{correlatedExchanges.map((ce) => {
|
||||||
const isCurrent = exec.executionId === id;
|
const isCurrent = ce.executionId === id
|
||||||
const variant = exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'running';
|
const variant = backendStatusToVariant(ce.status)
|
||||||
const statusCls =
|
const statusCls =
|
||||||
variant === 'success' ? styles.chainNodeSuccess
|
variant === 'success' ? styles.chainNodeSuccess
|
||||||
: variant === 'error' ? styles.chainNodeError
|
: variant === 'error' ? styles.chainNodeError
|
||||||
: styles.chainNodeRunning;
|
: variant === 'running' ? styles.chainNodeRunning
|
||||||
|
: styles.chainNodeWarning
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={exec.executionId}
|
key={ce.executionId}
|
||||||
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
|
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
|
||||||
onClick={() => { if (!isCurrent) navigate(`/exchanges/${exec.executionId}`); }}
|
onClick={() => {
|
||||||
title={`${exec.executionId} — ${exec.routeId}`}
|
if (!isCurrent) navigate(`/exchanges/${ce.executionId}`)
|
||||||
|
}}
|
||||||
|
title={`${ce.executionId} \u2014 ${ce.routeId}`}
|
||||||
>
|
>
|
||||||
<StatusDot variant={variant as any} />
|
<StatusDot variant={variant} />
|
||||||
<span>{exec.routeId}</span>
|
<span>{ce.routeId}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
{correlationData.total > 20 && (
|
{correlationData && correlationData.total > 20 && (
|
||||||
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
|
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error callout */}
|
{/* Processor Timeline Section */}
|
||||||
{detail.errorMessage && (
|
|
||||||
<InfoCallout variant="error">
|
|
||||||
{detail.errorMessage}
|
|
||||||
</InfoCallout>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Processor Timeline / Flow Section */}
|
|
||||||
<div className={styles.timelineSection}>
|
<div className={styles.timelineSection}>
|
||||||
<div className={styles.timelineHeader}>
|
<div className={styles.timelineHeader}>
|
||||||
<span className={styles.timelineTitle}>
|
<span className={styles.timelineTitle}>
|
||||||
@@ -206,17 +285,17 @@ export default function ExchangeDetail() {
|
|||||||
<ProcessorTimeline
|
<ProcessorTimeline
|
||||||
processors={processors}
|
processors={processors}
|
||||||
totalMs={detail.durationMs}
|
totalMs={detail.durationMs}
|
||||||
onProcessorClick={(_p, i) => setSelectedProcessorIndex(i)}
|
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
|
||||||
selectedIndex={activeIndex}
|
selectedIndex={activeIndex}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<InfoCallout>No processor data available</InfoCallout>
|
<InfoCallout>No processor data available</InfoCallout>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
diagram ? (
|
routeNodes.length > 0 ? (
|
||||||
<RouteFlow
|
<RouteFlow
|
||||||
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
|
nodes={routeNodes}
|
||||||
onNodeClick={(_node, i) => setSelectedProcessorIndex(i)}
|
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
|
||||||
selectedIndex={activeIndex}
|
selectedIndex={activeIndex}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -226,7 +305,7 @@ export default function ExchangeDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Processor Detail: Message IN / Message OUT or Error */}
|
{/* Processor Detail Panel (split IN / OUT) */}
|
||||||
{selectedProc && snapshot && (
|
{selectedProc && snapshot && (
|
||||||
<div className={styles.detailSplit}>
|
<div className={styles.detailSplit}>
|
||||||
{/* Message IN */}
|
{/* Message IN */}
|
||||||
@@ -255,7 +334,7 @@ export default function ExchangeDetail() {
|
|||||||
)}
|
)}
|
||||||
<div className={styles.bodySection}>
|
<div className={styles.bodySection}>
|
||||||
<div className={styles.sectionLabel}>Body</div>
|
<div className={styles.sectionLabel}>Body</div>
|
||||||
<CodeBlock content={inputBody ?? 'null'} />
|
<CodeBlock content={inputBody ?? 'null'} language="json" copyable />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,7 +388,7 @@ export default function ExchangeDetail() {
|
|||||||
)}
|
)}
|
||||||
<div className={styles.bodySection}>
|
<div className={styles.bodySection}>
|
||||||
<div className={styles.sectionLabel}>Body</div>
|
<div className={styles.sectionLabel}>Body</div>
|
||||||
<CodeBlock content={outputBody ?? 'null'} />
|
<CodeBlock content={outputBody ?? 'null'} language="json" copyable />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,12 +396,13 @@ export default function ExchangeDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No snapshot loaded yet - show prompt */}
|
{/* Snapshot loading indicator */}
|
||||||
{selectedProc && !snapshot && procList.length > 0 && (
|
{selectedProc && !snapshot && procList.length > 0 && (
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', padding: 20 }}>
|
<div className={styles.snapshotLoading}>
|
||||||
Loading exchange snapshot...
|
Loading exchange snapshot...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</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 {
|
.headerCard {
|
||||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
background: var(--bg-surface);
|
||||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-card);
|
border: 1px solid var(--border-subtle);
|
||||||
padding: 16px; margin-bottom: 16px;
|
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; }
|
.headerRow {
|
||||||
.headerRight { display: flex; gap: 20px; }
|
display: flex;
|
||||||
.headerStat { text-align: center; }
|
justify-content: space-between;
|
||||||
.headerStatLabel { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 2px; }
|
align-items: center;
|
||||||
.headerStatValue { font-size: 14px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
|
gap: 16px;
|
||||||
.diagramStatsGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
|
||||||
.diagramPane, .statsPane {
|
|
||||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.paneTitle { font-size: 13px; font-weight: 700; color: var(--text-primary); margin-bottom: 12px; }
|
|
||||||
.tabSection { margin-top: 20px; }
|
.headerLeft {
|
||||||
.chartGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
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 {
|
.chartCard {
|
||||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
background: var(--bg-surface);
|
||||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
|
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 {
|
.executionsTable {
|
||||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
background: var(--bg-surface);
|
||||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden;
|
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 {
|
.errorRow {
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex;
|
||||||
padding: 10px 12px; background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
justify-content: space-between;
|
||||||
border-radius: var(--radius-lg); font-size: 12px;
|
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 { useState, useMemo } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router';
|
import { useParams, useNavigate, Link } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Badge, StatusDot, DataTable, Tabs,
|
KpiStrip,
|
||||||
AreaChart, LineChart, BarChart, RouteFlow, Spinner,
|
Badge,
|
||||||
|
StatusDot,
|
||||||
|
DataTable,
|
||||||
|
Tabs,
|
||||||
|
AreaChart,
|
||||||
|
LineChart,
|
||||||
|
BarChart,
|
||||||
|
RouteFlow,
|
||||||
|
Spinner,
|
||||||
MonoText,
|
MonoText,
|
||||||
|
Sparkline,
|
||||||
} from '@cameleer/design-system';
|
} 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 { useGlobalFilters } from '@cameleer/design-system';
|
||||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||||
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
|
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 type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
|
||||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
||||||
import styles from './RouteDetail.module.css';
|
import styles from './RouteDetail.module.css';
|
||||||
|
|
||||||
|
// ── Row types ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ExchangeRow extends ExecutionSummary {
|
interface ExchangeRow extends ExecutionSummary {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
@@ -26,6 +37,8 @@ interface ProcessorRow {
|
|||||||
avgDurationMs: number;
|
avgDurationMs: number;
|
||||||
p99DurationMs: number;
|
p99DurationMs: number;
|
||||||
errorCount: number;
|
errorCount: number;
|
||||||
|
errorRate: number;
|
||||||
|
sparkline: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorPattern {
|
interface ErrorPattern {
|
||||||
@@ -34,6 +47,211 @@ interface ErrorPattern {
|
|||||||
lastSeen: string;
|
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() {
|
export default function RouteDetail() {
|
||||||
const { appId, routeId } = useParams();
|
const { appId, routeId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -43,9 +261,11 @@ export default function RouteDetail() {
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('performance');
|
const [activeTab, setActiveTab] = useState('performance');
|
||||||
|
|
||||||
|
// ── API queries ────────────────────────────────────────────────────────────
|
||||||
const { data: catalog } = useRouteCatalog();
|
const { data: catalog } = useRouteCatalog();
|
||||||
const { data: diagram } = useDiagramByRoute(appId, routeId);
|
const { data: diagram } = useDiagramByRoute(appId, routeId);
|
||||||
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId);
|
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: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
||||||
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
|
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
|
||||||
timeFrom,
|
timeFrom,
|
||||||
@@ -65,6 +285,8 @@ export default function RouteDetail() {
|
|||||||
limit: 200,
|
limit: 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Derived data ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
|
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
|
||||||
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
|
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
|
||||||
[catalog, appId],
|
[catalog, appId],
|
||||||
@@ -79,7 +301,7 @@ export default function RouteDetail() {
|
|||||||
const exchangeCount = routeSummary?.exchangeCount ?? 0;
|
const exchangeCount = routeSummary?.exchangeCount ?? 0;
|
||||||
const lastSeen = routeSummary?.lastSeen
|
const lastSeen = routeSummary?.lastSeen
|
||||||
? new Date(routeSummary.lastSeen).toLocaleString()
|
? new Date(routeSummary.lastSeen).toLocaleString()
|
||||||
: '—';
|
: '\u2014';
|
||||||
|
|
||||||
const healthVariant = useMemo((): 'success' | 'warning' | 'error' | 'dead' => {
|
const healthVariant = useMemo((): 'success' | 'warning' | 'error' | 'dead' => {
|
||||||
const h = health.toLowerCase();
|
const h = health.toLowerCase();
|
||||||
@@ -89,39 +311,70 @@ export default function RouteDetail() {
|
|||||||
return 'dead';
|
return 'dead';
|
||||||
}, [health]);
|
}, [health]);
|
||||||
|
|
||||||
|
// Route flow from diagram
|
||||||
const diagramNodes = useMemo(() => {
|
const diagramNodes = useMemo(() => {
|
||||||
if (!diagram?.nodes) return [];
|
if (!diagram?.nodes) return [];
|
||||||
return mapDiagramToRouteNodes(diagram.nodes, []);
|
return mapDiagramToRouteNodes(diagram.nodes, []);
|
||||||
}, [diagram]);
|
}, [diagram]);
|
||||||
|
|
||||||
|
// Processor table rows
|
||||||
const processorRows: ProcessorRow[] = useMemo(() =>
|
const processorRows: ProcessorRow[] = useMemo(() =>
|
||||||
(processorMetrics || []).map((p: any) => ({
|
(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,
|
id: p.processorId,
|
||||||
processorId: p.processorId,
|
processorId: p.processorId,
|
||||||
callCount: p.callCount ?? 0,
|
type: classifyProcessorType(p.processorId ?? ''),
|
||||||
|
callCount,
|
||||||
avgDurationMs: p.avgDurationMs ?? 0,
|
avgDurationMs: p.avgDurationMs ?? 0,
|
||||||
p99DurationMs: p.p99DurationMs ?? 0,
|
p99DurationMs: p.p99DurationMs ?? 0,
|
||||||
errorCount: p.errorCount ?? 0,
|
errorCount,
|
||||||
})),
|
errorRate: Number(errRate.toFixed(2)),
|
||||||
|
sparkline: p.sparkline ?? [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
[processorMetrics],
|
[processorMetrics],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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(() =>
|
const chartData = useMemo(() =>
|
||||||
(timeseries?.buckets || []).map((b: any) => ({
|
(timeseries?.buckets || []).map((b) => {
|
||||||
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
const ts = new Date(b.time);
|
||||||
|
return {
|
||||||
|
time: !isNaN(ts.getTime())
|
||||||
|
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: '\u2014',
|
||||||
throughput: b.totalCount,
|
throughput: b.totalCount,
|
||||||
latency: b.avgDurationMs,
|
latency: b.avgDurationMs,
|
||||||
errors: b.failedCount,
|
errors: b.failedCount,
|
||||||
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
[timeseries],
|
[timeseries],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Exchange rows
|
||||||
const exchangeRows: ExchangeRow[] = useMemo(() =>
|
const exchangeRows: ExchangeRow[] = useMemo(() =>
|
||||||
(recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
(recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
||||||
[recentResult],
|
[recentResult],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Error patterns
|
||||||
const errorPatterns: ErrorPattern[] = useMemo(() => {
|
const errorPatterns: ErrorPattern[] = useMemo(() => {
|
||||||
const failed = (errorResult?.data || []) as ExecutionSummary[];
|
const failed = (errorResult?.data || []) as ExecutionSummary[];
|
||||||
const grouped = new Map<string, { count: number; lastSeen: string }>();
|
const grouped = new Map<string, { count: number; lastSeen: string }>();
|
||||||
@@ -141,31 +394,18 @@ export default function RouteDetail() {
|
|||||||
.map(([message, { count, lastSeen: ls }]) => ({
|
.map(([message, { count, lastSeen: ls }]) => ({
|
||||||
message,
|
message,
|
||||||
count,
|
count,
|
||||||
lastSeen: ls ? new Date(ls).toLocaleString() : '—',
|
lastSeen: ls ? new Date(ls).toLocaleString() : '\u2014',
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
}, [errorResult]);
|
}, [errorResult]);
|
||||||
|
|
||||||
const processorColumns: Column<ProcessorRow>[] = [
|
// KPI items
|
||||||
{ key: 'processorId', header: 'Processor', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
const kpiItems = useMemo(() =>
|
||||||
{ key: 'callCount', header: 'Calls', sortable: true },
|
buildDetailKpiItems(stats, throughputSparkline, errorSparkline, latencySparkline),
|
||||||
{ key: 'avgDurationMs', header: 'Avg', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
|
[stats, throughputSparkline, errorSparkline, latencySparkline],
|
||||||
{ key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
|
);
|
||||||
{ key: 'errorCount', header: 'Errors', sortable: true, render: (v) => {
|
|
||||||
const n = v as number;
|
|
||||||
return n > 0 ? <span style={{ color: 'var(--error)', fontWeight: 700 }}>{n}</span> : <span>0</span>;
|
|
||||||
}},
|
|
||||||
];
|
|
||||||
|
|
||||||
const exchangeColumns: Column<ExchangeRow>[] = [
|
const processorColumns = useMemo(() => makeProcessorColumns(styles), []);
|
||||||
{
|
|
||||||
key: 'status', header: 'Status', width: '80px',
|
|
||||||
render: (v) => <StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />,
|
|
||||||
},
|
|
||||||
{ key: 'executionId', header: 'Exchange ID', render: (v) => <MonoText size="xs">{String(v).slice(0, 12)}</MonoText> },
|
|
||||||
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => new Date(v as string).toLocaleTimeString() },
|
|
||||||
{ key: 'durationMs', header: 'Duration', sortable: true, render: (v) => `${v}ms` },
|
|
||||||
];
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ label: 'Performance', value: 'performance' },
|
{ label: 'Performance', value: 'performance' },
|
||||||
@@ -173,12 +413,15 @@ export default function RouteDetail() {
|
|||||||
{ label: 'Error Patterns', value: 'errors', count: errorPatterns.length },
|
{ label: 'Error Patterns', value: 'errors', count: errorPatterns.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link to={`/routes/${appId}`} className={styles.backLink}>
|
<Link to={`/routes/${appId}`} className={styles.backLink}>
|
||||||
← {appId} routes
|
← {appId} routes
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Route header card */}
|
||||||
<div className={styles.headerCard}>
|
<div className={styles.headerCard}>
|
||||||
<div className={styles.headerRow}>
|
<div className={styles.headerRow}>
|
||||||
<div className={styles.headerLeft}>
|
<div className={styles.headerLeft}>
|
||||||
@@ -199,13 +442,17 @@ export default function RouteDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* KPI strip */}
|
||||||
|
<KpiStrip items={kpiItems} />
|
||||||
|
|
||||||
|
{/* Diagram + Processor Stats grid */}
|
||||||
<div className={styles.diagramStatsGrid}>
|
<div className={styles.diagramStatsGrid}>
|
||||||
<div className={styles.diagramPane}>
|
<div className={styles.diagramPane}>
|
||||||
<div className={styles.paneTitle}>Route Diagram</div>
|
<div className={styles.paneTitle}>Route Diagram</div>
|
||||||
{diagramNodes.length > 0 ? (
|
{diagramNodes.length > 0 ? (
|
||||||
<RouteFlow nodes={diagramNodes} />
|
<RouteFlow nodes={diagramNodes} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
|
<div className={styles.emptyText}>
|
||||||
No diagram available for this route.
|
No diagram available for this route.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -217,13 +464,40 @@ export default function RouteDetail() {
|
|||||||
) : processorRows.length > 0 ? (
|
) : processorRows.length > 0 ? (
|
||||||
<DataTable columns={processorColumns} data={processorRows} sortable pageSize={10} />
|
<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.
|
No processor data available.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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}>
|
<div className={styles.tabSection}>
|
||||||
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
@@ -232,28 +506,41 @@ export default function RouteDetail() {
|
|||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartTitle}>Throughput</div>
|
<div className={styles.chartTitle}>Throughput</div>
|
||||||
<AreaChart
|
<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}
|
height={200}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartTitle}>Latency</div>
|
<div className={styles.chartTitle}>Latency</div>
|
||||||
<LineChart
|
<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}
|
height={200}
|
||||||
|
threshold={{ value: 300, label: 'SLA 300ms' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartTitle}>Errors</div>
|
<div className={styles.chartTitle}>Errors</div>
|
||||||
<BarChart
|
<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}
|
height={200}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartTitle}>Success Rate</div>
|
<div className={styles.chartTitle}>Success Rate</div>
|
||||||
<AreaChart
|
<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}
|
height={200}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,7 +555,7 @@ export default function RouteDetail() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={exchangeColumns}
|
columns={EXCHANGE_COLUMNS}
|
||||||
data={exchangeRows}
|
data={exchangeRows}
|
||||||
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
|
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
|
||||||
sortable
|
sortable
|
||||||
@@ -281,7 +568,7 @@ export default function RouteDetail() {
|
|||||||
{activeTab === 'errors' && (
|
{activeTab === 'errors' && (
|
||||||
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
|
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
|
||||||
{errorPatterns.length === 0 ? (
|
{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.
|
No error patterns found in the selected time range.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,17 +1,44 @@
|
|||||||
.statStrip {
|
/* Scrollable content area */
|
||||||
display: grid;
|
.content {
|
||||||
grid-template-columns: repeat(5, 1fr);
|
display: flex;
|
||||||
gap: 10px;
|
flex-direction: column;
|
||||||
margin-bottom: 16px;
|
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 {
|
.tableSection {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableHeader {
|
.tableHeader {
|
||||||
@@ -28,36 +55,56 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tableRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.tableMeta {
|
.tableMeta {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
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 {
|
.chartGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartCard {
|
.chart {
|
||||||
background: var(--bg-surface);
|
width: 100%;
|
||||||
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: 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 { useMemo } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
StatCard, Sparkline, MonoText, Badge,
|
KpiStrip,
|
||||||
DataTable, AreaChart, LineChart, BarChart,
|
DataTable,
|
||||||
|
AreaChart,
|
||||||
|
LineChart,
|
||||||
|
BarChart,
|
||||||
|
Card,
|
||||||
|
Sparkline,
|
||||||
|
MonoText,
|
||||||
|
Badge,
|
||||||
} from '@cameleer/design-system';
|
} 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 { useRouteMetrics } from '../../api/queries/catalog';
|
||||||
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
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';
|
import styles from './RoutesMetrics.module.css';
|
||||||
|
|
||||||
interface RouteRow {
|
interface RouteRow {
|
||||||
@@ -23,186 +31,322 @@ interface RouteRow {
|
|||||||
sparkline: number[];
|
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() {
|
export default function RoutesMetrics() {
|
||||||
const { appId, routeId } = useParams();
|
const { appId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { timeRange } = useGlobalFilters();
|
const { timeRange } = useGlobalFilters();
|
||||||
const timeFrom = timeRange.start.toISOString();
|
const timeFrom = timeRange.start.toISOString();
|
||||||
const timeTo = timeRange.end.toISOString();
|
const timeTo = timeRange.end.toISOString();
|
||||||
|
|
||||||
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
|
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
|
||||||
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
|
const { data: stats } = useExecutionStats(timeFrom, timeTo, undefined, appId);
|
||||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
||||||
|
|
||||||
|
// Map backend RouteMetrics[] to table rows
|
||||||
const rows: RouteRow[] = useMemo(() =>
|
const rows: RouteRow[] = useMemo(() =>
|
||||||
(metrics || []).map((m: any) => ({
|
(metrics || []).map((m: RouteMetrics) => ({
|
||||||
id: `${m.appId}/${m.routeId}`,
|
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],
|
[metrics],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sparklineData = useMemo(() =>
|
// Sparkline data from timeseries buckets
|
||||||
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
|
const throughputSparkline = useMemo(() =>
|
||||||
|
(timeseries?.buckets || []).map((b) => b.totalCount),
|
||||||
|
[timeseries],
|
||||||
|
);
|
||||||
|
const errorSparkline = useMemo(() =>
|
||||||
|
(timeseries?.buckets || []).map((b) => b.failedCount),
|
||||||
[timeseries],
|
[timeseries],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chartData = useMemo(() =>
|
// Chart series from timeseries buckets
|
||||||
(timeseries?.buckets || []).map((b: any, i: number) => {
|
const throughputChartSeries = useMemo(() => [{
|
||||||
const ts = b.timestamp ? new Date(b.timestamp) : null;
|
label: 'Throughput',
|
||||||
const time = ts && !isNaN(ts.getTime())
|
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' })
|
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
: String(i);
|
: '—';
|
||||||
return {
|
return { x: label, y: b.failedCount };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
[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 (
|
return (
|
||||||
<div>
|
<div className={styles.content}>
|
||||||
<div className={styles.statStrip}>
|
<div className={styles.refreshIndicator}>
|
||||||
<StatCard
|
<span className={styles.refreshDot} />
|
||||||
label="Total Throughput"
|
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
|
{/* KPI header cards */}
|
||||||
|
<KpiStrip items={kpiItems} />
|
||||||
|
|
||||||
|
{/* Per-route performance table */}
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
<div className={styles.tableHeader}>
|
<div className={styles.tableHeader}>
|
||||||
<span className={styles.tableTitle}>Per-Route Performance</span>
|
<span className={styles.tableTitle}>Per-Route Performance</span>
|
||||||
|
<div className={styles.tableRight}>
|
||||||
<span className={styles.tableMeta}>{rows.length} routes</span>
|
<span className={styles.tableMeta}>{rows.length} routes</span>
|
||||||
|
<Badge label="LIVE" color="success" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={ROUTE_COLUMNS}
|
||||||
data={rows}
|
data={rows}
|
||||||
sortable
|
sortable
|
||||||
pageSize={20}
|
onRowClick={(row) => {
|
||||||
|
const targetAppId = appId ?? row.appId;
|
||||||
|
navigate(`/routes/${targetAppId}/${row.routeId}`);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chartData.length > 0 && (
|
{/* 2x2 chart grid */}
|
||||||
|
{(timeseries?.buckets?.length ?? 0) > 0 && (
|
||||||
<div className={styles.chartGrid}>
|
<div className={styles.chartGrid}>
|
||||||
<div className={styles.chartCard}>
|
<Card title="Throughput (msg/s)">
|
||||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
<AreaChart
|
||||||
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/s" height={200} />
|
series={throughputChartSeries}
|
||||||
</div>
|
yLabel="msg/s"
|
||||||
<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"
|
|
||||||
height={200}
|
height={200}
|
||||||
threshold={{ value: 300, label: 'SLA 300ms' }}
|
className={styles.chart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
<div className={styles.chartCard}>
|
|
||||||
<div className={styles.chartTitle}>Errors by Route</div>
|
<Card title="Latency (ms)">
|
||||||
<BarChart series={[{ label: 'Errors', data: chartData.map((d: any) => ({ x: d.time as string, y: d.errors })) }]} height={200} />
|
<LineChart
|
||||||
</div>
|
series={latencyChartSeries}
|
||||||
<div className={styles.chartCard}>
|
yLabel="ms"
|
||||||
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
|
threshold={{ value: 300, label: 'SLA 300ms' }}
|
||||||
<AreaChart series={[{ label: 'Volume', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/min" height={200} />
|
height={200}
|
||||||
</div>
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user