feat: replace UI with design system example pages wired to real API
Migrate all page components from the @cameleer/design-system v0.0.3 example UI, replacing mock data with real backend API hooks. This brings richer visuals (KpiStrip, GroupCard, RouteFlow, ProcessorTimeline, DateRangePicker, expandable rows) while preserving all existing API integration, auth, and routing infrastructure. Pages migrated: Dashboard, RoutesMetrics, RouteDetail, ExchangeDetail, AgentHealth, AgentInstance, OidcConfig, AuditLog, RBAC (Users/Groups/Roles). Also enhanced LayoutShell CommandPalette with real search data from catalog. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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