feat: replace UI with design system example pages wired to real API
Some checks failed
CI / build (push) Successful in 1m18s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 55s
CI / deploy-feature (push) Has been cancelled
CI / deploy (push) Has been cancelled

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:
hsiegeln
2026-03-24 16:42:16 +01:00
parent dafd7adb00
commit 81f85aa82d
23 changed files with 4439 additions and 2542 deletions

View File

@@ -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 />

View File

@@ -20,7 +20,9 @@ export default function AdminLayout() {
active={location.pathname} active={location.pathname}
onChange={(path) => navigate(path)} onChange={(path) => navigate(path)}
/> />
<Outlet /> <div style={{ padding: '20px 24px 40px' }}>
<Outlet />
</div>
</div> </div>
); );
} }

View 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);
}

View File

@@ -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>
<DataTable <div className={styles.tableSection}>
columns={columns} <div className={styles.tableHeader}>
data={rows} <span className={styles.tableTitle}>Audit Log</span>
sortable <div className={styles.tableRight}>
pageSize={25} <span className={styles.tableMeta}>
expandedContent={(row) => ( {totalCount} events
<div style={{ padding: '0.75rem' }}> </span>
<CodeBlock content={JSON.stringify(row.detail, null, 2)} /> <Badge label="LIVE" color="success" />
</div> </div>
)} </div>
/> <DataTable
columns={COLUMNS}
data={rows}
sortable
flush
pageSize={25}
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
expandedContent={(row) => (
<div className={styles.expandedDetail}>
<div className={styles.detailGrid}>
<div className={styles.detailField}>
<span className={styles.detailLabel}>IP Address</span>
<MonoText size="xs">{row.ipAddress}</MonoText>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>User Agent</span>
<span className={styles.detailValue}>{row.userAgent}</span>
</div>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Detail</span>
<CodeBlock content={JSON.stringify(row.detail, null, 2)} language="json" />
</div>
</div>
)}
/>
</div>
</div> </div>
); );
} }

View File

@@ -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,350 +63,385 @@ 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;
try { for (const userId of userIds) {
await assignRoleToGroup.mutateAsync({ try {
groupId: selectedGroup.id, await addUserToGroup.mutateAsync({
roleId: addRoleId, userId,
}); groupId: selectedGroup.id,
toast({ title: 'Role assigned', variant: 'success' }); });
setAddRoleId(''); toast({ title: 'Member added', variant: 'success' });
} catch { } catch {
toast({ title: 'Failed to assign role', variant: 'error' }); toast({ title: 'Failed to add member', variant: 'error' });
}
} }
}; }
const handleRemoveRole = async (roleId: string) => { async function handleAddRoles(roleIds: string[]) {
if (!selectedGroup) return;
for (const roleId of roleIds) {
try {
await assignRoleToGroup.mutateAsync({
groupId: selectedGroup.id,
roleId,
});
toast({ title: 'Role assigned', variant: 'success' });
} catch {
toast({ title: 'Failed to assign role', variant: 'error' });
}
}
}
async function handleRemoveRole(roleId: string) {
if (!selectedGroup) return; 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..." <div className={styles.createForm}>
value={search} <Input
onChange={(e) => setSearch(e.target.value)} placeholder="Group name *"
onClear={() => setSearch('')} value={newName}
/> onChange={(e) => setNewName(e.target.value)}
<Button />
size="sm" {duplicateGroupName && (
variant="secondary" <span style={{ color: 'var(--error)', fontSize: 11 }}>
onClick={() => setShowCreate((v) => !v)} Group name already exists
> </span>
+ Add Group )}
</Button> <Select
</div> options={parentOptions}
value={newParent}
{showCreate && ( onChange={(e) => setNewParent(e.target.value)}
<div className={styles.createForm}> />
<Input <div className={styles.createFormActions}>
placeholder="Group name" <Button
value={newGroupName} size="sm"
onChange={(e) => setNewGroupName(e.target.value)} variant="ghost"
/> onClick={() => setCreating(false)}
<div style={{ marginTop: 8 }}> >
<Select Cancel
options={parentOptions} </Button>
value={newGroupParentId} <Button
onChange={(e) => setNewGroupParentId(e.target.value)} size="sm"
/> variant="primary"
</div> onClick={handleCreate}
<div className={styles.createFormActions}> loading={createGroup.isPending}
<Button disabled={!newName.trim() || duplicateGroupName}
size="sm" >
variant="ghost" Create
onClick={() => { </Button>
setShowCreate(false);
setNewGroupName('');
setNewGroupParentId('');
}}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
loading={createGroup.isPending}
onClick={handleCreate}
disabled={!newGroupName.trim()}
>
Create
</Button>
</div>
</div>
)}
{groupsLoading ? (
<Spinner />
) : (
<div className={styles.entityList} role="listbox">
{filteredGroups.map((group) => {
const isSelected = group.id === selectedGroupId;
return (
<div
key={group.id}
role="option"
aria-selected={isSelected}
className={
styles.entityItem +
(isSelected ? ' ' + styles.entityItemSelected : '')
}
onClick={() => setSelectedGroupId(group.id)}
>
<Avatar name={group.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{group.name}</div>
<div className={styles.entityMeta}>
{group.parentGroupId
? `Child of ${parentName(group.parentGroupId)}`
: 'Top-level'}
</div>
</div>
</div> </div>
);
})}
</div>
)}
</div>
{/* Right pane */}
<div className={styles.detailPane}>
{!selectedGroupId ? (
<div className={styles.emptyDetail}>Select a group to view details</div>
) : detailLoading ? (
<Spinner />
) : selectedGroup ? (
<div>
{/* Header */}
<div className={styles.detailHeader}>
<Avatar name={selectedGroup.name} size="md" />
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEdit
value={selectedGroup.name}
onSave={handleRename}
disabled={isBuiltinAdmins}
/>
<div className={styles.entityMeta}>
{selectedGroup.parentGroupId
? `Child of ${parentName(selectedGroup.parentGroupId)}`
: 'Top-level'}
</div>
</div>
<Button
variant="danger"
size="sm"
disabled={isBuiltinAdmins}
onClick={() => setDeleteOpen(true)}
>
Delete
</Button>
</div>
{/* Metadata */}
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>Group ID</span>
<MonoText size="xs">{selectedGroup.id}</MonoText>
<span className={styles.metaLabel}>Parent</span>
<span>{parentName(selectedGroup.parentGroupId)}</span>
</div>
{/* Members */}
<div className={styles.sectionTitle}>Members</div>
<div className={styles.sectionTags}>
{(selectedGroup.members ?? []).map((member) => (
<Tag
key={member.userId}
label={member.displayName}
onRemove={() => handleRemoveMember(member.userId)}
/>
))}
{(selectedGroup.members ?? []).length === 0 && (
<span className={styles.inheritedNote}>No members</span>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Select
options={[
{ value: '', label: 'Add member...' },
...availableUsers.map((u) => ({
value: u.userId,
label: u.displayName,
})),
]}
value={addMemberUserId}
onChange={(e) => setAddMemberUserId(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
onClick={handleAddMember}
disabled={!addMemberUserId || addUserToGroup.isPending}
>
Add
</Button>
</div>
{/* Assigned roles */}
<div className={styles.sectionTitle}>Assigned Roles</div>
<div className={styles.sectionTags}>
{(selectedGroup.directRoles ?? []).map((role) => (
<Badge
key={role.id}
label={role.name}
variant="outlined"
onRemove={() => handleRemoveRole(role.id)}
/>
))}
{(selectedGroup.directRoles ?? []).length === 0 && (
<span className={styles.inheritedNote}>No roles assigned</span>
)}
</div>
{(selectedGroup.effectiveRoles ?? []).length >
(selectedGroup.directRoles ?? []).length && (
<div className={styles.inheritedNote}>
+
{(selectedGroup.effectiveRoles ?? []).length -
(selectedGroup.directRoles ?? []).length}{' '}
inherited role(s)
</div> </div>
)} )}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Select
options={[
{ value: '', label: 'Assign role...' },
...availableRoles.map((r) => ({
value: r.id,
label: r.name,
})),
]}
value={addRoleId}
onChange={(e) => setAddRoleId(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
onClick={handleAddRole}
disabled={!addRoleId || assignRoleToGroup.isPending}
>
Add
</Button>
</div>
</div>
) : null}
</div>
{/* Delete confirmation */} <EntityList
items={filtered}
renderItem={(group) => {
const groupChildren = groups.filter(
(g) => g.parentGroupId === group.id,
);
const groupParent = group.parentGroupId
? groups.find((g) => g.id === group.parentGroupId)
: null;
return (
<>
<Avatar name={group.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{group.name}</div>
<div className={styles.entityMeta}>
{groupParent
? `Child of ${groupParent.name}`
: 'Top-level'}
{' \u00b7 '}
{groupChildren.length} children
{' \u00b7 '}
{(group.members ?? []).length} members
</div>
<div className={styles.entityTags}>
{(group.directRoles ?? []).map((r) => (
<Badge key={r.id} label={r.name} color="warning" />
))}
</div>
</div>
</>
);
}}
getItemId={(group) => group.id}
selectedId={selectedId ?? undefined}
onSelect={setSelectedId}
searchPlaceholder="Search groups..."
onSearch={setSearch}
addLabel="+ Add group"
onAdd={() => setCreating(true)}
emptyMessage="No groups match your search"
/>
</>
}
detail={
selectedId && detailLoading ? (
<Spinner size="md" />
) : selectedGroup ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selectedGroup.name} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>
{isBuiltinAdmins ? (
selectedGroup.name
) : (
<InlineEdit
value={selectedGroup.name}
onSave={handleRename}
/>
)}
</div>
<div className={styles.detailEmail}>
{parentGroup
? `${parentGroup.name} > ${selectedGroup.name}`
: 'Top-level group'}
{isBuiltinAdmins && ' (built-in)'}
</div>
</div>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selectedGroup)}
disabled={isBuiltinAdmins}
>
Delete
</Button>
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selectedGroup.id}</MonoText>
</div>
{parentGroup && (
<>
<SectionHeader>Member of</SectionHeader>
<div className={styles.sectionTags}>
<Tag label={parentGroup.name} color="auto" />
</div>
</>
)}
<SectionHeader>Members (direct)</SectionHeader>
<div className={styles.sectionTags}>
{members.map((u) => (
<Tag
key={u.userId}
label={u.displayName}
color="auto"
onRemove={() => handleRemoveMember(u.userId)}
/>
))}
{members.length === 0 && (
<span className={styles.inheritedNote}>(no members)</span>
)}
<MultiSelect
options={availableMembers}
value={[]}
onChange={handleAddMembers}
placeholder="+ Add"
/>
</div>
{children.length > 0 && (
<span className={styles.inheritedNote}>
+ all members of {children.map((c) => c.name).join(', ')}
</span>
)}
<SectionHeader>Child groups</SectionHeader>
<div className={styles.sectionTags}>
{children.map((c) => (
<Tag key={c.id} label={c.name} color="success" />
))}
{children.length === 0 && (
<span className={styles.inheritedNote}>
(no child groups)
</span>
)}
</div>
<SectionHeader>Assigned roles</SectionHeader>
<div className={styles.sectionTags}>
{(selectedGroup.directRoles ?? []).map((r) => (
<Tag
key={r.id}
label={r.name}
color="warning"
onRemove={() => {
if (members.length > 0) {
setRemoveRoleTarget(r.id);
} else {
handleRemoveRole(r.id);
}
}}
/>
))}
{(selectedGroup.directRoles ?? []).length === 0 && (
<span className={styles.inheritedNote}>(no roles)</span>
)}
<MultiSelect
options={availableRoles}
value={[]}
onChange={handleAddRoles}
placeholder="+ Add"
/>
</div>
</>
) : null
}
emptyMessage="Select a group to view details"
/>
<ConfirmDialog <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"
/>
</>
); );
} }

View File

@@ -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;
} }

View File

@@ -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}>
{(config.defaultRoles || []).map(role => (
<Tag key={role} label={role} onRemove={() => {
setConfig(prev => ({ ...prev!, defaultRoles: prev!.defaultRoles.filter(r => r !== role) }));
}} />
))}
</div>
<div className={styles.addRow}>
<Input placeholder="Add role..." value={newRole} onChange={e => setNewRole(e.target.value)} />
<Button onClick={() => {
if (newRole.trim() && !config.defaultRoles?.includes(newRole.trim())) {
setConfig(prev => ({ ...prev!, defaultRoles: [...(prev!.defaultRoles || []), newRole.trim()] }));
setNewRole('');
}
}}>Add</Button>
</div>
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}> <section className={styles.section}>
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button> <SectionHeader>Behavior</SectionHeader>
<Button variant="danger" onClick={() => setDeleteOpen(true)}>Delete Configuration</Button> <div className={styles.toggleRow}>
</div> <Toggle
label="Enabled"
{error && <Alert variant="error">{error}</Alert>} checked={form.enabled}
{success && <Alert variant="success">Configuration saved</Alert>} onChange={(e) => update('enabled', e.target.checked)}
/>
</div> </div>
</Card> <div className={styles.toggleRow}>
<Toggle
label="Auto Sign-Up"
checked={form.autoSignup}
onChange={(e) => update('autoSignup', e.target.checked)}
/>
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
</div>
</section>
<ConfirmDialog <section className={styles.section}>
open={deleteOpen} <SectionHeader>Provider Settings</SectionHeader>
onClose={() => setDeleteOpen(false)} <FormField label="Issuer URI" htmlFor="issuer">
onConfirm={handleDelete} <Input
title="Delete OIDC Configuration" id="issuer"
message="Delete OIDC configuration? All OIDC users will lose access." type="url"
confirmText="DELETE" placeholder="https://idp.example.com/realms/my-realm"
/> value={form.issuerUri}
onChange={(e) => update('issuerUri', e.target.value)}
/>
</FormField>
<FormField label="Client ID" htmlFor="client-id">
<Input
id="client-id"
value={form.clientId}
onChange={(e) => update('clientId', e.target.value)}
/>
</FormField>
<FormField label="Client Secret" htmlFor="client-secret">
<Input
id="client-secret"
type="password"
value={form.clientSecret}
onChange={(e) => update('clientSecret', e.target.value)}
/>
</FormField>
</section>
<section className={styles.section}>
<SectionHeader>Claim Mapping</SectionHeader>
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
<Input
id="roles-claim"
value={form.rolesClaim}
onChange={(e) => update('rolesClaim', e.target.value)}
/>
</FormField>
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
<Input
id="name-claim"
value={form.displayNameClaim}
onChange={(e) => update('displayNameClaim', e.target.value)}
/>
</FormField>
</section>
<section className={styles.section}>
<SectionHeader>Default Roles</SectionHeader>
<div className={styles.tagList}>
{form.defaultRoles.map((role) => (
<Tag key={role} label={role} color="primary" onRemove={() => removeRole(role)} />
))}
{form.defaultRoles.length === 0 && (
<span className={styles.noRoles}>No default roles configured</span>
)}
</div>
<div className={styles.addRoleRow}>
<Input
placeholder="Add role..."
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }}
className={styles.roleInput}
/>
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
Add
</Button>
</div>
</section>
<section className={styles.section}>
<SectionHeader>Danger Zone</SectionHeader>
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
Delete OIDC Configuration
</Button>
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDelete}
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
confirmText="delete oidc"
/>
</section>
</div> </div>
); );
} }

View File

@@ -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' }, {tab === 'users' && <UsersTab />}
{ label: 'Groups', value: 'groups' }, {tab === 'groups' && <GroupsTab />}
{ label: 'Roles', value: 'roles' }, {tab === 'roles' && <RolesTab />}
]} </div>
active={tab}
onChange={setTab}
/>
{tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab />}
{tab === 'roles' && <RolesTab />}
</div> </div>
); );
} }

View File

@@ -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,152 +80,144 @@ 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 (
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0)
);
}
if (isLoading) return <Spinner size="md" />;
return ( return (
<div className={styles.splitPane}> <>
{/* Left pane — list */} <SplitPane
<div className={styles.listPane}> list={
<div className={styles.listHeader}> <>
<Input {creating && (
placeholder="Search roles…" <div className={styles.createForm}>
value={search} <Input
onChange={(e) => setSearch(e.target.value)} placeholder="Role name *"
/> value={newName}
<Button onChange={(e) => setNewName(e.target.value)}
variant="secondary" />
size="sm" {duplicateRoleName && (
onClick={() => setShowCreate((v) => !v)} <span style={{ color: 'var(--error)', fontSize: 11 }}>
> Role name already exists
+ Add Role </span>
</Button> )}
</div> <Input
placeholder="Description"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button
size="sm"
variant="ghost"
onClick={() => setCreating(false)}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
onClick={handleCreate}
loading={createRole.isPending}
disabled={!newName.trim() || duplicateRoleName}
>
Create
</Button>
</div>
</div>
)}
{showCreate && ( <EntityList
<div className={styles.createForm}> items={filtered}
<Input renderItem={(role) => (
placeholder="Role name (e.g. EDITOR)" <>
value={newName}
onChange={(e) => setNewName(e.target.value.toUpperCase())}
style={{ marginBottom: 8 }}
/>
<Input
placeholder="Description (optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowCreate(false);
setNewName('');
setNewDescription('');
}}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
loading={createRole.isPending}
disabled={!newName.trim()}
onClick={handleCreate}
>
Create
</Button>
</div>
</div>
)}
{isLoading ? (
<Spinner />
) : (
<div className={styles.entityList} role="listbox">
{filtered.map((role) => {
const assignmentCount =
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0);
return (
<div
key={role.id}
className={
styles.entityItem +
(selectedId === role.id ? ' ' + styles.entityItemSelected : '')
}
role="option"
aria-selected={selectedId === role.id}
onClick={() => setSelectedId(role.id)}
>
<Avatar name={role.name} size="sm" /> <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 className={styles.entityTags}>
{(role.assignedGroups ?? []).map((g) => (
<Badge key={g.id} label={g.name} color="success" />
))}
{(role.directUsers ?? []).map((u) => (
<Badge
key={u.userId}
label={u.displayName}
color="auto"
/>
))}
</div> </div>
{((role.assignedGroups?.length ?? 0) > 0 ||
(role.directUsers?.length ?? 0) > 0) && (
<div className={styles.entityTags}>
{(role.assignedGroups ?? []).map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))}
{(role.directUsers ?? []).map((u) => (
<Tag key={u.userId} label={u.displayName} />
))}
</div>
)}
</div> </div>
</div> </>
); )}
})} getItemId={(role) => role.id}
</div> selectedId={selectedId ?? undefined}
)} onSelect={setSelectedId}
</div> searchPlaceholder="Search roles..."
onSearch={setSearch}
addLabel="+ Add role"
onAdd={() => setCreating(true)}
emptyMessage="No roles match your search"
/>
</>
}
detail={
selectedId && (detailLoading || !detail) ? (
<Spinner size="md" />
) : detail ? (
<RoleDetailPanel
role={detail}
onDeleteRequest={() => setDeleteTarget(detail)}
/>
) : null
}
emptyMessage="Select a role to view details"
/>
{/* Right pane — detail */} <ConfirmDialog
<div className={styles.detailPane}> open={deleteTarget !== null}
{!selectedId ? ( onClose={() => setDeleteTarget(null)}
<div className={styles.emptyDetail}>Select a role to view details</div> onConfirm={handleDelete}
) : detailLoading || !detail ? ( message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
<Spinner /> confirmText={deleteTarget?.name ?? ''}
) : ( loading={deleteRole.isPending}
<RoleDetailPanel />
role={detail} </>
onDeleteRequest={() => setConfirmDelete(true)}
/>
)}
</div>
{detail && (
<ConfirmDialog
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={handleDelete}
title="Delete role"
message={`Delete role "${detail.name}"? This cannot be undone.`}
confirmText={detail.name}
confirmLabel="Delete"
variant="danger"
loading={deleteRole.isPending}
/>
)}
</div>
); );
} }
@@ -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" Delete
disabled={role.system} </Button>
onClick={onDeleteRequest} )}
>
Delete
</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>{role.system ? 'System role (read-only)' : 'Custom role'}</span> <span className={styles.metaLabel}>Type</span>
</div> <span className={styles.metaValue}>System role (read-only)</span>
</>
{/* Assigned to groups */}
<div className={styles.sectionTitle}>Assigned to groups</div>
<div className={styles.sectionTags}>
{(role.assignedGroups ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.assignedGroups ?? []).map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))
)} )}
</div> </div>
{/* Assigned to users (direct) */} <SectionHeader>Assigned to groups</SectionHeader>
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
<div className={styles.sectionTags}> <div className={styles.sectionTags}>
{(role.directUsers ?? []).length === 0 ? ( {assignedGroups.map((g) => (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span> <Tag key={g.id} label={g.name} color="success" />
) : ( ))}
(role.directUsers ?? []).map((u) => ( {assignedGroups.length === 0 && (
<Tag key={u.userId} label={u.displayName} /> <span className={styles.inheritedNote}>(none)</span>
))
)} )}
</div> </div>
{/* Effective principals */} <SectionHeader>Assigned to users (direct)</SectionHeader>
<div className={styles.sectionTitle}>Effective principals</div>
<div className={styles.sectionTags}> <div className={styles.sectionTags}>
{(role.effectivePrincipals ?? []).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.effectivePrincipals ?? []).map((u) => { {directUsers.length === 0 && (
const isDirect = directUserIds.has(u.userId); <span className={styles.inheritedNote}>(none)</span>
return isDirect ? (
<Badge key={u.userId} label={u.displayName} variant="filled" />
) : (
<Badge
key={u.userId}
label={`${u.displayName}`}
variant="dashed"
/>
);
})
)} )}
</div> </div>
{(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && (
<div className={styles.inheritedNote}> <SectionHeader>Effective principals</SectionHeader>
<div className={styles.sectionTags}>
{effectivePrincipals.map((u) => {
const isDirect = directUserIds.has(u.userId);
return isDirect ? (
<Badge
key={u.userId}
label={u.displayName}
color="auto"
variant="filled"
/>
) : (
<Badge
key={u.userId}
label={u.displayName}
color="auto"
variant="dashed"
/>
);
})}
{effectivePrincipals.length === 0 && (
<span className={styles.inheritedNote}>(none)</span>
)}
</div>
{effectivePrincipals.some((u) => !directUserIds.has(u.userId)) && (
<span className={styles.inheritedNote}>
Dashed entries inherit this role through group membership Dashed entries inherit this role through group membership
</div> </span>
)} )}
</div> </>
); );
} }

View File

@@ -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;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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;
} }

View File

@@ -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 =
? Math.round((heapUsed / heapMax) * 100) heapUsed != null && heapMax != null && heapMax > 0
: undefined; ? Math.round((heapUsed / heapMax) * 100)
: 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}> <div className={styles.detailRow}>
<StatusDot variant={statusVariant} /> <span className={styles.detailLabel}>Status</span>
<Badge label={agent.status} color={statusColor} /> <Badge label={agent.status} color={statusColor(ns)} variant="filled" />
</div> </div>
<div className={styles.detailRow}>
<dl className={styles.detailList}> <span className={styles.detailLabel}>Application</span>
<div className={styles.detailRow}> <MonoText size="xs">{agent.application}</MonoText>
<dt>Application</dt>
<dd><MonoText>{agent.application ?? '—'}</MonoText></dd>
</div>
<div className={styles.detailRow}>
<dt>Version</dt>
<dd><MonoText>{agent.version ?? '—'}</MonoText></dd>
</div>
<div className={styles.detailRow}>
<dt>Uptime</dt>
<dd>{formatUptime(agent.uptimeSeconds)}</dd>
</div>
<div className={styles.detailRow}>
<dt>Last Heartbeat</dt>
<dd>{formatRelativeTime(agent.lastHeartbeat)}</dd>
</div>
<div className={styles.detailRow}>
<dt>TPS</dt>
<dd>{agent.tps != null ? (agent.tps as number).toFixed(2) : '—'}</dd>
</div>
<div className={styles.detailRow}>
<dt>Error Rate</dt>
<dd>{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}</dd>
</div>
<div className={styles.detailRow}>
<dt>Routes</dt>
<dd>{agent.activeRoutes ?? '—'} active / {agent.totalRoutes ?? '—'} total</dd>
</div>
</dl>
<div className={styles.metricsSection}>
<div className={styles.metricLabel}>
Heap Memory{heapUsed != null && heapMax != null
? `${Math.round(heapUsed / 1024 / 1024)}MB / ${Math.round(heapMax / 1024 / 1024)}MB`
: ''}
</div>
<ProgressBar
value={heapPercent}
variant={heapPercent == null ? 'primary' : heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
indeterminate={heapPercent == null}
size="sm"
/>
</div> </div>
<div className={styles.detailRow}>
<div className={styles.metricsSection}> <span className={styles.detailLabel}>Uptime</span>
<div className={styles.metricLabel}> <MonoText size="xs">{formatUptime(agent.uptimeSeconds)}</MonoText>
CPU Usage{cpuPercent != null ? `${cpuPercent}%` : ''} </div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Last Seen</span>
<MonoText size="xs">{timeAgo(agent.lastHeartbeat)}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Throughput</span>
<MonoText size="xs">{agent.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Errors</span>
<MonoText size="xs" className={agent.errorRate ? styles.instanceError : undefined}>
{formatErrorRate(agent.errorRate)}
</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Routes</span>
<span>{agent.activeRoutes ?? 0}/{agent.totalRoutes ?? 0} active</span>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Heap Memory</span>
<div className={styles.detailProgress}>
<ProgressBar
value={heapPercent}
variant={heapPercent == null ? 'primary' : heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
indeterminate={heapPercent == null}
size="sm"
/>
<MonoText size="xs">{heapPercent != null ? `${heapPercent}%` : '\u2014'}</MonoText>
</div>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>CPU</span>
<div className={styles.detailProgress}>
<ProgressBar
value={cpuPercent}
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
indeterminate={cpuPercent == null}
size="sm"
/>
<MonoText size="xs">{cpuPercent != null ? `${cpuPercent}%` : '\u2014'}</MonoText>
</div> </div>
<ProgressBar
value={cpuPercent}
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
indeterminate={cpuPercent == null}
size="sm"
/>
</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;
id: String(e.id), const liveCount = agentList.filter((a) => normalizeStatus(a.status) === 'live').length;
severity: e.eventType === 'WENT_DEAD' ? 'error' as const const staleCount = agentList.filter((a) => normalizeStatus(a.status) === 'stale').length;
: e.eventType === 'WENT_STALE' ? 'warning' as const const deadCount = agentList.filter((a) => normalizeStatus(a.status) === 'dead').length;
: e.eventType === 'RECOVERED' ? 'success' as const const totalTps = agentList.reduce((s, a) => s + (a.tps ?? 0), 0);
: 'running' as const, const totalActiveRoutes = agentList.reduce((s, a) => s + (a.activeRoutes ?? 0), 0);
message: `${e.agentId}: ${e.eventType}${e.detail ? ' — ' + e.detail : ''}`, const totalRoutes = agentList.reduce((s, a) => s + (a.totalRoutes ?? 0), 0);
timestamp: new Date(e.timestamp),
})), // Map events to FeedEvent
const feedEvents: FeedEvent[] = useMemo(
() =>
(events ?? []).map((e: { id: number; agentId: string; eventType: string; detail: string; timestamp: string }) => ({
id: String(e.id),
severity:
e.eventType === 'WENT_DEAD'
? ('error' as const)
: e.eventType === 'WENT_STALE'
? ('warning' as const)
: e.eventType === 'RECOVERED'
? ('success' as const)
: ('running' as const),
message: `${e.agentId}: ${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
[events], [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"
</div> detail={
<span className={styles.breakdown}>
<div className={styles.scopeTrail}> <span className={styles.bpLive}>
<span className={styles.scopeLabel}>{liveCount}/{(agents || []).length} live</span> <StatusDot variant="live" /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy
</div> </span>
<span className={styles.bpStale}>
<div className={styles.groupGrid}> <StatusDot variant="stale" /> {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded
{Object.entries(apps).map(([group, groupAgents]) => { </span>
const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD'); <span className={styles.bpDead}>
const groupTps = (groupAgents || []).reduce((s: number, a: any) => s + (a.tps || 0), 0); <StatusDot variant="dead" /> {groups.filter((g) => g.deadCount > 0).length} critical
const groupActiveRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.activeRoutes || 0), 0); </span>
const groupTotalRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.totalRoutes || 0), 0); </span>
const liveInGroup = (groupAgents || []).filter((a: any) => a.status === 'LIVE').length; }
return ( />
<GroupCard <StatCard
key={group} label="Active Routes"
title={group} value={
headerRight={ <span
<Badge className={
label={`${liveInGroup}/${groupAgents?.length ?? 0} LIVE`} styles[
color={ totalActiveRoutes === 0
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error' ? 'routesError'
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning' : totalActiveRoutes < totalRoutes
: 'success' ? 'routesWarning'
} : 'routesSuccess'
variant="filled" ]
/>
}
meta={
<div className={styles.groupMeta}>
<span><strong>{groupTps.toFixed(1)}</strong> msg/s</span>
<span><strong>{groupActiveRoutes}</strong>/{groupTotalRoutes} routes</span>
</div>
}
accent={
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
: 'success'
} }
> >
{deadInGroup.length > 0 && ( {totalActiveRoutes}/{totalRoutes}
<Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert> </span>
)} }
<table className={styles.instanceTable}> accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'}
<thead> detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'}
<tr> />
<th className={styles.thStatus} /> <StatCard
<th>Instance</th> label="Total TPS"
<th>State</th> value={totalTps.toFixed(1)}
<th>Uptime</th> accent="amber"
<th>TPS</th> detail="msg/s"
<th>Errors</th> />
<th>Heartbeat</th> <StatCard
</tr> label="Dead"
</thead> value={String(deadCount)}
<tbody> accent={deadCount > 0 ? 'error' : 'success'}
{(groupAgents || []).map((agent: any) => ( detail={deadCount > 0 ? 'requires attention' : 'all healthy'}
<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>
);
})}
</div> </div>
{/* Scope trail + badges */}
<div className={styles.scopeTrail}>
{appId && (
<>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeCurrent}>{appId}</span>
</>
)}
<Badge
label={`${liveCount}/${totalInstances} live`}
color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'}
variant="filled"
/>
</div>
{/* Group cards grid */}
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
{groups.map((group) => (
<GroupCard
key={group.appId}
title={group.appId}
accent={appHealth(group)}
headerRight={
<Badge
label={`${group.liveCount}/${group.instances.length} LIVE`}
color={appHealth(group)}
variant="filled"
/>
}
meta={
<div className={styles.groupMeta}>
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
<span>
<StatusDot
variant={
appHealth(group) === 'success'
? 'live'
: appHealth(group) === 'warning'
? 'stale'
: 'dead'
}
/>
</span>
</div>
}
footer={
group.deadCount > 0 ? (
<div className={styles.alertBanner}>
<span className={styles.alertIcon}>&#9888;</span>
<span>
Single point of failure &mdash;{' '}
{group.deadCount === group.instances.length
? 'no redundancy'
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
</span>
</div>
) : undefined
}
>
<DataTable<AgentInstance>
columns={instanceColumns}
data={group.instances}
onRowClick={handleInstanceClick}
selectedId={panelOpen ? selectedInstance?.id : undefined}
pageSize={50}
flush
/>
</GroupCard>
))}
</div>
{/* EventFeed */}
{feedEvents.length > 0 && ( {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>
); );

View File

@@ -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);
} }

View File

@@ -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,26 +51,34 @@ export default function AgentInstance() {
60, 60,
); );
const chartData = useMemo(() => const chartData = useMemo(
(timeseries?.buckets || []).map((b: any) => ({ () =>
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), (timeseries?.buckets || []).map((b: any) => ({
throughput: b.totalCount, time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
latency: b.avgDurationMs, throughput: b.totalCount,
errors: b.failedCount, latency: b.avgDurationMs,
})), errors: b.failedCount,
})),
[timeseries], [timeseries],
); );
const feedEvents = useMemo(() => const feedEvents = useMemo<FeedEvent[]>(
(events || []).filter((e: any) => !instanceId || e.agentId === instanceId).map((e: any) => ({ () =>
id: String(e.id), (events || [])
severity: e.eventType === 'WENT_DEAD' ? 'error' as const .filter((e: any) => !instanceId || e.agentId === instanceId)
: e.eventType === 'WENT_STALE' ? 'warning' as const .map((e: any) => ({
: e.eventType === 'RECOVERED' ? 'success' as const id: String(e.id),
: 'running' as const, severity:
message: `${e.eventType}${e.detail ? ' — ' + e.detail : ''}`, e.eventType === 'WENT_DEAD'
timestamp: new Date(e.timestamp), ? ('error' as const)
})), : e.eventType === 'WENT_STALE'
? ('warning' as const)
: e.eventType === 'RECOVERED'
? ('success' as const)
: ('running' as const),
message: `${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
[events, instanceId], [events, instanceId],
); );
@@ -88,194 +104,305 @@ 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" />;
return ( const statusVariant =
<div> agent?.status === 'LIVE' ? 'live' : agent?.status === 'STALE' ? 'stale' : 'dead';
<Breadcrumb items={[ const statusColor: 'success' | 'warning' | 'error' =
{ label: 'Agents', href: '/agents' }, agent?.status === 'LIVE' ? 'success' : agent?.status === 'STALE' ? 'warning' : 'error';
{ label: appId || '', href: `/agents/${appId}` }, const cpuDisplay = cpuPct != null ? (cpuPct * 100).toFixed(0) : null;
{ label: agent?.name || instanceId || '' }, const heapUsedMB = heapUsed != null ? (heapUsed / (1024 * 1024)).toFixed(0) : null;
]} /> const heapMaxMB = heapMax != null ? (heapMax / (1024 * 1024)).toFixed(0) : null;
return (
<div className={styles.content}>
{/* Stat strip — 5 columns */}
<div className={styles.statStrip}>
<StatCard
label="CPU"
value={cpuDisplay != null ? `${cpuDisplay}%` : '\u2014'}
accent={
cpuDisplay != null
? Number(cpuDisplay) > 85
? 'error'
: Number(cpuDisplay) > 70
? 'warning'
: 'success'
: undefined
}
/>
<StatCard
label="Memory"
value={memPct != null ? `${memPct.toFixed(0)}%` : '\u2014'}
accent={
memPct != null
? memPct > 85
? 'error'
: memPct > 70
? 'warning'
: 'success'
: undefined
}
detail={
heapUsedMB != null && heapMaxMB != null
? `${heapUsedMB} MB / ${heapMaxMB} MB`
: undefined
}
/>
<StatCard
label="Throughput"
value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'}
accent="amber"
detail="msg/s"
/>
<StatCard
label="Errors"
value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '\u2014'}
accent={agent?.errorRate > 0 ? 'error' : 'success'}
/>
<StatCard
label="Uptime"
value={formatUptime(agent?.uptimeSeconds)}
accent="running"
detail={
agent?.registeredAt
? `since ${new Date(agent.registeredAt).toLocaleDateString()}`
: undefined
}
/>
</div>
{/* Scope trail + badges */}
{agent && ( {agent && (
<> <>
<div className={styles.agentHeader}>
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
<h2>{agent.name}</h2>
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
{agent.version && <Badge label={agent.version} variant="outlined" />}
</div>
<div className={styles.statStrip}>
<StatCard label="CPU" value={cpuPct != null ? `${(cpuPct * 100).toFixed(0)}%` : '—'} />
<StatCard
label="Memory"
value={memPct != null ? `${memPct.toFixed(0)}%` : '—'}
detail={heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : undefined}
/>
<StatCard label="Throughput" value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '—'} />
<StatCard label="Errors" value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '—'} accent={agent?.errorRate > 0 ? 'error' : undefined} />
<StatCard
label="Uptime"
value={formatUptime(agent?.uptimeSeconds)}
detail={agent?.registeredAt ? `since ${new Date(agent.registeredAt).toLocaleDateString()}` : undefined}
/>
</div>
<div className={styles.scopeTrail}> <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}>&#9656;</span> <span className={styles.scopeSep}>&#9656;</span>
<a href={`/agents/${appId}`} className={styles.scopeLink}>{appId}</a> <Link to={`/agents/${appId}`} className={styles.scopeLink}>
{appId}
</Link>
<span className={styles.scopeSep}>&#9656;</span> <span className={styles.scopeSep}>&#9656;</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> </>
)}
<span className={styles.processLabel}>Started</span>
<MonoText size="xs">
{agent.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '\u2014'}
</MonoText>
{agent.capabilities && (
<>
<span className={styles.processLabel}>Capabilities</span>
<span className={styles.capTags}>
{Object.entries(agent.capabilities)
.filter(([, v]) => typeof v === 'boolean' && v)
.map(([k]) => (
<Badge key={k} label={k} variant="outlined" />
))}
</span>
</>
)} )}
<div>
<span className={styles.infoLabel}>Started</span>
<span>{agent?.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '—'}</span>
</div>
<div>
<span className={styles.infoLabel}>Capabilities</span>
<span className={styles.capTags}>
{Object.entries(agent?.capabilities || {})
.filter(([, v]) => typeof v === 'boolean' && v)
.map(([k]) => (
<Badge key={k} label={k} variant="outlined" />
))}
</span>
</div>
</div> </div>
</Card>
<div className={styles.sectionTitle}>Routes</div>
<div className={styles.routeBadges}>
{(agent.routeIds || []).map((r: string) => (
<Badge key={r} label={r} color="auto" />
))}
</div> </div>
{/* Routes */}
{(agent.routeIds?.length ?? 0) > 0 && (
<>
<SectionHeader>Routes</SectionHeader>
<div className={styles.routeBadges}>
{(agent.routeIds || []).map((r: string) => (
<Badge key={r} label={r} color="auto" />
))}
</div>
</>
)}
</> </>
)} )}
{/* Charts grid — 3x2 */}
<div className={styles.chartsGrid}> <div className={styles.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}>
{threadSeries
? `${threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active`
: ''}
</span>
</div> </div>
{threadSeries {threadSeries ? (
? <LineChart series={threadSeries} yLabel="threads" height={200} /> <LineChart series={threadSeries} height={160} yLabel="threads" />
: <EmptyState title="No data" description="No thread metrics available" />} ) : (
<EmptyState title="No data" description="No thread metrics available" />
)}
</div> </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 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>
</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);

View File

@@ -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;
} }

View File

@@ -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"
>&#x2197;</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}`)
}}
>
&#x2197;
</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>
<div className={styles.tableSection}> {/* Exchanges table */}
<div className={styles.tableHeader}> <div className={styles.tableSection}>
<span className={styles.tableTitle}>Recent Exchanges</span> <div className={styles.tableHeader}>
<div className={styles.tableRight}> <span className={styles.tableTitle}>Recent Exchanges</span>
<span className={styles.tableMeta}>{rows.length} of {searchResult?.total ?? 0} exchanges</span> <div className={styles.tableRight}>
<Badge label="LIVE" color="success" /> <span className={styles.tableMeta}>
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
</span>
<Badge label="LIVE" color="success" />
</div>
</div> </div>
<DataTable
columns={columns}
data={rows}
onRowClick={handleRowClick}
selectedId={selectedId}
sortable
flush
rowAccent={handleRowAccent}
expandedContent={(row: Row) =>
row.errorMessage ? (
<div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}>{'\u26A0'}</span>
<div>
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
</div>
</div>
) : null
}
/>
</div> </div>
<DataTable
columns={columns}
data={rows}
onRowClick={(row) => { setSelectedId(row.id); }}
selectedId={selectedId ?? undefined}
sortable
pageSize={25}
/>
</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;
} }

View File

@@ -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;
}

View File

@@ -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 &quot;{id}&quot; 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}>&middot;</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>
); )
} }

View File

@@ -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); }

View File

@@ -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) => {
id: p.processorId, const callCount = p.callCount ?? 0;
processorId: p.processorId, const errorCount = p.errorCount ?? 0;
callCount: p.callCount ?? 0, const errRate = callCount > 0 ? (errorCount / callCount) * 100 : 0;
avgDurationMs: p.avgDurationMs ?? 0, return {
p99DurationMs: p.p99DurationMs ?? 0, id: p.processorId,
errorCount: p.errorCount ?? 0, processorId: p.processorId,
})), type: classifyProcessorType(p.processorId ?? ''),
callCount,
avgDurationMs: p.avgDurationMs ?? 0,
p99DurationMs: p.p99DurationMs ?? 0,
errorCount,
errorRate: Number(errRate.toFixed(2)),
sparkline: p.sparkline ?? [],
};
}),
[processorMetrics], [processorMetrics],
); );
const chartData = useMemo(() => // Timeseries-derived data
(timeseries?.buckets || []).map((b: any) => ({ const throughputSparkline = useMemo(() =>
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), (timeseries?.buckets || []).map((b) => b.totalCount),
throughput: b.totalCount, [timeseries],
latency: b.avgDurationMs, );
errors: b.failedCount, const errorSparkline = useMemo(() =>
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100, (timeseries?.buckets || []).map((b) => b.failedCount),
})), [timeseries],
);
const latencySparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.p99DurationMs),
[timeseries], [timeseries],
); );
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b) => {
const ts = new Date(b.time);
return {
time: !isNaN(ts.getTime())
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: '\u2014',
throughput: b.totalCount,
latency: b.avgDurationMs,
errors: b.failedCount,
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
};
}),
[timeseries],
);
// Exchange rows
const exchangeRows: ExchangeRow[] = useMemo(() => 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 &larr; {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>
) : ( ) : (

View File

@@ -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); }

View File

@@ -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>
<span className={styles.tableMeta}>{rows.length} routes</span> <div className={styles.tableRight}>
<span className={styles.tableMeta}>{rows.length} routes</span>
<Badge label="LIVE" color="success" />
</div>
</div> </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>