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 { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, useCommandPalette } from '@cameleer/design-system';
import type { SidebarApp, SearchResult } from '@cameleer/design-system';
import { useRouteCatalog } from '../api/queries/catalog';
import { useAgents } from '../api/queries/agents';
import { useAuthStore } from '../auth/auth-store';
import { useMemo, useCallback } from 'react';
import type { SidebarApp } from '@cameleer/design-system';
function healthToColor(health: string): string {
switch (health) {
case 'live': return 'success';
case 'stale': return 'warning';
case 'dead': return 'error';
default: return 'auto';
}
}
function buildSearchData(
catalog: any[] | undefined,
agents: any[] | undefined,
): SearchResult[] {
if (!catalog) return [];
const results: SearchResult[] = [];
for (const app of catalog) {
const liveAgents = (app.agents || []).filter((a: any) => a.status === 'live').length;
results.push({
id: app.appId,
category: 'application',
title: app.appId,
badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToColor(app.health) }],
meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`,
path: `/apps/${app.appId}`,
});
for (const route of (app.routes || [])) {
results.push({
id: route.routeId,
category: 'route',
title: route.routeId,
badges: [{ label: app.appId }],
meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`,
path: `/apps/${app.appId}/${route.routeId}`,
});
}
}
if (agents) {
for (const agent of agents) {
results.push({
id: agent.id,
category: 'agent',
title: agent.name,
badges: [{ label: (agent.state || 'unknown').toUpperCase(), color: healthToColor((agent.state || '').toLowerCase()) }],
meta: `${agent.application} · ${agent.version || ''}${agent.agentTps != null ? ` · ${agent.agentTps.toFixed(1)} msg/s` : ''}`,
path: `/agents/${agent.application}/${agent.id}`,
});
}
}
return results;
}
function LayoutContent() {
const navigate = useNavigate();
const location = useLocation();
const { data: catalog } = useRouteCatalog();
const { username, roles, logout } = useAuthStore();
const { data: agents } = useAgents();
const { username, logout } = useAuthStore();
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
const sidebarApps: SidebarApp[] = useMemo(() => {
@@ -33,6 +90,11 @@ function LayoutContent() {
}));
}, [catalog]);
const searchData = useMemo(
() => buildSearchData(catalog, agents as any[]),
[catalog, agents],
);
const breadcrumb = useMemo(() => {
const parts = location.pathname.split('/').filter(Boolean);
return parts.map((part, i) => ({
@@ -47,12 +109,12 @@ function LayoutContent() {
}, [logout, navigate]);
const handlePaletteSelect = useCallback((result: any) => {
if (result.path) navigate(result.path);
if (result.path) {
navigate(result.path, { state: result.path ? { sidebarReveal: result.path } : undefined });
}
setPaletteOpen(false);
}, [navigate, setPaletteOpen]);
const isAdmin = roles.includes('ADMIN');
return (
<AppShell
sidebar={
@@ -70,7 +132,7 @@ function LayoutContent() {
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onSelect={handlePaletteSelect}
data={[]}
data={searchData}
/>
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
<Outlet />

View File

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

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 { DataTable, Badge, Input, Select, MonoText, CodeBlock } from '@cameleer/design-system';
import {
Badge, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useAuditLog } from '../../api/queries/admin/audit';
import { useAuditLog, type AuditEvent } from '../../api/queries/admin/audit';
import styles from './AuditLogPage.module.css';
const CATEGORIES = [
{ value: '', label: 'All categories' },
{ value: 'INFRA', label: 'INFRA' },
{ value: 'AUTH', label: 'AUTH' },
{ value: 'USER_MGMT', label: 'USER_MGMT' },
{ value: 'CONFIG', label: 'CONFIG' },
{ value: 'RBAC', label: 'RBAC' },
];
function formatTimestamp(iso: string): string {
return new Date(iso).toLocaleString('en-GB', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
});
}
type AuditRow = Omit<AuditEvent, 'id'> & { id: string };
const COLUMNS: Column<AuditRow>[] = [
{
key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true,
render: (_, row) => <MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>,
},
{
key: 'username', header: 'User', sortable: true,
render: (_, row) => <span style={{ fontWeight: 500 }}>{row.username}</span>,
},
{
key: 'category', header: 'Category', width: '110px', sortable: true,
render: (_, row) => <Badge label={row.category} color="auto" />,
},
{ key: 'action', header: 'Action' },
{
key: 'target', header: 'Target',
render: (_, row) => <span className={styles.target}>{row.target}</span>,
},
{
key: 'result', header: 'Result', width: '90px', sortable: true,
render: (_, row) => (
<Badge label={row.result} color={row.result === 'SUCCESS' ? 'success' : 'error'} />
),
},
];
export default function AuditLogPage() {
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [dateRange, setDateRange] = useState({
start: new Date(Date.now() - 7 * 24 * 3600_000),
end: new Date(),
});
const [userFilter, setUserFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [searchFilter, setSearchFilter] = useState('');
const [page, setPage] = useState(0);
const { data, isLoading } = useAuditLog({ search, category: category || undefined, page, size: 25 });
const { data } = useAuditLog({
username: userFilter || undefined,
category: categoryFilter || undefined,
search: searchFilter || undefined,
from: dateRange.start.toISOString(),
to: dateRange.end.toISOString(),
page,
size: 25,
});
const columns: Column<any>[] = [
{ key: 'timestamp', header: 'Time', sortable: true, render: (v) => new Date(v as string).toLocaleString() },
{ key: 'username', header: 'User', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'action', header: 'Action' },
{ key: 'category', header: 'Category', render: (v) => <Badge label={String(v)} color="auto" /> },
{ key: 'target', header: 'Target', render: (v) => v ? <MonoText size="sm">{String(v)}</MonoText> : null },
{ key: 'result', header: 'Result', render: (v) => <Badge label={String(v)} color={v === 'SUCCESS' ? 'success' : 'error'} /> },
];
const rows = useMemo(() =>
(data?.items || []).map((item: any) => ({ ...item, id: String(item.id) })),
const rows: AuditRow[] = useMemo(
() => (data?.items || []).map((item) => ({ ...item, id: String(item.id) })),
[data],
);
const totalCount = data?.totalCount ?? 0;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Audit Log</h2>
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} />
<div className={styles.filters}>
<DateRangePicker
value={dateRange}
onChange={(range) => { setDateRange(range); setPage(0); }}
/>
<Input
placeholder="Filter by user..."
value={userFilter}
onChange={(e) => { setUserFilter(e.target.value); setPage(0); }}
onClear={() => { setUserFilter(''); setPage(0); }}
className={styles.filterInput}
/>
<Select
options={[
{ value: '', label: 'All Categories' },
{ value: 'AUTH', label: 'Auth' },
{ value: 'CONFIG', label: 'Config' },
{ value: 'RBAC', label: 'RBAC' },
{ value: 'INFRA', label: 'Infra' },
]}
value={category}
onChange={(e) => setCategory(e.target.value)}
options={CATEGORIES}
value={categoryFilter}
onChange={(e) => { setCategoryFilter(e.target.value); setPage(0); }}
className={styles.filterSelect}
/>
<Input
placeholder="Search action or target..."
value={searchFilter}
onChange={(e) => { setSearchFilter(e.target.value); setPage(0); }}
onClear={() => { setSearchFilter(''); setPage(0); }}
className={styles.filterInput}
/>
</div>
<DataTable
columns={columns}
data={rows}
sortable
pageSize={25}
expandedContent={(row) => (
<div style={{ padding: '0.75rem' }}>
<CodeBlock content={JSON.stringify(row.detail, null, 2)} />
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Audit Log</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{totalCount} events
</span>
<Badge label="LIVE" color="success" />
</div>
)}
/>
</div>
<DataTable
columns={COLUMNS}
data={rows}
sortable
flush
pageSize={25}
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
expandedContent={(row) => (
<div className={styles.expandedDetail}>
<div className={styles.detailGrid}>
<div className={styles.detailField}>
<span className={styles.detailLabel}>IP Address</span>
<MonoText size="xs">{row.ipAddress}</MonoText>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>User Agent</span>
<span className={styles.detailValue}>{row.userAgent}</span>
</div>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Detail</span>
<CodeBlock content={JSON.stringify(row.detail, null, 2)} language="json" />
</div>
</div>
)}
/>
</div>
</div>
);
}

View File

@@ -1,15 +1,20 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
Avatar,
Badge,
Button,
Input,
MonoText,
Tag,
Select,
ConfirmDialog,
Spinner,
MonoText,
SectionHeader,
Tag,
InlineEdit,
MultiSelect,
ConfirmDialog,
AlertDialog,
SplitPane,
EntityList,
Spinner,
useToast,
} from '@cameleer/design-system';
import {
@@ -25,26 +30,31 @@ import {
useUsers,
useRoles,
} from '../../api/queries/admin/rbac';
import type { GroupDetail } from '../../api/queries/admin/rbac';
import styles from './UserManagement.module.css';
const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010';
export default function GroupsTab() {
const [search, setSearch] = useState('');
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [newGroupParentId, setNewGroupParentId] = useState<string>('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [addMemberUserId, setAddMemberUserId] = useState<string>('');
const [addRoleId, setAddRoleId] = useState<string>('');
const { toast } = useToast();
const { data: groups = [], isLoading: groupsLoading } = useGroups();
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedGroupId);
const { data: users = [] } = useUsers();
const { data: roles = [] } = useRoles();
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<GroupDetail | null>(null);
const [removeRoleTarget, setRemoveRoleTarget] = useState<string | null>(null);
// Create form state
const [newName, setNewName] = useState('');
const [newParent, setNewParent] = useState('');
// Detail query
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedId);
// Mutations
const createGroup = useCreateGroup();
const updateGroup = useUpdateGroup();
const deleteGroup = useDeleteGroup();
@@ -53,350 +63,385 @@ export default function GroupsTab() {
const addUserToGroup = useAddUserToGroup();
const removeUserFromGroup = useRemoveUserFromGroup();
const filteredGroups = groups.filter((g) =>
g.name.toLowerCase().includes(search.toLowerCase())
);
const filtered = useMemo(() => {
if (!search) return groups;
const q = search.toLowerCase();
return groups.filter((g) => g.name.toLowerCase().includes(q));
}, [groups, search]);
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
const parentOptions = [
{ value: '', label: 'Top-level' },
...groups.map((g) => ({ value: g.id, label: g.name })),
...groups
.filter((g) => g.id !== selectedId)
.map((g) => ({ value: g.id, label: g.name })),
];
const parentName = (parentGroupId: string | null) => {
const duplicateGroupName =
newName.trim() !== '' &&
groups.some(
(g) => g.name.toLowerCase() === newName.trim().toLowerCase(),
);
// Derived data for the detail pane
const children = selectedGroup?.childGroups ?? [];
const members = selectedGroup?.members ?? [];
const parentGroup = selectedGroup?.parentGroupId
? groups.find((g) => g.id === selectedGroup.parentGroupId)
: null;
const memberUserIds = new Set(members.map((m) => m.userId));
const assignedRoleIds = new Set(
(selectedGroup?.directRoles ?? []).map((r) => r.id),
);
const availableRoles = roles
.filter((r) => !assignedRoleIds.has(r.id))
.map((r) => ({ value: r.id, label: r.name }));
const availableMembers = users
.filter((u) => !memberUserIds.has(u.userId))
.map((u) => ({ value: u.userId, label: u.displayName }));
function parentName(parentGroupId: string | null): string {
if (!parentGroupId) return 'Top-level';
const parent = groups.find((g) => g.id === parentGroupId);
return parent ? parent.name : parentGroupId;
};
}
const handleCreate = async () => {
const name = newGroupName.trim();
if (!name) return;
async function handleCreate() {
if (!newName.trim()) return;
try {
await createGroup.mutateAsync({
name,
parentGroupId: newGroupParentId || null,
name: newName.trim(),
parentGroupId: newParent || null,
});
toast({ title: 'Group created', variant: 'success' });
setNewGroupName('');
setNewGroupParentId('');
setShowCreate(false);
toast({ title: 'Group created', description: newName.trim(), variant: 'success' });
setCreating(false);
setNewName('');
setNewParent('');
} catch {
toast({ title: 'Failed to create group', variant: 'error' });
}
};
}
const handleRename = async (newName: string) => {
async function handleDelete() {
if (!deleteTarget) return;
try {
await deleteGroup.mutateAsync(deleteTarget.id);
toast({
title: 'Group deleted',
description: deleteTarget.name,
variant: 'warning',
});
if (selectedId === deleteTarget.id) setSelectedId(null);
setDeleteTarget(null);
} catch {
toast({ title: 'Failed to delete group', variant: 'error' });
setDeleteTarget(null);
}
}
async function handleRename(newNameVal: string) {
if (!selectedGroup) return;
try {
await updateGroup.mutateAsync({
id: selectedGroup.id,
name: newName,
name: newNameVal,
parentGroupId: selectedGroup.parentGroupId,
});
toast({ title: 'Group renamed', variant: 'success' });
} catch {
toast({ title: 'Failed to rename group', variant: 'error' });
}
};
}
const handleDelete = async () => {
async function handleRemoveMember(userId: string) {
if (!selectedGroup) return;
try {
await deleteGroup.mutateAsync(selectedGroup.id);
toast({ title: 'Group deleted', variant: 'success' });
setSelectedGroupId(null);
setDeleteOpen(false);
} catch {
toast({ title: 'Failed to delete group', variant: 'error' });
}
};
const handleAddMember = async () => {
if (!selectedGroup || !addMemberUserId) return;
try {
await addUserToGroup.mutateAsync({
userId: addMemberUserId,
await removeUserFromGroup.mutateAsync({
userId,
groupId: selectedGroup.id,
});
toast({ title: 'Member added', variant: 'success' });
setAddMemberUserId('');
} catch {
toast({ title: 'Failed to add member', variant: 'error' });
}
};
const handleRemoveMember = async (userId: string) => {
if (!selectedGroup) return;
try {
await removeUserFromGroup.mutateAsync({ userId, groupId: selectedGroup.id });
toast({ title: 'Member removed', variant: 'success' });
} catch {
toast({ title: 'Failed to remove member', variant: 'error' });
}
};
}
const handleAddRole = async () => {
if (!selectedGroup || !addRoleId) return;
try {
await assignRoleToGroup.mutateAsync({
groupId: selectedGroup.id,
roleId: addRoleId,
});
toast({ title: 'Role assigned', variant: 'success' });
setAddRoleId('');
} catch {
toast({ title: 'Failed to assign role', variant: 'error' });
async function handleAddMembers(userIds: string[]) {
if (!selectedGroup) return;
for (const userId of userIds) {
try {
await addUserToGroup.mutateAsync({
userId,
groupId: selectedGroup.id,
});
toast({ title: 'Member added', variant: 'success' });
} catch {
toast({ title: 'Failed to add member', variant: 'error' });
}
}
};
}
const handleRemoveRole = async (roleId: string) => {
async function handleAddRoles(roleIds: string[]) {
if (!selectedGroup) return;
for (const roleId of roleIds) {
try {
await assignRoleToGroup.mutateAsync({
groupId: selectedGroup.id,
roleId,
});
toast({ title: 'Role assigned', variant: 'success' });
} catch {
toast({ title: 'Failed to assign role', variant: 'error' });
}
}
}
async function handleRemoveRole(roleId: string) {
if (!selectedGroup) return;
try {
await removeRoleFromGroup.mutateAsync({ groupId: selectedGroup.id, roleId });
await removeRoleFromGroup.mutateAsync({
groupId: selectedGroup.id,
roleId,
});
toast({ title: 'Role removed', variant: 'success' });
} catch {
toast({ title: 'Failed to remove role', variant: 'error' });
}
};
}
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
// Build sets for quick lookup of already-assigned items
const memberUserIds = new Set((selectedGroup?.members ?? []).map((m) => m.userId));
const assignedRoleIds = new Set((selectedGroup?.directRoles ?? []).map((r) => r.id));
const availableUsers = users.filter((u) => !memberUserIds.has(u.userId));
const availableRoles = roles.filter((r) => !assignedRoleIds.has(r.id));
if (groupsLoading) return <Spinner size="md" />;
return (
<div className={styles.splitPane}>
{/* Left pane */}
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search groups..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch('')}
/>
<Button
size="sm"
variant="secondary"
onClick={() => setShowCreate((v) => !v)}
>
+ Add Group
</Button>
</div>
{showCreate && (
<div className={styles.createForm}>
<Input
placeholder="Group name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
<div style={{ marginTop: 8 }}>
<Select
options={parentOptions}
value={newGroupParentId}
onChange={(e) => setNewGroupParentId(e.target.value)}
/>
</div>
<div className={styles.createFormActions}>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowCreate(false);
setNewGroupName('');
setNewGroupParentId('');
}}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
loading={createGroup.isPending}
onClick={handleCreate}
disabled={!newGroupName.trim()}
>
Create
</Button>
</div>
</div>
)}
{groupsLoading ? (
<Spinner />
) : (
<div className={styles.entityList} role="listbox">
{filteredGroups.map((group) => {
const isSelected = group.id === selectedGroupId;
return (
<div
key={group.id}
role="option"
aria-selected={isSelected}
className={
styles.entityItem +
(isSelected ? ' ' + styles.entityItemSelected : '')
}
onClick={() => setSelectedGroupId(group.id)}
>
<Avatar name={group.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{group.name}</div>
<div className={styles.entityMeta}>
{group.parentGroupId
? `Child of ${parentName(group.parentGroupId)}`
: 'Top-level'}
</div>
</div>
<>
<SplitPane
list={
<>
{creating && (
<div className={styles.createForm}>
<Input
placeholder="Group name *"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
{duplicateGroupName && (
<span style={{ color: 'var(--error)', fontSize: 11 }}>
Group name already exists
</span>
)}
<Select
options={parentOptions}
value={newParent}
onChange={(e) => setNewParent(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button
size="sm"
variant="ghost"
onClick={() => setCreating(false)}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
onClick={handleCreate}
loading={createGroup.isPending}
disabled={!newName.trim() || duplicateGroupName}
>
Create
</Button>
</div>
);
})}
</div>
)}
</div>
{/* Right pane */}
<div className={styles.detailPane}>
{!selectedGroupId ? (
<div className={styles.emptyDetail}>Select a group to view details</div>
) : detailLoading ? (
<Spinner />
) : selectedGroup ? (
<div>
{/* Header */}
<div className={styles.detailHeader}>
<Avatar name={selectedGroup.name} size="md" />
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEdit
value={selectedGroup.name}
onSave={handleRename}
disabled={isBuiltinAdmins}
/>
<div className={styles.entityMeta}>
{selectedGroup.parentGroupId
? `Child of ${parentName(selectedGroup.parentGroupId)}`
: 'Top-level'}
</div>
</div>
<Button
variant="danger"
size="sm"
disabled={isBuiltinAdmins}
onClick={() => setDeleteOpen(true)}
>
Delete
</Button>
</div>
{/* Metadata */}
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>Group ID</span>
<MonoText size="xs">{selectedGroup.id}</MonoText>
<span className={styles.metaLabel}>Parent</span>
<span>{parentName(selectedGroup.parentGroupId)}</span>
</div>
{/* Members */}
<div className={styles.sectionTitle}>Members</div>
<div className={styles.sectionTags}>
{(selectedGroup.members ?? []).map((member) => (
<Tag
key={member.userId}
label={member.displayName}
onRemove={() => handleRemoveMember(member.userId)}
/>
))}
{(selectedGroup.members ?? []).length === 0 && (
<span className={styles.inheritedNote}>No members</span>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Select
options={[
{ value: '', label: 'Add member...' },
...availableUsers.map((u) => ({
value: u.userId,
label: u.displayName,
})),
]}
value={addMemberUserId}
onChange={(e) => setAddMemberUserId(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
onClick={handleAddMember}
disabled={!addMemberUserId || addUserToGroup.isPending}
>
Add
</Button>
</div>
{/* Assigned roles */}
<div className={styles.sectionTitle}>Assigned Roles</div>
<div className={styles.sectionTags}>
{(selectedGroup.directRoles ?? []).map((role) => (
<Badge
key={role.id}
label={role.name}
variant="outlined"
onRemove={() => handleRemoveRole(role.id)}
/>
))}
{(selectedGroup.directRoles ?? []).length === 0 && (
<span className={styles.inheritedNote}>No roles assigned</span>
)}
</div>
{(selectedGroup.effectiveRoles ?? []).length >
(selectedGroup.directRoles ?? []).length && (
<div className={styles.inheritedNote}>
+
{(selectedGroup.effectiveRoles ?? []).length -
(selectedGroup.directRoles ?? []).length}{' '}
inherited role(s)
</div>
)}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Select
options={[
{ value: '', label: 'Assign role...' },
...availableRoles.map((r) => ({
value: r.id,
label: r.name,
})),
]}
value={addRoleId}
onChange={(e) => setAddRoleId(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
onClick={handleAddRole}
disabled={!addRoleId || assignRoleToGroup.isPending}
>
Add
</Button>
</div>
</div>
) : null}
</div>
{/* Delete confirmation */}
<EntityList
items={filtered}
renderItem={(group) => {
const groupChildren = groups.filter(
(g) => g.parentGroupId === group.id,
);
const groupParent = group.parentGroupId
? groups.find((g) => g.id === group.parentGroupId)
: null;
return (
<>
<Avatar name={group.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{group.name}</div>
<div className={styles.entityMeta}>
{groupParent
? `Child of ${groupParent.name}`
: 'Top-level'}
{' \u00b7 '}
{groupChildren.length} children
{' \u00b7 '}
{(group.members ?? []).length} members
</div>
<div className={styles.entityTags}>
{(group.directRoles ?? []).map((r) => (
<Badge key={r.id} label={r.name} color="warning" />
))}
</div>
</div>
</>
);
}}
getItemId={(group) => group.id}
selectedId={selectedId ?? undefined}
onSelect={setSelectedId}
searchPlaceholder="Search groups..."
onSearch={setSearch}
addLabel="+ Add group"
onAdd={() => setCreating(true)}
emptyMessage="No groups match your search"
/>
</>
}
detail={
selectedId && detailLoading ? (
<Spinner size="md" />
) : selectedGroup ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selectedGroup.name} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>
{isBuiltinAdmins ? (
selectedGroup.name
) : (
<InlineEdit
value={selectedGroup.name}
onSave={handleRename}
/>
)}
</div>
<div className={styles.detailEmail}>
{parentGroup
? `${parentGroup.name} > ${selectedGroup.name}`
: 'Top-level group'}
{isBuiltinAdmins && ' (built-in)'}
</div>
</div>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selectedGroup)}
disabled={isBuiltinAdmins}
>
Delete
</Button>
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selectedGroup.id}</MonoText>
</div>
{parentGroup && (
<>
<SectionHeader>Member of</SectionHeader>
<div className={styles.sectionTags}>
<Tag label={parentGroup.name} color="auto" />
</div>
</>
)}
<SectionHeader>Members (direct)</SectionHeader>
<div className={styles.sectionTags}>
{members.map((u) => (
<Tag
key={u.userId}
label={u.displayName}
color="auto"
onRemove={() => handleRemoveMember(u.userId)}
/>
))}
{members.length === 0 && (
<span className={styles.inheritedNote}>(no members)</span>
)}
<MultiSelect
options={availableMembers}
value={[]}
onChange={handleAddMembers}
placeholder="+ Add"
/>
</div>
{children.length > 0 && (
<span className={styles.inheritedNote}>
+ all members of {children.map((c) => c.name).join(', ')}
</span>
)}
<SectionHeader>Child groups</SectionHeader>
<div className={styles.sectionTags}>
{children.map((c) => (
<Tag key={c.id} label={c.name} color="success" />
))}
{children.length === 0 && (
<span className={styles.inheritedNote}>
(no child groups)
</span>
)}
</div>
<SectionHeader>Assigned roles</SectionHeader>
<div className={styles.sectionTags}>
{(selectedGroup.directRoles ?? []).map((r) => (
<Tag
key={r.id}
label={r.name}
color="warning"
onRemove={() => {
if (members.length > 0) {
setRemoveRoleTarget(r.id);
} else {
handleRemoveRole(r.id);
}
}}
/>
))}
{(selectedGroup.directRoles ?? []).length === 0 && (
<span className={styles.inheritedNote}>(no roles)</span>
)}
<MultiSelect
options={availableRoles}
value={[]}
onChange={handleAddRoles}
placeholder="+ Add"
/>
</div>
</>
) : null
}
emptyMessage="Select a group to view details"
/>
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
title="Delete Group"
message={`Delete group "${selectedGroup?.name}"? This action cannot be undone.`}
confirmText="DELETE"
variant="danger"
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
confirmText={deleteTarget?.name ?? ''}
loading={deleteGroup.isPending}
/>
</div>
<AlertDialog
open={removeRoleTarget !== null}
onClose={() => setRemoveRoleTarget(null)}
onConfirm={() => {
if (removeRoleTarget && selectedGroup) {
handleRemoveRole(removeRoleTarget);
}
setRemoveRoleTarget(null);
}}
title="Remove role from group"
description={`Removing this role will affect ${members.length} member(s) who inherit it. Continue?`}
confirmLabel="Remove"
variant="warning"
/>
</>
);
}

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 {
display: grid;
gap: 0.5rem;
margin-bottom: 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.section h3 {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
.toggleRow {
display: flex;
align-items: center;
gap: 12px;
}
.tagRow {
.hint {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-body);
}
.tagList {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 2rem;
align-items: center;
gap: 6px;
}
.addRow {
.noRoles {
font-size: 12px;
color: var(--text-faint);
font-style: italic;
font-family: var(--font-body);
}
.addRoleRow {
display: flex;
gap: 0.5rem;
gap: 8px;
align-items: center;
}
.addRow input {
flex: 1;
.roleInput {
width: 200px;
}

View File

@@ -1,110 +1,226 @@
import { useEffect, useState } from 'react';
import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader, Tag, ConfirmDialog } from '@cameleer/design-system';
import {
Button, Input, Toggle, FormField, SectionHeader, Tag, ConfirmDialog, Alert,
} from '@cameleer/design-system';
import { useToast } from '@cameleer/design-system';
import { adminFetch } from '../../api/queries/admin/admin-api';
import styles from './OidcConfigPage.module.css';
interface OidcConfig {
interface OidcFormData {
enabled: boolean;
autoSignup: boolean;
issuerUri: string;
clientId: string;
clientSecret: string;
rolesClaim: string;
defaultRoles: string[];
autoSignup: boolean;
displayNameClaim: string;
defaultRoles: string[];
}
const EMPTY_CONFIG: OidcFormData = {
enabled: false,
autoSignup: true,
issuerUri: '',
clientId: '',
clientSecret: '',
rolesClaim: 'roles',
displayNameClaim: 'name',
defaultRoles: ['VIEWER'],
};
export default function OidcConfigPage() {
const [config, setConfig] = useState<OidcConfig | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [form, setForm] = useState<OidcFormData | null>(null);
const [newRole, setNewRole] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
useEffect(() => {
adminFetch<OidcConfig>('/oidc')
.then(setConfig)
.catch(() => setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' }));
adminFetch<OidcFormData>('/oidc')
.then(setForm)
.catch(() => setForm(EMPTY_CONFIG));
}, []);
const handleSave = async () => {
if (!config) return;
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
setForm((prev) => prev ? { ...prev, [key]: value } : prev);
}
function addRole() {
if (!form) return;
const role = newRole.trim().toUpperCase();
if (role && !form.defaultRoles.includes(role)) {
update('defaultRoles', [...form.defaultRoles, role]);
setNewRole('');
}
}
function removeRole(role: string) {
if (!form) return;
update('defaultRoles', form.defaultRoles.filter((r) => r !== role));
}
async function handleSave() {
if (!form) return;
setSaving(true);
setError(null);
try {
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(config) });
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(form) });
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' });
} catch (e: any) {
setError(e.message);
toast({ title: 'Save failed', description: e.message, variant: 'error' });
} finally {
setSaving(false);
}
};
}
const handleDelete = async () => {
async function handleTest() {
if (!form) return;
setTesting(true);
setError(null);
try {
await adminFetch('/oidc', { method: 'DELETE' });
setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' });
const result = await adminFetch<{ status: string; authorizationEndpoint?: string }>('/oidc/test', { method: 'POST' });
toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' });
} catch (e: any) {
setError(e.message);
toast({ title: 'Connection test failed', description: e.message, variant: 'error' });
} finally {
setTesting(false);
}
};
}
if (!config) return null;
async function handleDelete() {
setDeleteOpen(false);
setError(null);
try {
await adminFetch('/oidc', { method: 'DELETE' });
setForm(EMPTY_CONFIG);
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' });
} catch (e: any) {
setError(e.message);
toast({ title: 'Delete failed', description: e.message, variant: 'error' });
}
}
if (!form) return null;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>OIDC Configuration</h2>
<Card>
<div style={{ padding: '1.5rem', display: 'grid', gap: '1rem' }}>
<Toggle checked={config.enabled} onChange={(e) => setConfig({ ...config, enabled: e.target.checked })} label="Enable OIDC" />
<FormField label="Issuer URI"><Input value={config.issuerUri} onChange={(e) => setConfig({ ...config, issuerUri: e.target.value })} /></FormField>
<FormField label="Client ID"><Input value={config.clientId} onChange={(e) => setConfig({ ...config, clientId: e.target.value })} /></FormField>
<FormField label="Client Secret"><Input type="password" value={config.clientSecret} onChange={(e) => setConfig({ ...config, clientSecret: e.target.value })} /></FormField>
<FormField label="Roles Claim"><Input value={config.rolesClaim} onChange={(e) => setConfig({ ...config, rolesClaim: e.target.value })} /></FormField>
<FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField>
<Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" />
<div className={styles.page}>
<div className={styles.toolbar}>
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri || testing}>
{testing ? 'Testing...' : 'Test Connection'}
</Button>
<Button size="sm" variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
<div className={styles.section}>
<h3>Default Roles</h3>
<div className={styles.tagRow}>
{(config.defaultRoles || []).map(role => (
<Tag key={role} label={role} onRemove={() => {
setConfig(prev => ({ ...prev!, defaultRoles: prev!.defaultRoles.filter(r => r !== role) }));
}} />
))}
</div>
<div className={styles.addRow}>
<Input placeholder="Add role..." value={newRole} onChange={e => setNewRole(e.target.value)} />
<Button onClick={() => {
if (newRole.trim() && !config.defaultRoles?.includes(newRole.trim())) {
setConfig(prev => ({ ...prev!, defaultRoles: [...(prev!.defaultRoles || []), newRole.trim()] }));
setNewRole('');
}
}}>Add</Button>
</div>
</div>
{error && <div style={{ marginBottom: 16 }}><Alert variant="error">{error}</Alert></div>}
<div style={{ display: 'flex', gap: '0.75rem' }}>
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
<Button variant="danger" onClick={() => setDeleteOpen(true)}>Delete Configuration</Button>
</div>
{error && <Alert variant="error">{error}</Alert>}
{success && <Alert variant="success">Configuration saved</Alert>}
<section className={styles.section}>
<SectionHeader>Behavior</SectionHeader>
<div className={styles.toggleRow}>
<Toggle
label="Enabled"
checked={form.enabled}
onChange={(e) => update('enabled', e.target.checked)}
/>
</div>
</Card>
<div className={styles.toggleRow}>
<Toggle
label="Auto Sign-Up"
checked={form.autoSignup}
onChange={(e) => update('autoSignup', e.target.checked)}
/>
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
</div>
</section>
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDelete}
title="Delete OIDC Configuration"
message="Delete OIDC configuration? All OIDC users will lose access."
confirmText="DELETE"
/>
<section className={styles.section}>
<SectionHeader>Provider Settings</SectionHeader>
<FormField label="Issuer URI" htmlFor="issuer">
<Input
id="issuer"
type="url"
placeholder="https://idp.example.com/realms/my-realm"
value={form.issuerUri}
onChange={(e) => update('issuerUri', e.target.value)}
/>
</FormField>
<FormField label="Client ID" htmlFor="client-id">
<Input
id="client-id"
value={form.clientId}
onChange={(e) => update('clientId', e.target.value)}
/>
</FormField>
<FormField label="Client Secret" htmlFor="client-secret">
<Input
id="client-secret"
type="password"
value={form.clientSecret}
onChange={(e) => update('clientSecret', e.target.value)}
/>
</FormField>
</section>
<section className={styles.section}>
<SectionHeader>Claim Mapping</SectionHeader>
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
<Input
id="roles-claim"
value={form.rolesClaim}
onChange={(e) => update('rolesClaim', e.target.value)}
/>
</FormField>
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
<Input
id="name-claim"
value={form.displayNameClaim}
onChange={(e) => update('displayNameClaim', e.target.value)}
/>
</FormField>
</section>
<section className={styles.section}>
<SectionHeader>Default Roles</SectionHeader>
<div className={styles.tagList}>
{form.defaultRoles.map((role) => (
<Tag key={role} label={role} color="primary" onRemove={() => removeRole(role)} />
))}
{form.defaultRoles.length === 0 && (
<span className={styles.noRoles}>No default roles configured</span>
)}
</div>
<div className={styles.addRoleRow}>
<Input
placeholder="Add role..."
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }}
className={styles.roleInput}
/>
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
Add
</Button>
</div>
</section>
<section className={styles.section}>
<SectionHeader>Danger Zone</SectionHeader>
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
Delete OIDC Configuration
</Button>
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDelete}
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
confirmText="delete oidc"
/>
</section>
</div>
);
}

View File

@@ -6,30 +6,29 @@ import UsersTab from './UsersTab';
import GroupsTab from './GroupsTab';
import RolesTab from './RolesTab';
const TABS = [
{ label: 'Users', value: 'users' },
{ label: 'Groups', value: 'groups' },
{ label: 'Roles', value: 'roles' },
];
export default function RbacPage() {
const { data: stats } = useRbacStats();
const [tab, setTab] = useState('users');
return (
<div>
<h2 style={{ margin: '0 0 16px' }}>User Management</h2>
<div className={styles.statStrip}>
<StatCard label="Users" value={stats?.userCount ?? 0} />
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
</div>
<Tabs
tabs={[
{ label: 'Users', value: 'users' },
{ label: 'Groups', value: 'groups' },
{ label: 'Roles', value: 'roles' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab />}
{tab === 'roles' && <RolesTab />}
<Tabs tabs={TABS} active={tab} onChange={setTab} />
<div className={styles.tabContent}>
{tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab />}
{tab === 'roles' && <RolesTab />}
</div>
</div>
);
}

View File

@@ -1,13 +1,16 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
Avatar,
Badge,
Button,
ConfirmDialog,
Input,
MonoText,
Spinner,
SectionHeader,
Tag,
ConfirmDialog,
SplitPane,
EntityList,
Spinner,
useToast,
} from '@cameleer/design-system';
import {
@@ -20,33 +23,54 @@ import type { RoleDetail } from '../../api/queries/admin/rbac';
import styles from './UserManagement.module.css';
export default function RolesTab() {
const { toast } = useToast();
const { data: roles, isLoading } = useRoles();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [newDescription, setNewDescription] = useState('');
const [confirmDelete, setConfirmDelete] = useState(false);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<RoleDetail | null>(null);
// Create form state
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
// Detail query
const { data: detail, isLoading: detailLoading } = useRole(selectedId);
// Mutations
const createRole = useCreateRole();
const deleteRole = useDeleteRole();
const { toast } = useToast();
const filtered = (roles ?? []).filter((r) =>
r.name.toLowerCase().includes(search.toLowerCase()),
);
const filtered = useMemo(() => {
const list = roles ?? [];
if (!search) return list;
const q = search.toLowerCase();
return list.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.description.toLowerCase().includes(q),
);
}, [roles, search]);
const duplicateRoleName =
newName.trim() !== '' &&
(roles ?? []).some((r) => r.name === newName.trim().toUpperCase());
function handleCreate() {
if (!newName.trim()) return;
createRole.mutate(
{ name: newName.trim(), description: newDescription.trim() || undefined },
{ name: newName.trim().toUpperCase(), description: newDesc.trim() || undefined },
{
onSuccess: () => {
toast({ title: 'Role created', variant: 'success' });
setShowCreate(false);
toast({
title: 'Role created',
description: newName.trim().toUpperCase(),
variant: 'success',
});
setCreating(false);
setNewName('');
setNewDescription('');
setNewDesc('');
},
onError: () => {
toast({ title: 'Failed to create role', variant: 'error' });
@@ -56,152 +80,144 @@ export default function RolesTab() {
}
function handleDelete() {
if (!selectedId) return;
deleteRole.mutate(selectedId, {
if (!deleteTarget) return;
deleteRole.mutate(deleteTarget.id, {
onSuccess: () => {
toast({ title: 'Role deleted', variant: 'success' });
setSelectedId(null);
setConfirmDelete(false);
toast({
title: 'Role deleted',
description: deleteTarget.name,
variant: 'warning',
});
if (selectedId === deleteTarget.id) setSelectedId(null);
setDeleteTarget(null);
},
onError: () => {
toast({ title: 'Failed to delete role', variant: 'error' });
setConfirmDelete(false);
setDeleteTarget(null);
},
});
}
function getAssignmentCount(role: RoleDetail): number {
return (
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0)
);
}
if (isLoading) return <Spinner size="md" />;
return (
<div className={styles.splitPane}>
{/* Left pane — list */}
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search roles…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setShowCreate((v) => !v)}
>
+ Add Role
</Button>
</div>
<>
<SplitPane
list={
<>
{creating && (
<div className={styles.createForm}>
<Input
placeholder="Role name *"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
{duplicateRoleName && (
<span style={{ color: 'var(--error)', fontSize: 11 }}>
Role name already exists
</span>
)}
<Input
placeholder="Description"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button
size="sm"
variant="ghost"
onClick={() => setCreating(false)}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
onClick={handleCreate}
loading={createRole.isPending}
disabled={!newName.trim() || duplicateRoleName}
>
Create
</Button>
</div>
</div>
)}
{showCreate && (
<div className={styles.createForm}>
<Input
placeholder="Role name (e.g. EDITOR)"
value={newName}
onChange={(e) => setNewName(e.target.value.toUpperCase())}
style={{ marginBottom: 8 }}
/>
<Input
placeholder="Description (optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowCreate(false);
setNewName('');
setNewDescription('');
}}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
loading={createRole.isPending}
disabled={!newName.trim()}
onClick={handleCreate}
>
Create
</Button>
</div>
</div>
)}
{isLoading ? (
<Spinner />
) : (
<div className={styles.entityList} role="listbox">
{filtered.map((role) => {
const assignmentCount =
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0);
return (
<div
key={role.id}
className={
styles.entityItem +
(selectedId === role.id ? ' ' + styles.entityItemSelected : '')
}
role="option"
aria-selected={selectedId === role.id}
onClick={() => setSelectedId(role.id)}
>
<EntityList
items={filtered}
renderItem={(role) => (
<>
<Avatar name={role.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{role.name}
{role.system && <Badge label="system" variant="outlined" />}
{role.system && (
<Badge
label="system"
color="auto"
variant="outlined"
className={styles.providerBadge}
/>
)}
</div>
<div className={styles.entityMeta}>
{role.description || ''} · {assignmentCount} assignment
{assignmentCount !== 1 ? 's' : ''}
{role.description || '\u2014'} \u00b7{' '}
{getAssignmentCount(role)} assignments
</div>
<div className={styles.entityTags}>
{(role.assignedGroups ?? []).map((g) => (
<Badge key={g.id} label={g.name} color="success" />
))}
{(role.directUsers ?? []).map((u) => (
<Badge
key={u.userId}
label={u.displayName}
color="auto"
/>
))}
</div>
{((role.assignedGroups?.length ?? 0) > 0 ||
(role.directUsers?.length ?? 0) > 0) && (
<div className={styles.entityTags}>
{(role.assignedGroups ?? []).map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))}
{(role.directUsers ?? []).map((u) => (
<Tag key={u.userId} label={u.displayName} />
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</>
)}
getItemId={(role) => role.id}
selectedId={selectedId ?? undefined}
onSelect={setSelectedId}
searchPlaceholder="Search roles..."
onSearch={setSearch}
addLabel="+ Add role"
onAdd={() => setCreating(true)}
emptyMessage="No roles match your search"
/>
</>
}
detail={
selectedId && (detailLoading || !detail) ? (
<Spinner size="md" />
) : detail ? (
<RoleDetailPanel
role={detail}
onDeleteRequest={() => setDeleteTarget(detail)}
/>
) : null
}
emptyMessage="Select a role to view details"
/>
{/* Right pane — detail */}
<div className={styles.detailPane}>
{!selectedId ? (
<div className={styles.emptyDetail}>Select a role to view details</div>
) : detailLoading || !detail ? (
<Spinner />
) : (
<RoleDetailPanel
role={detail}
onDeleteRequest={() => setConfirmDelete(true)}
/>
)}
</div>
{detail && (
<ConfirmDialog
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={handleDelete}
title="Delete role"
message={`Delete role "${detail.name}"? This cannot be undone.`}
confirmText={detail.name}
confirmLabel="Delete"
variant="danger"
loading={deleteRole.isPending}
/>
)}
</div>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
confirmText={deleteTarget?.name ?? ''}
loading={deleteRole.isPending}
/>
</>
);
}
@@ -213,93 +229,93 @@ interface RoleDetailPanelProps {
}
function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) {
// Build a set of directly-assigned user IDs for distinguishing inherited principals
const directUserIds = new Set((role.directUsers ?? []).map((u) => u.userId));
const directUserIds = new Set(
(role.directUsers ?? []).map((u) => u.userId),
);
const assignedGroups = role.assignedGroups ?? [];
const directUsers = role.directUsers ?? [];
const effectivePrincipals = role.effectivePrincipals ?? [];
return (
<div>
{/* Header */}
<>
<div className={styles.detailHeader}>
<Avatar name={role.name} size="md" />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 16 }}>{role.name}</div>
<Avatar name={role.name} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>{role.name}</div>
{role.description && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{role.description}
</div>
<div className={styles.detailEmail}>{role.description}</div>
)}
</div>
<Button
variant="danger"
size="sm"
disabled={role.system}
onClick={onDeleteRequest}
>
Delete
</Button>
{!role.system && (
<Button size="sm" variant="danger" onClick={onDeleteRequest}>
Delete
</Button>
)}
</div>
{/* Metadata */}
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{role.id}</MonoText>
<span className={styles.metaLabel}>Scope</span>
<span>{role.scope || ''}</span>
<span className={styles.metaLabel}>Type</span>
<span>{role.system ? 'System role (read-only)' : 'Custom role'}</span>
</div>
{/* Assigned to groups */}
<div className={styles.sectionTitle}>Assigned to groups</div>
<div className={styles.sectionTags}>
{(role.assignedGroups ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.assignedGroups ?? []).map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))
<span className={styles.metaValue}>{role.scope || '\u2014'}</span>
{role.system && (
<>
<span className={styles.metaLabel}>Type</span>
<span className={styles.metaValue}>System role (read-only)</span>
</>
)}
</div>
{/* Assigned to users (direct) */}
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
<SectionHeader>Assigned to groups</SectionHeader>
<div className={styles.sectionTags}>
{(role.directUsers ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.directUsers ?? []).map((u) => (
<Tag key={u.userId} label={u.displayName} />
))
{assignedGroups.map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))}
{assignedGroups.length === 0 && (
<span className={styles.inheritedNote}>(none)</span>
)}
</div>
{/* Effective principals */}
<div className={styles.sectionTitle}>Effective principals</div>
<SectionHeader>Assigned to users (direct)</SectionHeader>
<div className={styles.sectionTags}>
{(role.effectivePrincipals ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.effectivePrincipals ?? []).map((u) => {
const isDirect = directUserIds.has(u.userId);
return isDirect ? (
<Badge key={u.userId} label={u.displayName} variant="filled" />
) : (
<Badge
key={u.userId}
label={`${u.displayName}`}
variant="dashed"
/>
);
})
{directUsers.map((u) => (
<Tag key={u.userId} label={u.displayName} color="auto" />
))}
{directUsers.length === 0 && (
<span className={styles.inheritedNote}>(none)</span>
)}
</div>
{(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && (
<div className={styles.inheritedNote}>
<SectionHeader>Effective principals</SectionHeader>
<div className={styles.sectionTags}>
{effectivePrincipals.map((u) => {
const isDirect = directUserIds.has(u.userId);
return isDirect ? (
<Badge
key={u.userId}
label={u.displayName}
color="auto"
variant="filled"
/>
) : (
<Badge
key={u.userId}
label={u.displayName}
color="auto"
variant="dashed"
/>
);
})}
{effectivePrincipals.length === 0 && (
<span className={styles.inheritedNote}>(none)</span>
)}
</div>
{effectivePrincipals.some((u) => !directUserIds.has(u.userId)) && (
<span className={styles.inheritedNote}>
Dashed entries inherit this role through group membership
</div>
</span>
)}
</div>
</>
);
}

View File

@@ -5,187 +5,149 @@
margin-bottom: 16px;
}
.splitPane {
display: grid;
grid-template-columns: 52fr 48fr;
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-height: 500px;
box-shadow: var(--shadow-card);
}
.listPane {
background: var(--bg-surface);
display: flex;
flex-direction: column;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
}
.detailPane {
background: var(--bg-surface);
overflow-y: auto;
padding: 20px;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}
.listHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.listHeader input { flex: 1; }
.entityList {
flex: 1;
overflow-y: auto;
}
.entityItem {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid var(--border-subtle);
}
.entityItem:last-child {
border-bottom: none;
}
.entityItem:hover {
background: var(--bg-hover);
}
.entityItemSelected {
background: var(--bg-raised);
.tabContent {
margin-top: 16px;
}
.entityInfo {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.entityName {
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-body);
}
.entityMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-body);
margin-top: 2px;
}
.entityTags {
display: flex;
gap: 4px;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.createForm {
background: var(--bg-raised);
border-bottom: 1px solid var(--border-subtle);
padding: 12px;
}
.createFormActions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.detailHeader {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-subtle);
}
.detailHeaderInfo {
flex: 1;
min-width: 0;
}
.detailName {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-body);
}
.detailEmail {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-body);
}
.metaGrid {
display: grid;
grid-template-columns: 100px 1fr;
gap: 6px 12px;
font-size: 13px;
grid-template-columns: auto 1fr;
gap: 6px 16px;
margin-bottom: 16px;
font-size: 12px;
font-family: var(--font-body);
}
.metaLabel {
font-weight: 700;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
font-weight: 500;
}
.sectionTitle {
font-size: 13px;
font-weight: 700;
.metaValue {
color: var(--text-primary);
margin-bottom: 8px;
margin-top: 16px;
}
.sectionTags {
display: flex;
gap: 4px;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
margin-bottom: 8px;
}
.createForm {
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-raised);
display: flex;
flex-direction: column;
gap: 8px;
}
.createFormRow {
display: flex;
gap: 8px;
}
.createFormActions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.inheritedNote {
font-size: 11px;
font-style: italic;
color: var(--text-muted);
font-style: italic;
font-family: var(--font-body);
margin-top: 4px;
}
.providerBadge {
margin-left: 6px;
}
.inherited {
opacity: 0.65;
}
.securitySection {
padding: 12px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
margin-bottom: 16px;
margin-top: 8px;
margin-bottom: 8px;
}
.securityRow {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
font-family: var(--font-body);
color: var(--text-primary);
}
.passwordDots {
font-family: var(--font-mono);
letter-spacing: 2px;
}
.resetForm {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.emptyDetail {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
}
.emptySearch {
padding: 20px;
text-align: center;
color: var(--text-muted);
font-size: 12px;
}
.providerBadge {
font-size: 9px;
.resetInput {
width: 200px;
}

File diff suppressed because it is too large Load Diff

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 {
display: grid;
grid-template-columns: repeat(5, 1fr);
@@ -5,13 +15,66 @@
margin-bottom: 16px;
}
/* Stat breakdown with colored dots */
.breakdown {
display: flex;
gap: 8px;
font-size: 11px;
font-family: var(--font-mono);
}
.bpLive { color: var(--success); display: inline-flex; align-items: center; gap: 3px; }
.bpStale { color: var(--warning); display: inline-flex; align-items: center; gap: 3px; }
.bpDead { color: var(--error); display: inline-flex; align-items: center; gap: 3px; }
.routesSuccess { color: var(--success); }
.routesWarning { color: var(--warning); }
.routesError { color: var(--error); }
/* Scope breadcrumb trail */
.scopeTrail {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
gap: 6px;
margin-bottom: 12px;
font-size: 12px;
}
.scopeLink {
color: var(--amber);
text-decoration: none;
font-weight: 500;
}
.scopeLink:hover {
text-decoration: underline;
}
.scopeSep {
color: var(--text-muted);
font-size: 10px;
}
.scopeCurrent {
color: var(--text-primary);
font-weight: 600;
font-family: var(--font-mono);
}
/* Section header */
.sectionTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.sectionMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Group cards grid */
.groupGrid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -19,115 +82,131 @@
margin-bottom: 20px;
}
/* GroupCard meta strip */
.groupGridSingle {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
margin-bottom: 20px;
}
/* Group meta row */
.groupMeta {
display: flex;
gap: 16px;
align-items: center;
font-size: 12px;
gap: 16px;
font-size: 11px;
color: var(--text-muted);
}
.groupMeta strong {
color: var(--text-primary);
}
/* Instance table */
.instanceTable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.instanceTable thead tr {
border-bottom: 1px solid var(--border-subtle);
}
.instanceTable thead th {
padding: 6px 8px;
text-align: left;
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-secondary);
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.thStatus {
width: 24px;
/* Alert banner in group footer */
.alertBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--error-bg);
font-size: 11px;
color: var(--error);
font-weight: 500;
}
.tdStatus {
width: 24px;
padding: 0 4px 0 8px;
}
.instanceRow {
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid var(--border-subtle);
}
.instanceRow:last-child {
border-bottom: none;
}
.instanceRow:hover {
background: var(--bg-hover);
}
.instanceRow td {
padding: 7px 8px;
vertical-align: middle;
}
.instanceRowActive {
background: var(--bg-selected, var(--bg-hover));
.alertIcon {
font-size: 14px;
flex-shrink: 0;
}
/* Instance fields */
.instanceName {
font-weight: 600;
color: var(--text-primary);
}
.instanceMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
white-space: nowrap;
}
.instanceError {
font-size: 11px;
color: var(--error);
font-family: var(--font-mono);
}
.instanceHeartbeatDead {
font-size: 11px;
color: var(--error);
font-family: var(--font-mono);
white-space: nowrap;
}
.instanceHeartbeatStale {
font-size: 11px;
color: var(--warning);
font-family: var(--font-mono);
font-weight: 600;
white-space: nowrap;
}
.instanceLink {
.instanceHeartbeatDead {
color: var(--error);
font-weight: 600;
white-space: nowrap;
}
/* Detail panel content */
.detailContent {
display: flex;
flex-direction: column;
gap: 12px;
}
.detailRow {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-family: var(--font-body);
padding: 4px 0;
border-bottom: 1px solid var(--border-subtle);
}
.detailLabel {
color: var(--text-muted);
text-decoration: none;
font-size: 14px;
padding: 4px;
margin-left: auto;
font-weight: 500;
}
.instanceLink:hover {
color: var(--text-primary);
.detailProgress {
display: flex;
align-items: center;
gap: 8px;
width: 140px;
}
.chartPanel {
display: flex;
flex-direction: column;
gap: 6px;
}
.chartTitle {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.emptyChart {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
background: var(--bg-surface-raised);
border: 1px dashed var(--border-subtle);
border-radius: var(--radius-md);
font-size: 12px;
color: var(--text-muted);
}
/* Event card (timeline panel) */
.eventCard {
margin-top: 20px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
@@ -144,136 +223,4 @@
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
/* DetailPanel: Overview tab */
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
padding: 4px 0;
}
.overviewRow {
display: flex;
align-items: center;
gap: 8px;
}
.detailList {
display: flex;
flex-direction: column;
gap: 0;
margin: 0;
padding: 0;
}
.detailRow {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: 12px;
}
.detailRow:last-child {
border-bottom: none;
}
.detailRow dt {
color: var(--text-muted);
font-weight: 500;
}
.detailRow dd {
margin: 0;
color: var(--text-primary);
text-align: right;
}
.metricsSection {
display: flex;
flex-direction: column;
gap: 6px;
}
.metricLabel {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* DetailPanel: Performance tab */
.performanceContent {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 0;
}
.chartSection {
display: flex;
flex-direction: column;
gap: 6px;
}
.chartLabel {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.emptyChart {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
background: var(--bg-surface-raised);
border: 1px dashed var(--border-subtle);
border-radius: var(--radius-md);
font-size: 12px;
color: var(--text-muted);
}
/* Status breakdown in stat card */
.statusBreakdown {
display: flex;
gap: 8px;
font-size: 11px;
}
.statusLive { color: var(--success); }
.statusStale { color: var(--warning); }
.statusDead { color: var(--error); }
/* Scope trail */
.scopeLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
/* DetailPanel override */
.detailPanelOverride {
position: fixed;
top: 0;
right: 0;
height: 100vh;
z-index: 100;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
}
.panelDivider {
border-top: 1px solid var(--border-subtle);
margin: 16px 0;
}

View File

@@ -1,17 +1,31 @@
import { useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useState, useMemo } from 'react';
import { useParams, Link } from 'react-router';
import {
StatCard, StatusDot, Badge, MonoText,
GroupCard, EventFeed, Alert,
DetailPanel, ProgressBar, LineChart,
StatCard, StatusDot, Badge, MonoText, ProgressBar,
GroupCard, DataTable, LineChart, EventFeed, DetailPanel,
} from '@cameleer/design-system';
import type { Column, FeedEvent } from '@cameleer/design-system';
import styles from './AgentHealth.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useRouteCatalog } from '../../api/queries/catalog';
import { useAgentMetrics } from '../../api/queries/agent-metrics';
import type { AgentInstance } from '../../api/types';
// ── Helpers ──────────────────────────────────────────────────────────────────
function timeAgo(iso?: string): string {
if (!iso) return '\u2014';
const diff = Date.now() - new Date(iso).getTime();
const secs = Math.floor(diff / 1000);
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function formatUptime(seconds?: number): string {
if (!seconds) return '';
if (!seconds) return '\u2014';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
@@ -20,18 +34,65 @@ function formatUptime(seconds?: number): string {
return `${mins}m`;
}
function formatRelativeTime(iso?: string): string {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
function formatErrorRate(rate?: number): string {
if (rate == null) return '\u2014';
return `${(rate * 100).toFixed(1)}%`;
}
function AgentOverviewContent({ agent }: { agent: any }) {
type NormStatus = 'live' | 'stale' | 'dead';
function normalizeStatus(status: string): NormStatus {
return status.toLowerCase() as NormStatus;
}
function statusColor(s: NormStatus): 'success' | 'warning' | 'error' {
if (s === 'live') return 'success';
if (s === 'stale') return 'warning';
return 'error';
}
// ── Data grouping ────────────────────────────────────────────────────────────
interface AppGroup {
appId: string;
instances: AgentInstance[];
liveCount: number;
staleCount: number;
deadCount: number;
totalTps: number;
totalActiveRoutes: number;
totalRoutes: number;
}
function groupByApp(agentList: AgentInstance[]): AppGroup[] {
const map = new Map<string, AgentInstance[]>();
for (const a of agentList) {
const app = a.application;
const list = map.get(app) ?? [];
list.push(a);
map.set(app, list);
}
return Array.from(map.entries()).map(([appId, instances]) => ({
appId,
instances,
liveCount: instances.filter((i) => normalizeStatus(i.status) === 'live').length,
staleCount: instances.filter((i) => normalizeStatus(i.status) === 'stale').length,
deadCount: instances.filter((i) => normalizeStatus(i.status) === 'dead').length,
totalTps: instances.reduce((s, i) => s + (i.tps ?? 0), 0),
totalActiveRoutes: instances.reduce((s, i) => s + (i.activeRoutes ?? 0), 0),
totalRoutes: instances.reduce((s, i) => s + (i.totalRoutes ?? 0), 0),
}));
}
function appHealth(group: AppGroup): 'success' | 'warning' | 'error' {
if (group.deadCount > 0) return 'error';
if (group.staleCount > 0) return 'warning';
return 'success';
}
// ── Detail sub-components ────────────────────────────────────────────────────
function AgentOverviewContent({ agent }: { agent: AgentInstance }) {
const { data: memMetrics } = useAgentMetrics(
agent.id,
['jvm.memory.heap.used', 'jvm.memory.heap.max'],
@@ -43,93 +104,81 @@ function AgentOverviewContent({ agent }: { agent: any }) {
const heapUsed = memMetrics?.metrics?.['jvm.memory.heap.used']?.[0]?.value;
const heapMax = memMetrics?.metrics?.['jvm.memory.heap.max']?.[0]?.value;
const heapPercent = heapUsed != null && heapMax != null && heapMax > 0
? Math.round((heapUsed / heapMax) * 100)
: undefined;
const heapPercent =
heapUsed != null && heapMax != null && heapMax > 0
? Math.round((heapUsed / heapMax) * 100)
: undefined;
const cpuPercent = cpuValue != null ? Math.round(cpuValue * 100) : undefined;
const statusVariant: 'live' | 'stale' | 'dead' =
agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead';
const statusColor: 'success' | 'warning' | 'error' =
agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error';
const ns = normalizeStatus(agent.status);
return (
<div className={styles.overviewContent}>
<div className={styles.overviewRow}>
<StatusDot variant={statusVariant} />
<Badge label={agent.status} color={statusColor} />
<div className={styles.detailContent}>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Status</span>
<Badge label={agent.status} color={statusColor(ns)} variant="filled" />
</div>
<dl className={styles.detailList}>
<div className={styles.detailRow}>
<dt>Application</dt>
<dd><MonoText>{agent.application ?? '—'}</MonoText></dd>
</div>
<div className={styles.detailRow}>
<dt>Version</dt>
<dd><MonoText>{agent.version ?? '—'}</MonoText></dd>
</div>
<div className={styles.detailRow}>
<dt>Uptime</dt>
<dd>{formatUptime(agent.uptimeSeconds)}</dd>
</div>
<div className={styles.detailRow}>
<dt>Last Heartbeat</dt>
<dd>{formatRelativeTime(agent.lastHeartbeat)}</dd>
</div>
<div className={styles.detailRow}>
<dt>TPS</dt>
<dd>{agent.tps != null ? (agent.tps as number).toFixed(2) : '—'}</dd>
</div>
<div className={styles.detailRow}>
<dt>Error Rate</dt>
<dd>{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}</dd>
</div>
<div className={styles.detailRow}>
<dt>Routes</dt>
<dd>{agent.activeRoutes ?? '—'} active / {agent.totalRoutes ?? '—'} total</dd>
</div>
</dl>
<div className={styles.metricsSection}>
<div className={styles.metricLabel}>
Heap Memory{heapUsed != null && heapMax != null
? `${Math.round(heapUsed / 1024 / 1024)}MB / ${Math.round(heapMax / 1024 / 1024)}MB`
: ''}
</div>
<ProgressBar
value={heapPercent}
variant={heapPercent == null ? 'primary' : heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
indeterminate={heapPercent == null}
size="sm"
/>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Application</span>
<MonoText size="xs">{agent.application}</MonoText>
</div>
<div className={styles.metricsSection}>
<div className={styles.metricLabel}>
CPU Usage{cpuPercent != null ? `${cpuPercent}%` : ''}
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Uptime</span>
<MonoText size="xs">{formatUptime(agent.uptimeSeconds)}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Last Seen</span>
<MonoText size="xs">{timeAgo(agent.lastHeartbeat)}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Throughput</span>
<MonoText size="xs">{agent.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Errors</span>
<MonoText size="xs" className={agent.errorRate ? styles.instanceError : undefined}>
{formatErrorRate(agent.errorRate)}
</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Routes</span>
<span>{agent.activeRoutes ?? 0}/{agent.totalRoutes ?? 0} active</span>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Heap Memory</span>
<div className={styles.detailProgress}>
<ProgressBar
value={heapPercent}
variant={heapPercent == null ? 'primary' : heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
indeterminate={heapPercent == null}
size="sm"
/>
<MonoText size="xs">{heapPercent != null ? `${heapPercent}%` : '\u2014'}</MonoText>
</div>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>CPU</span>
<div className={styles.detailProgress}>
<ProgressBar
value={cpuPercent}
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
indeterminate={cpuPercent == null}
size="sm"
/>
<MonoText size="xs">{cpuPercent != null ? `${cpuPercent}%` : '\u2014'}</MonoText>
</div>
<ProgressBar
value={cpuPercent}
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
indeterminate={cpuPercent == null}
size="sm"
/>
</div>
</div>
);
}
function AgentPerformanceContent({ agent }: { agent: any }) {
function AgentPerformanceContent({ agent }: { agent: AgentInstance }) {
const { data: tpsMetrics } = useAgentMetrics(agent.id, ['cameleer.tps'], 60);
const { data: errMetrics } = useAgentMetrics(agent.id, ['cameleer.error.rate'], 60);
const tpsSeries = useMemo(() => {
const raw = tpsMetrics?.metrics?.['cameleer.tps'] ?? [];
return [{
label: 'TPS',
data: raw.map((p) => ({ x: new Date(p.time), y: p.value })),
}];
return [{ label: 'TPS', data: raw.map((p) => ({ x: new Date(p.time), y: p.value })) }];
}, [tpsMetrics]);
const errSeries = useMemo(() => {
@@ -137,24 +186,24 @@ function AgentPerformanceContent({ agent }: { agent: any }) {
return [{
label: 'Error Rate',
data: raw.map((p) => ({ x: new Date(p.time), y: p.value * 100 })),
color: 'var(--error)',
}];
}, [errMetrics]);
return (
<div className={styles.performanceContent}>
<div className={styles.chartSection}>
<div className={styles.chartLabel}>Throughput (TPS)</div>
<div className={styles.detailContent}>
<div className={styles.chartPanel}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
{tpsSeries[0].data.length > 0 ? (
<LineChart series={tpsSeries} yLabel="req/s" height={160} />
<LineChart series={tpsSeries} height={160} yLabel="msg/s" />
) : (
<div className={styles.emptyChart}>No data available</div>
)}
</div>
<div className={styles.chartSection}>
<div className={styles.chartLabel}>Error Rate (%)</div>
<div className={styles.chartPanel}>
<div className={styles.chartTitle}>Error Rate (%)</div>
{errSeries[0].data.length > 0 ? (
<LineChart series={errSeries} yLabel="%" height={160} />
<LineChart series={errSeries} height={160} yLabel="%" />
) : (
<div className={styles.emptyChart}>No data available</div>
)}
@@ -163,197 +212,308 @@ function AgentPerformanceContent({ agent }: { agent: any }) {
);
}
// ── AgentHealth page ─────────────────────────────────────────────────────────
export default function AgentHealth() {
const { appId } = useParams();
const navigate = useNavigate();
const { data: agents } = useAgents(undefined, appId);
const { data: catalog } = useRouteCatalog();
const { data: events } = useAgentEvents(appId);
const [selectedAgent, setSelectedAgent] = useState<any>(null);
const [selectedInstance, setSelectedInstance] = useState<AgentInstance | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const agentsByApp = useMemo(() => {
const map: Record<string, any[]> = {};
(agents || []).forEach((a: any) => {
const g = a.application;
if (!map[g]) map[g] = [];
map[g].push(a);
});
return map;
}, [agents]);
const agentList = agents ?? [];
const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length;
const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').length;
const deadCount = (agents || []).filter((a: any) => a.status === 'DEAD').length;
const uniqueApps = new Set((agents || []).map((a: any) => a.application)).size;
const activeRoutes = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.activeRoutes || 0), 0);
const totalTps = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.tps || 0), 0);
const groups = useMemo(() => groupByApp(agentList), [agentList]);
const feedEvents = useMemo(() =>
(events || []).map((e: any) => ({
id: String(e.id),
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
: e.eventType === 'WENT_STALE' ? 'warning' as const
: e.eventType === 'RECOVERED' ? 'success' as const
: 'running' as const,
message: `${e.agentId}: ${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
// Aggregate stats
const totalInstances = agentList.length;
const liveCount = agentList.filter((a) => normalizeStatus(a.status) === 'live').length;
const staleCount = agentList.filter((a) => normalizeStatus(a.status) === 'stale').length;
const deadCount = agentList.filter((a) => normalizeStatus(a.status) === 'dead').length;
const totalTps = agentList.reduce((s, a) => s + (a.tps ?? 0), 0);
const totalActiveRoutes = agentList.reduce((s, a) => s + (a.activeRoutes ?? 0), 0);
const totalRoutes = agentList.reduce((s, a) => s + (a.totalRoutes ?? 0), 0);
// Map events to FeedEvent
const feedEvents: FeedEvent[] = useMemo(
() =>
(events ?? []).map((e: { id: number; agentId: string; eventType: string; detail: string; timestamp: string }) => ({
id: String(e.id),
severity:
e.eventType === 'WENT_DEAD'
? ('error' as const)
: e.eventType === 'WENT_STALE'
? ('warning' as const)
: e.eventType === 'RECOVERED'
? ('success' as const)
: ('running' as const),
message: `${e.agentId}: ${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
[events],
);
const apps = appId ? { [appId]: agentsByApp[appId] || [] } : agentsByApp;
// Column definitions for the instance DataTable
const instanceColumns: Column<AgentInstance>[] = useMemo(
() => [
{
key: 'status',
header: '',
width: '12px',
render: (_val, row) => <StatusDot variant={normalizeStatus(row.status)} />,
},
{
key: 'name',
header: 'Instance',
render: (_val, row) => (
<MonoText size="sm" className={styles.instanceName}>{row.name ?? row.id}</MonoText>
),
},
{
key: 'state',
header: 'State',
render: (_val, row) => {
const ns = normalizeStatus(row.status);
return <Badge label={row.status} color={statusColor(ns)} variant="filled" />;
},
},
{
key: 'uptime',
header: 'Uptime',
render: (_val, row) => (
<MonoText size="xs" className={styles.instanceMeta}>{formatUptime(row.uptimeSeconds)}</MonoText>
),
},
{
key: 'tps',
header: 'TPS',
render: (_val, row) => (
<MonoText size="xs" className={styles.instanceMeta}>
{row.tps != null ? `${row.tps.toFixed(1)}/s` : '\u2014'}
</MonoText>
),
},
{
key: 'errorRate',
header: 'Errors',
render: (_val, row) => (
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
{formatErrorRate(row.errorRate)}
</MonoText>
),
},
{
key: 'lastHeartbeat',
header: 'Heartbeat',
render: (_val, row) => {
const ns = normalizeStatus(row.status);
return (
<MonoText
size="xs"
className={
ns === 'dead'
? styles.instanceHeartbeatDead
: ns === 'stale'
? styles.instanceHeartbeatStale
: styles.instanceMeta
}
>
{timeAgo(row.lastHeartbeat)}
</MonoText>
);
},
},
],
[],
);
function handleInstanceClick(inst: AgentInstance) {
setSelectedInstance(inst);
setPanelOpen(true);
}
// Detail panel tabs
const detailTabs = selectedInstance
? [
{
label: 'Overview',
value: 'overview',
content: <AgentOverviewContent agent={selectedInstance} />,
},
{
label: 'Performance',
value: 'performance',
content: <AgentPerformanceContent agent={selectedInstance} />,
},
]
: [];
const isFullWidth = !!appId;
return (
<div>
<div className={styles.content}>
{/* Stat strip */}
<div className={styles.statStrip}>
<StatCard
label="Total Agents"
value={(agents || []).length}
value={String(totalInstances)}
accent={deadCount > 0 ? 'warning' : 'amber'}
detail={
<span className={styles.statusBreakdown}>
<span className={styles.statusLive}>{liveCount} live</span>
<span className={styles.statusStale}>{staleCount} stale</span>
<span className={styles.statusDead}>{deadCount} dead</span>
<span className={styles.breakdown}>
<span className={styles.bpLive}><StatusDot variant="live" /> {liveCount} live</span>
<span className={styles.bpStale}><StatusDot variant="stale" /> {staleCount} stale</span>
<span className={styles.bpDead}><StatusDot variant="dead" /> {deadCount} dead</span>
</span>
}
/>
<StatCard label="Applications" value={uniqueApps} />
<StatCard label="Active Routes" value={activeRoutes} />
<StatCard label="Total TPS" value={totalTps.toFixed(1)} detail="msg/s" />
<StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} detail={deadCount > 0 ? 'requires attention' : undefined} />
</div>
<div className={styles.scopeTrail}>
<span className={styles.scopeLabel}>{liveCount}/{(agents || []).length} live</span>
</div>
<div className={styles.groupGrid}>
{Object.entries(apps).map(([group, groupAgents]) => {
const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD');
const groupTps = (groupAgents || []).reduce((s: number, a: any) => s + (a.tps || 0), 0);
const groupActiveRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.activeRoutes || 0), 0);
const groupTotalRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.totalRoutes || 0), 0);
const liveInGroup = (groupAgents || []).filter((a: any) => a.status === 'LIVE').length;
return (
<GroupCard
key={group}
title={group}
headerRight={
<Badge
label={`${liveInGroup}/${groupAgents?.length ?? 0} LIVE`}
color={
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
: 'success'
}
variant="filled"
/>
}
meta={
<div className={styles.groupMeta}>
<span><strong>{groupTps.toFixed(1)}</strong> msg/s</span>
<span><strong>{groupActiveRoutes}</strong>/{groupTotalRoutes} routes</span>
</div>
}
accent={
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
: 'success'
<StatCard
label="Applications"
value={String(groups.length)}
accent="running"
detail={
<span className={styles.breakdown}>
<span className={styles.bpLive}>
<StatusDot variant="live" /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy
</span>
<span className={styles.bpStale}>
<StatusDot variant="stale" /> {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded
</span>
<span className={styles.bpDead}>
<StatusDot variant="dead" /> {groups.filter((g) => g.deadCount > 0).length} critical
</span>
</span>
}
/>
<StatCard
label="Active Routes"
value={
<span
className={
styles[
totalActiveRoutes === 0
? 'routesError'
: totalActiveRoutes < totalRoutes
? 'routesWarning'
: 'routesSuccess'
]
}
>
{deadInGroup.length > 0 && (
<Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
)}
<table className={styles.instanceTable}>
<thead>
<tr>
<th className={styles.thStatus} />
<th>Instance</th>
<th>State</th>
<th>Uptime</th>
<th>TPS</th>
<th>Errors</th>
<th>Heartbeat</th>
</tr>
</thead>
<tbody>
{(groupAgents || []).map((agent: any) => (
<tr
key={agent.id}
className={[
styles.instanceRow,
selectedAgent?.id === agent.id ? styles.instanceRowActive : '',
].filter(Boolean).join(' ')}
onClick={() => {
setSelectedAgent(agent);
navigate(`/agents/${group}/${agent.id}`);
}}
>
<td className={styles.tdStatus}>
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
</td>
<td>
<MonoText size="sm" className={styles.instanceName}>{agent.name ?? agent.id}</MonoText>
</td>
<td>
<Badge
label={agent.status}
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
variant="filled"
/>
</td>
<td>
<span className={styles.instanceMeta}>{formatUptime(agent.uptimeSeconds)}</span>
</td>
<td>
<span className={styles.instanceMeta}>{agent.tps != null ? `${(agent.tps as number).toFixed(1)}/s` : '—'}</span>
</td>
<td>
<span className={agent.errorRate != null ? styles.instanceError : styles.instanceMeta}>
{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}
</span>
</td>
<td>
<span className={
agent.status === 'DEAD' ? styles.instanceHeartbeatDead
: agent.status === 'STALE' ? styles.instanceHeartbeatStale
: styles.instanceMeta
}>
{formatRelativeTime(agent.lastHeartbeat)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</GroupCard>
);
})}
{totalActiveRoutes}/{totalRoutes}
</span>
}
accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'}
detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'}
/>
<StatCard
label="Total TPS"
value={totalTps.toFixed(1)}
accent="amber"
detail="msg/s"
/>
<StatCard
label="Dead"
value={String(deadCount)}
accent={deadCount > 0 ? 'error' : 'success'}
detail={deadCount > 0 ? 'requires attention' : 'all healthy'}
/>
</div>
{/* Scope trail + badges */}
<div className={styles.scopeTrail}>
{appId && (
<>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<span className={styles.scopeSep}>&#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 && (
<div className={styles.eventCard}>
<div className={styles.eventCardHeader}>
<span>Timeline</span>
<Badge label={`${feedEvents.length} events`} variant="outlined" />
<span className={styles.sectionTitle}>Timeline</span>
<span className={styles.sectionMeta}>{feedEvents.length} events</span>
</div>
<EventFeed events={feedEvents} maxItems={100} />
</div>
)}
{selectedAgent && (
{/* Detail panel */}
{selectedInstance && (
<DetailPanel
key={selectedAgent.id}
open={true}
title={selectedAgent.name ?? selectedAgent.id}
onClose={() => setSelectedAgent(null)}
className={styles.detailPanelOverride}
>
<AgentOverviewContent agent={selectedAgent} />
<div className={styles.panelDivider} />
<AgentPerformanceContent agent={selectedAgent} />
</DetailPanel>
open={panelOpen}
onClose={() => {
setPanelOpen(false);
setSelectedInstance(null);
}}
title={selectedInstance.name ?? selectedInstance.id}
tabs={detailTabs}
/>
)}
</div>
);

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 {
display: grid;
grid-template-columns: repeat(5, 1fr);
@@ -5,18 +14,67 @@
margin-bottom: 16px;
}
.agentHeader {
/* Scope trail — matches /agents */
.scopeTrail {
display: flex;
align-items: center;
gap: 12px;
margin: 16px 0;
gap: 6px;
margin-bottom: 12px;
font-size: 12px;
}
.agentHeader h2 {
font-size: 18px;
.scopeLink {
color: var(--amber);
text-decoration: none;
font-weight: 500;
}
.scopeLink:hover {
text-decoration: underline;
}
.scopeSep {
color: var(--text-muted);
font-size: 10px;
}
.scopeCurrent {
color: var(--text-primary);
font-weight: 600;
font-family: var(--font-mono);
}
/* Process info card */
.processCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
margin-bottom: 20px;
}
.processGrid {
display: grid;
grid-template-columns: auto 1fr auto 1fr;
gap: 6px 16px;
font-size: 12px;
font-family: var(--font-body);
margin-top: 12px;
}
.processLabel {
color: var(--text-muted);
font-weight: 500;
}
.capTags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
/* Route badges */
.routeBadges {
display: flex;
gap: 6px;
@@ -24,9 +82,10 @@
margin-bottom: 20px;
}
/* Charts 3x2 grid */
.chartsGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
margin-bottom: 20px;
}
@@ -53,14 +112,46 @@
color: var(--text-primary);
}
.sectionTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
.chartMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.eventCard {
/* Log + Timeline side by side */
.bottomRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
/* Log viewer */
.logCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.logHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
/* Empty state (shared) */
.logEmpty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
/* Timeline card */
.timelineCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
@@ -69,107 +160,12 @@
display: flex;
flex-direction: column;
max-height: 420px;
margin-bottom: 20px;
}
.eventCardHeader {
.timelineHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.infoCard {
margin-bottom: 20px;
}
.infoGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
font-size: 13px;
}
.infoLabel {
font-weight: 700;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
display: block;
margin-bottom: 2px;
}
.capTags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.scopeTrail {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 16px;
font-size: 13px;
flex-wrap: wrap;
}
.scopeLink {
color: var(--text-accent, var(--text-primary));
text-decoration: none;
font-weight: 500;
}
.scopeLink:hover {
text-decoration: underline;
}
.scopeSep {
color: var(--text-muted);
font-size: 10px;
}
.scopeCurrent {
color: var(--text-primary);
font-weight: 600;
}
.paneTitle {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 12px;
}
.chartMeta {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
font-family: var(--font-mono);
}
.bottomSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 20px;
}
.eventCount {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
font-family: var(--font-mono);
}
.emptyEvents {
padding: 20px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
}

View File

@@ -1,18 +1,26 @@
import { useMemo } from 'react';
import { useParams } from 'react-router';
import { useMemo, useState } from 'react';
import { useParams, Link } from 'react-router';
import {
StatCard, StatusDot, Badge, Card,
LineChart, AreaChart, BarChart, EventFeed, Breadcrumb, Spinner, EmptyState,
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
LogViewer, Tabs, useGlobalFilters,
} from '@cameleer/design-system';
import type { FeedEvent, LogEntry } from '@cameleer/design-system';
import styles from './AgentInstance.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useStatsTimeseries } from '../../api/queries/executions';
import { useAgentMetrics } from '../../api/queries/agent-metrics';
import { useGlobalFilters } from '@cameleer/design-system';
const LOG_TABS = [
{ label: 'All', value: 'all' },
{ label: 'Warnings', value: 'warn' },
{ label: 'Errors', value: 'error' },
];
export default function AgentInstance() {
const { appId, instanceId } = useParams();
const { timeRange } = useGlobalFilters();
const [logFilter, setLogFilter] = useState('all');
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
@@ -20,8 +28,8 @@ export default function AgentInstance() {
const { data: events } = useAgentEvents(appId, instanceId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
const agent = useMemo(() =>
(agents || []).find((a: any) => a.id === instanceId) as any,
const agent = useMemo(
() => (agents || []).find((a: any) => a.id === instanceId) as any,
[agents, instanceId],
);
@@ -43,26 +51,34 @@ export default function AgentInstance() {
60,
);
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => ({
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
throughput: b.totalCount,
latency: b.avgDurationMs,
errors: b.failedCount,
})),
const chartData = useMemo(
() =>
(timeseries?.buckets || []).map((b: any) => ({
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
throughput: b.totalCount,
latency: b.avgDurationMs,
errors: b.failedCount,
})),
[timeseries],
);
const feedEvents = useMemo(() =>
(events || []).filter((e: any) => !instanceId || e.agentId === instanceId).map((e: any) => ({
id: String(e.id),
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
: e.eventType === 'WENT_STALE' ? 'warning' as const
: e.eventType === 'RECOVERED' ? 'success' as const
: 'running' as const,
message: `${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
const feedEvents = useMemo<FeedEvent[]>(
() =>
(events || [])
.filter((e: any) => !instanceId || e.agentId === instanceId)
.map((e: any) => ({
id: String(e.id),
severity:
e.eventType === 'WENT_DEAD'
? ('error' as const)
: e.eventType === 'WENT_STALE'
? ('warning' as const)
: e.eventType === 'RECOVERED'
? ('success' as const)
: ('running' as const),
message: `${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
[events, instanceId],
);
@@ -88,194 +104,305 @@ export default function AgentInstance() {
const gcSeries = useMemo(() => {
const pts = jvmMetrics?.metrics?.['jvm.gc.time'];
if (!pts?.length) return null;
return [{ label: 'GC ms', data: pts.map((p: any, i: number) => ({ x: String(i), y: p.value })) }];
return [{ label: 'GC ms', data: pts.map((p: any) => ({ x: String(p.time ?? ''), y: p.value })) }];
}, [jvmMetrics]);
const throughputSeries = useMemo(() =>
chartData.length ? [{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }] : null,
const throughputSeries = useMemo(
() =>
chartData.length
? [{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]
: null,
[chartData],
);
const errorSeries = useMemo(() =>
chartData.length ? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }] : null,
const errorSeries = useMemo(
() =>
chartData.length
? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }]
: null,
[chartData],
);
// Placeholder log entries (backend does not stream logs yet)
const logEntries = useMemo<LogEntry[]>(() => [], []);
const filteredLogs =
logFilter === 'all' ? logEntries : logEntries.filter((l) => l.level === logFilter);
if (isLoading) return <Spinner size="lg" />;
return (
<div>
<Breadcrumb items={[
{ label: 'Agents', href: '/agents' },
{ label: appId || '', href: `/agents/${appId}` },
{ label: agent?.name || instanceId || '' },
]} />
const statusVariant =
agent?.status === 'LIVE' ? 'live' : agent?.status === 'STALE' ? 'stale' : 'dead';
const statusColor: 'success' | 'warning' | 'error' =
agent?.status === 'LIVE' ? 'success' : agent?.status === 'STALE' ? 'warning' : 'error';
const cpuDisplay = cpuPct != null ? (cpuPct * 100).toFixed(0) : null;
const heapUsedMB = heapUsed != null ? (heapUsed / (1024 * 1024)).toFixed(0) : null;
const heapMaxMB = heapMax != null ? (heapMax / (1024 * 1024)).toFixed(0) : null;
return (
<div className={styles.content}>
{/* Stat strip — 5 columns */}
<div className={styles.statStrip}>
<StatCard
label="CPU"
value={cpuDisplay != null ? `${cpuDisplay}%` : '\u2014'}
accent={
cpuDisplay != null
? Number(cpuDisplay) > 85
? 'error'
: Number(cpuDisplay) > 70
? 'warning'
: 'success'
: undefined
}
/>
<StatCard
label="Memory"
value={memPct != null ? `${memPct.toFixed(0)}%` : '\u2014'}
accent={
memPct != null
? memPct > 85
? 'error'
: memPct > 70
? 'warning'
: 'success'
: undefined
}
detail={
heapUsedMB != null && heapMaxMB != null
? `${heapUsedMB} MB / ${heapMaxMB} MB`
: undefined
}
/>
<StatCard
label="Throughput"
value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'}
accent="amber"
detail="msg/s"
/>
<StatCard
label="Errors"
value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '\u2014'}
accent={agent?.errorRate > 0 ? 'error' : 'success'}
/>
<StatCard
label="Uptime"
value={formatUptime(agent?.uptimeSeconds)}
accent="running"
detail={
agent?.registeredAt
? `since ${new Date(agent.registeredAt).toLocaleDateString()}`
: undefined
}
/>
</div>
{/* Scope trail + badges */}
{agent && (
<>
<div className={styles.agentHeader}>
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
<h2>{agent.name}</h2>
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
{agent.version && <Badge label={agent.version} variant="outlined" />}
</div>
<div className={styles.statStrip}>
<StatCard label="CPU" value={cpuPct != null ? `${(cpuPct * 100).toFixed(0)}%` : '—'} />
<StatCard
label="Memory"
value={memPct != null ? `${memPct.toFixed(0)}%` : '—'}
detail={heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : undefined}
/>
<StatCard label="Throughput" value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '—'} />
<StatCard label="Errors" value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '—'} accent={agent?.errorRate > 0 ? 'error' : undefined} />
<StatCard
label="Uptime"
value={formatUptime(agent?.uptimeSeconds)}
detail={agent?.registeredAt ? `since ${new Date(agent.registeredAt).toLocaleDateString()}` : undefined}
/>
</div>
<div className={styles.scopeTrail}>
<a href="/agents" className={styles.scopeLink}>All Agents</a>
<Link to="/agents" className={styles.scopeLink}>
All Agents
</Link>
<span className={styles.scopeSep}>&#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.scopeCurrent}>{agent.name}</span>
<Badge
label={agent.status.toUpperCase()}
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
/>
{agent.version && <Badge label={agent.version} variant="outlined" />}
<StatusDot variant={statusVariant} />
<Badge label={agent.status} color={statusColor} />
{agent.version && <Badge label={agent.version} variant="outlined" color="auto" />}
<Badge
label={`${agent.activeRoutes ?? (agent.routeIds?.length ?? 0)}/${agent.totalRoutes ?? (agent.routeIds?.length ?? 0)} routes`}
color={(agent.activeRoutes ?? 0) < (agent.totalRoutes ?? 0) ? 'warning' : 'success'}
color={
(agent.activeRoutes ?? 0) < (agent.totalRoutes ?? 0) ? 'warning' : 'success'
}
/>
</div>
<Card className={styles.infoCard}>
<div className={styles.paneTitle}>Process Information</div>
<div className={styles.infoGrid}>
{agent?.capabilities?.jvmVersion && (
<div>
<span className={styles.infoLabel}>JVM</span>
<span>{agent.capabilities.jvmVersion}</span>
</div>
{/* Process info card */}
<div className={styles.processCard}>
<SectionHeader>Process Information</SectionHeader>
<div className={styles.processGrid}>
{agent.capabilities?.jvmVersion && (
<>
<span className={styles.processLabel}>JVM</span>
<MonoText size="xs">{agent.capabilities.jvmVersion}</MonoText>
</>
)}
{agent?.capabilities?.camelVersion && (
<div>
<span className={styles.infoLabel}>Camel</span>
<span>{agent.capabilities.camelVersion}</span>
</div>
{agent.capabilities?.camelVersion && (
<>
<span className={styles.processLabel}>Camel</span>
<MonoText size="xs">{agent.capabilities.camelVersion}</MonoText>
</>
)}
{agent?.capabilities?.springBootVersion && (
<div>
<span className={styles.infoLabel}>Spring Boot</span>
<span>{agent.capabilities.springBootVersion}</span>
</div>
{agent.capabilities?.springBootVersion && (
<>
<span className={styles.processLabel}>Spring Boot</span>
<MonoText size="xs">{agent.capabilities.springBootVersion}</MonoText>
</>
)}
<span className={styles.processLabel}>Started</span>
<MonoText size="xs">
{agent.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '\u2014'}
</MonoText>
{agent.capabilities && (
<>
<span className={styles.processLabel}>Capabilities</span>
<span className={styles.capTags}>
{Object.entries(agent.capabilities)
.filter(([, v]) => typeof v === 'boolean' && v)
.map(([k]) => (
<Badge key={k} label={k} variant="outlined" />
))}
</span>
</>
)}
<div>
<span className={styles.infoLabel}>Started</span>
<span>{agent?.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '—'}</span>
</div>
<div>
<span className={styles.infoLabel}>Capabilities</span>
<span className={styles.capTags}>
{Object.entries(agent?.capabilities || {})
.filter(([, v]) => typeof v === 'boolean' && v)
.map(([k]) => (
<Badge key={k} label={k} variant="outlined" />
))}
</span>
</div>
</div>
</Card>
<div className={styles.sectionTitle}>Routes</div>
<div className={styles.routeBadges}>
{(agent.routeIds || []).map((r: string) => (
<Badge key={r} label={r} color="auto" />
))}
</div>
{/* Routes */}
{(agent.routeIds?.length ?? 0) > 0 && (
<>
<SectionHeader>Routes</SectionHeader>
<div className={styles.routeBadges}>
{(agent.routeIds || []).map((r: string) => (
<Badge key={r} label={r} color="auto" />
))}
</div>
</>
)}
</>
)}
{/* Charts grid — 3x2 */}
<div className={styles.chartsGrid}>
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div className={styles.chartTitle}>CPU Usage</div>
<div className={styles.chartMeta}>{cpuPct != null ? `${(cpuPct * 100).toFixed(0)}% current` : ''}</div>
<span className={styles.chartTitle}>CPU Usage</span>
<span className={styles.chartMeta}>
{cpuDisplay != null ? `${cpuDisplay}% current` : ''}
</span>
</div>
{cpuSeries
? <AreaChart series={cpuSeries} yLabel="%" height={200} />
: <EmptyState title="No data" description="No CPU metrics available" />}
{cpuSeries ? (
<AreaChart
series={cpuSeries}
height={160}
yLabel="%"
threshold={{ value: 85, label: 'Alert' }}
/>
) : (
<EmptyState title="No data" description="No CPU metrics available" />
)}
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div className={styles.chartTitle}>Memory (Heap)</div>
<div className={styles.chartMeta}>{heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : ''}</div>
<span className={styles.chartTitle}>Memory (Heap)</span>
<span className={styles.chartMeta}>
{heapUsedMB != null && heapMaxMB != null
? `${heapUsedMB} MB / ${heapMaxMB} MB`
: ''}
</span>
</div>
{heapSeries
? <AreaChart series={heapSeries} yLabel="MB" height={200} />
: <EmptyState title="No data" description="No heap metrics available" />}
{heapSeries ? (
<AreaChart series={heapSeries} height={160} yLabel="MB" />
) : (
<EmptyState title="No data" description="No heap metrics available" />
)}
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div className={styles.chartTitle}>Throughput</div>
<div className={styles.chartMeta}>{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}</div>
<span className={styles.chartTitle}>Throughput</span>
<span className={styles.chartMeta}>
{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}
</span>
</div>
{throughputSeries
? <AreaChart series={throughputSeries} yLabel="msg/s" height={200} />
: <EmptyState title="No data" description="No throughput data in range" />}
{throughputSeries ? (
<LineChart series={throughputSeries} height={160} yLabel="msg/s" />
) : (
<EmptyState title="No data" description="No throughput data in range" />
)}
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div className={styles.chartTitle}>Error Rate</div>
<div className={styles.chartMeta}>{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}</div>
<span className={styles.chartTitle}>Error Rate</span>
<span className={styles.chartMeta}>
{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}
</span>
</div>
{errorSeries
? <LineChart series={errorSeries} yLabel="%" height={200} />
: <EmptyState title="No data" description="No error data in range" />}
{errorSeries ? (
<LineChart series={errorSeries} height={160} yLabel="err/h" />
) : (
<EmptyState title="No data" description="No error data in range" />
)}
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div className={styles.chartTitle}>Thread Count</div>
{threadSeries && <div className={styles.chartMeta}>{threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active</div>}
<span className={styles.chartTitle}>Thread Count</span>
<span className={styles.chartMeta}>
{threadSeries
? `${threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active`
: ''}
</span>
</div>
{threadSeries
? <LineChart series={threadSeries} yLabel="threads" height={200} />
: <EmptyState title="No data" description="No thread metrics available" />}
{threadSeries ? (
<LineChart series={threadSeries} height={160} yLabel="threads" />
) : (
<EmptyState title="No data" description="No thread metrics available" />
)}
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div className={styles.chartTitle}>GC Pauses</div>
<span className={styles.chartTitle}>GC Pauses</span>
<span className={styles.chartMeta} />
</div>
{gcSeries
? <BarChart series={gcSeries} yLabel="ms" height={200} />
: <EmptyState title="No data" description="No GC metrics available" />}
{gcSeries ? (
<BarChart series={gcSeries} height={160} yLabel="ms" />
) : (
<EmptyState title="No data" description="No GC metrics available" />
)}
</div>
</div>
<div className={styles.bottomSection}>
<EmptyState title="Application Log" description="Application log streaming is not yet available" />
<div className={styles.eventCard}>
<div className={styles.eventCardHeader}>
<span>Timeline</span>
<span className={styles.eventCount}>{feedEvents.length} events</span>
{/* Log + Timeline side by side */}
<div className={styles.bottomRow}>
<div className={styles.logCard}>
<div className={styles.logHeader}>
<SectionHeader>Application Log</SectionHeader>
<Tabs tabs={LOG_TABS} active={logFilter} onChange={setLogFilter} />
</div>
{feedEvents.length > 0
? <EventFeed events={feedEvents} maxItems={50} />
: <div className={styles.emptyEvents}>No events in the selected time range.</div>}
{filteredLogs.length > 0 ? (
<LogViewer entries={filteredLogs} maxHeight={360} />
) : (
<div className={styles.logEmpty}>
Application log streaming is not yet available.
</div>
)}
</div>
<div className={styles.timelineCard}>
<div className={styles.timelineHeader}>
<span className={styles.chartTitle}>Timeline</span>
<span className={styles.chartMeta}>{feedEvents.length} events</span>
</div>
{feedEvents.length > 0 ? (
<EventFeed events={feedEvents} maxItems={50} />
) : (
<div className={styles.logEmpty}>No events in the selected time range.</div>
)}
</div>
</div>
</div>
);
}
function formatUptime(seconds?: number): string {
if (!seconds) return '';
if (!seconds) return '\u2014';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);

View File

@@ -1,10 +1,18 @@
.healthStrip {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
/* Scrollable content area */
.content {
flex: 1;
overflow-y: auto;
padding: 20px 24px 40px;
min-width: 0;
background: var(--bg-body);
}
/* Filter bar spacing */
.filterBar {
margin-bottom: 16px;
}
/* Table section */
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
@@ -39,6 +47,93 @@
font-family: var(--font-mono);
}
/* Status cell */
.statusCell {
display: flex;
align-items: center;
gap: 5px;
}
/* Route cells */
.routeName {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
}
/* Application column */
.appName {
font-size: 12px;
color: var(--text-secondary);
}
/* Duration color classes */
.durFast {
color: var(--success);
}
.durNormal {
color: var(--text-secondary);
}
.durSlow {
color: var(--warning);
}
.durBreach {
color: var(--error);
}
/* Agent badge in table */
.agentBadge {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.agentDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #5db866;
box-shadow: 0 0 4px rgba(93, 184, 102, 0.4);
flex-shrink: 0;
}
/* Inline error preview below row */
.inlineError {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 12px;
background: var(--error-bg);
border-left: 3px solid var(--error-border);
}
.inlineErrorIcon {
color: var(--error);
font-size: 14px;
flex-shrink: 0;
margin-top: 1px;
}
.inlineErrorText {
font-size: 11px;
color: var(--error);
font-family: var(--font-mono);
line-height: 1.4;
}
.inlineErrorHint {
font-size: 10px;
color: var(--text-muted);
margin-top: 3px;
}
/* Detail panel sections */
.panelSection {
padding-bottom: 16px;
margin-bottom: 16px;
@@ -59,19 +154,21 @@
color: var(--text-muted);
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.panelSectionMeta {
font-size: 11px;
font-weight: 400;
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
color: var(--text-muted);
font-family: var(--font-mono);
color: var(--text-faint);
}
/* Overview grid */
.overviewGrid {
display: flex;
flex-direction: column;
@@ -95,45 +192,67 @@
padding-top: 2px;
}
/* Error block */
.errorBlock {
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.errorClass {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
color: var(--error);
margin-bottom: 4px;
}
.errorMessage {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
font-family: var(--font-mono);
word-break: break-word;
}
/* Inspect exchange icon in table */
.inspectLink {
background: transparent;
border: none;
color: var(--text-faint);
opacity: 0.75;
cursor: pointer;
font-size: 13px;
padding: 2px 4px;
border-radius: var(--radius-sm);
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
font-size: 14px;
color: var(--text-muted);
transition: color 0.15s, opacity 0.15s;
text-decoration: none;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.inspectLink:hover {
color: var(--accent, #c6820e);
background: var(--bg-hover);
}
.detailPanelOverride {
position: fixed;
top: 0;
right: 0;
height: 100vh;
z-index: 100;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
color: var(--text-primary);
opacity: 1;
}
/* Open full details link in panel */
.openDetailLink {
display: inline-block;
font-size: 13px;
font-weight: 600;
color: var(--accent, #c6820e);
cursor: pointer;
background: none;
background: transparent;
border: none;
color: var(--amber);
cursor: pointer;
font-size: 12px;
padding: 0;
text-decoration: none;
font-family: var(--font-body);
transition: color 0.1s;
}
.openDetailLink:hover {
color: var(--amber-deep);
text-decoration: underline;
text-underline-offset: 2px;
}

View File

@@ -1,186 +1,417 @@
import { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useState, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router'
import {
StatCard, StatusDot, Badge, MonoText,
DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
Alert, Collapsible, CodeBlock, ShortcutsBar,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail } from '../../api/queries/executions';
import { useDiagramLayout } from '../../api/queries/diagrams';
import { useGlobalFilters } from '@cameleer/design-system';
import type { ExecutionSummary } from '../../api/types';
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
import styles from './Dashboard.module.css';
DataTable,
DetailPanel,
ShortcutsBar,
ProcessorTimeline,
RouteFlow,
KpiStrip,
StatusDot,
MonoText,
Badge,
useGlobalFilters,
} from '@cameleer/design-system'
import type { Column, KpiItem, RouteNode } from '@cameleer/design-system'
import {
useSearchExecutions,
useExecutionStats,
useStatsTimeseries,
useExecutionDetail,
} from '../../api/queries/executions'
import { useDiagramLayout } from '../../api/queries/diagrams'
import type { ExecutionSummary } from '../../api/types'
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
import styles from './Dashboard.module.css'
interface Row extends ExecutionSummary { id: string }
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
// Row type extends ExecutionSummary with an `id` field for DataTable
interface Row extends ExecutionSummary {
id: string
}
export default function Dashboard() {
const { appId, routeId } = useParams();
const navigate = useNavigate();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
// ─── Helpers ─────────────────────────────────────────────────────────────────
const [selectedId, setSelectedId] = useState<string | null>(null);
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms}ms`
}
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
function formatTimestamp(iso: string): string {
const date = new Date(iso)
const y = date.getFullYear()
const mo = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
}
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: searchResult } = useSearchExecutions({
timeFrom, timeTo,
routeId: routeId || undefined,
application: appId || undefined,
offset: 0, limit: 50,
}, true);
const { data: detail } = useExecutionDetail(selectedId);
function statusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'COMPLETED': return 'success'
case 'FAILED': return 'error'
case 'RUNNING': return 'running'
default: return 'warning'
}
}
const rows: Row[] = useMemo(() =>
(searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[searchResult],
);
function statusLabel(status: string): string {
switch (status) {
case 'COMPLETED': return 'OK'
case 'FAILED': return 'ERR'
case 'RUNNING': return 'RUN'
default: return 'WARN'
}
}
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
function durationClass(ms: number, status: string): string {
if (status === 'FAILED') return styles.durBreach
if (ms < 100) return styles.durFast
if (ms < 200) return styles.durNormal
if (ms < 300) return styles.durSlow
return styles.durBreach
}
const totalCount = stats?.totalCount ?? 0;
const failedCount = stats?.failedCount ?? 0;
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount * 100) : 100;
const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 0;
function flattenProcessors(nodes: any[]): any[] {
const result: any[] = []
let offset = 0
function walk(node: any) {
result.push({
name: node.processorId || node.processorType,
type: node.processorType,
durationMs: node.durationMs ?? 0,
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
startMs: offset,
})
offset += node.durationMs ?? 0
if (node.children) node.children.forEach(walk)
}
nodes.forEach(walk)
return result
}
const sparkExchanges = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.totalCount as number), [timeseries]);
const sparkErrors = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.failedCount as number), [timeseries]);
const sparkLatency = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number), [timeseries]);
const sparkThroughput = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => {
const bucketSeconds = timeWindowSeconds / Math.max((timeseries?.buckets || []).length, 1);
return bucketSeconds > 0 ? (b.totalCount as number) / bucketSeconds : 0;
}), [timeseries, timeWindowSeconds]);
// ─── Table columns (base, without inspect action) ────────────────────────────
const prevTotal = stats?.prevTotalCount ?? 0;
const prevFailed = stats?.prevFailedCount ?? 0;
const exchangeTrend = prevTotal > 0 ? ((totalCount - prevTotal) / prevTotal * 100) : 0;
const prevSuccessRate = prevTotal > 0 ? ((prevTotal - prevFailed) / prevTotal * 100) : 100;
const successRateDelta = successRate - prevSuccessRate;
const errorDelta = failedCount - prevFailed;
const columns: Column<Row>[] = [
function buildBaseColumns(): Column<Row>[] {
return [
{
key: 'status', header: 'Status', width: '80px',
render: (v, row) => (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />
<MonoText size="xs">{v === 'COMPLETED' ? 'OK' : v === 'FAILED' ? 'ERR' : 'RUN'}</MonoText>
key: 'status',
header: 'Status',
width: '80px',
render: (_: unknown, row: Row) => (
<span className={styles.statusCell}>
<StatusDot variant={statusToVariant(row.status)} />
<MonoText size="xs">{statusLabel(row.status)}</MonoText>
</span>
),
},
{
key: '_inspect' as any, header: '', width: '36px',
render: (_v, row) => (
<a
href={`/exchanges/${row.executionId}`}
onClick={(e) => { e.stopPropagation(); e.preventDefault(); navigate(`/exchanges/${row.executionId}`); }}
className={styles.inspectLink}
title="Open full details"
>&#x2197;</a>
key: 'routeId',
header: 'Route',
sortable: true,
render: (_: unknown, row: Row) => (
<span className={styles.routeName}>{row.routeId}</span>
),
},
{ key: 'routeId', header: 'Route', sortable: true, render: (v) => <span>{String(v)}</span> },
{ key: 'applicationName', header: 'Application', sortable: true, render: (v) => <span>{String(v ?? '')}</span> },
{ key: 'executionId', header: 'Exchange ID', sortable: true, render: (v) => <MonoText size="xs">{String(v)}</MonoText> },
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => <MonoText size="xs">{new Date(v as string).toLocaleString()}</MonoText> },
{
key: 'durationMs', header: 'Duration', sortable: true,
render: (v) => <MonoText size="sm">{formatDuration(v as number)}</MonoText>,
key: 'applicationName',
header: 'Application',
sortable: true,
render: (_: unknown, row: Row) => (
<span className={styles.appName}>{row.applicationName ?? ''}</span>
),
},
{
key: 'agentId', header: 'Agent',
render: (v) => v ? <Badge label={String(v)} color="auto" /> : null,
key: 'executionId',
header: 'Exchange ID',
sortable: true,
render: (_: unknown, row: Row) => (
<MonoText size="xs">{row.executionId}</MonoText>
),
},
];
{
key: 'startTime',
header: 'Started',
sortable: true,
render: (_: unknown, row: Row) => (
<MonoText size="xs">{formatTimestamp(row.startTime)}</MonoText>
),
},
{
key: 'durationMs',
header: 'Duration',
sortable: true,
render: (_: unknown, row: Row) => (
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
{formatDuration(row.durationMs)}
</MonoText>
),
},
{
key: 'agentId',
header: 'Agent',
render: (_: unknown, row: Row) => (
<span className={styles.agentBadge}>
<span className={styles.agentDot} />
{row.agentId}
</span>
),
},
]
}
const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
const SHORTCUTS = [
{ keys: 'Ctrl+K', label: 'Search' },
{ keys: '\u2191\u2193', label: 'Navigate rows' },
{ keys: 'Enter', label: 'Open detail' },
{ keys: 'Esc', label: 'Close panel' },
]
// ─── Dashboard component ─────────────────────────────────────────────────────
export default function Dashboard() {
const { appId, routeId } = useParams<{ appId: string; routeId: string }>()
const navigate = useNavigate()
const [selectedId, setSelectedId] = useState<string | undefined>()
const [panelOpen, setPanelOpen] = useState(false)
const { timeRange, statusFilters } = useGlobalFilters()
const timeFrom = timeRange.start.toISOString()
const timeTo = timeRange.end.toISOString()
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000
// ─── API hooks ───────────────────────────────────────────────────────────
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId)
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId)
const { data: searchResult } = useSearchExecutions(
{
timeFrom,
timeTo,
routeId: routeId || undefined,
application: appId || undefined,
offset: 0,
limit: 50,
},
true,
)
const { data: detail } = useExecutionDetail(selectedId ?? null)
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null)
// ─── Rows ────────────────────────────────────────────────────────────────
const allRows: Row[] = useMemo(
() => (searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[searchResult],
)
// Apply global status filters (time filtering is done server-side via timeFrom/timeTo)
const rows: Row[] = useMemo(() => {
if (statusFilters.size === 0) return allRows
return allRows.filter((r) => statusFilters.has(r.status.toLowerCase() as any))
}, [allRows, statusFilters])
// ─── KPI items ───────────────────────────────────────────────────────────
const totalCount = stats?.totalCount ?? 0
const failedCount = stats?.failedCount ?? 0
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100
const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 0
const prevTotal = stats?.prevTotalCount ?? 0
const prevFailed = stats?.prevFailedCount ?? 0
const exchangeTrend = prevTotal > 0 ? ((totalCount - prevTotal) / prevTotal) * 100 : 0
const prevSuccessRate = prevTotal > 0 ? ((prevTotal - prevFailed) / prevTotal) * 100 : 100
const successRateDelta = successRate - prevSuccessRate
const errorDelta = failedCount - prevFailed
const sparkExchanges = useMemo(
() => (timeseries?.buckets || []).map((b: any) => b.totalCount as number),
[timeseries],
)
const sparkErrors = useMemo(
() => (timeseries?.buckets || []).map((b: any) => b.failedCount as number),
[timeseries],
)
const sparkLatency = useMemo(
() => (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number),
[timeseries],
)
const sparkThroughput = useMemo(
() =>
(timeseries?.buckets || []).map((b: any) => {
const bucketSeconds = timeWindowSeconds / Math.max((timeseries?.buckets || []).length, 1)
return bucketSeconds > 0 ? (b.totalCount as number) / bucketSeconds : 0
}),
[timeseries, timeWindowSeconds],
)
const kpiItems: KpiItem[] = useMemo(
() => [
{
label: 'Exchanges',
value: totalCount.toLocaleString(),
trend: {
label: `${exchangeTrend > 0 ? '\u2191' : exchangeTrend < 0 ? '\u2193' : '\u2192'} ${exchangeTrend > 0 ? '+' : ''}${exchangeTrend.toFixed(0)}%`,
variant: (exchangeTrend > 0 ? 'success' : exchangeTrend < 0 ? 'error' : 'muted') as 'success' | 'error' | 'muted',
},
subtitle: `${successRate.toFixed(1)}% success rate`,
sparkline: sparkExchanges,
borderColor: 'var(--amber)',
},
{
label: 'Success Rate',
value: `${successRate.toFixed(1)}%`,
trend: {
label: `${successRateDelta >= 0 ? '\u2191' : '\u2193'} ${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`,
variant: (successRateDelta >= 0 ? 'success' : 'error') as 'success' | 'error',
},
subtitle: `${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`,
borderColor: 'var(--success)',
},
{
label: 'Errors',
value: failedCount,
trend: {
label: `${errorDelta > 0 ? '\u2191' : errorDelta < 0 ? '\u2193' : '\u2192'} ${errorDelta > 0 ? '+' : ''}${errorDelta}`,
variant: (errorDelta > 0 ? 'error' : errorDelta < 0 ? 'success' : 'muted') as 'success' | 'error' | 'muted',
},
subtitle: `${failedCount} errors in selected period`,
sparkline: sparkErrors,
borderColor: 'var(--error)',
},
{
label: 'Throughput',
value: `${throughput.toFixed(1)} msg/s`,
trend: { label: '\u2192', variant: 'muted' as const },
subtitle: `${throughput.toFixed(1)} msg/s`,
sparkline: sparkThroughput,
borderColor: 'var(--running)',
},
{
label: 'Latency p99',
value: `${(stats?.p99LatencyMs ?? 0).toLocaleString()} ms`,
trend: { label: '', variant: 'muted' as const },
subtitle: `${(stats?.p99LatencyMs ?? 0).toLocaleString()}ms`,
sparkline: sparkLatency,
borderColor: 'var(--warning)',
},
],
[totalCount, failedCount, successRate, throughput, exchangeTrend, successRateDelta, errorDelta, sparkExchanges, sparkErrors, sparkLatency, sparkThroughput, stats?.p99LatencyMs],
)
// ─── Table columns with inspect action ───────────────────────────────────
const columns: Column<Row>[] = useMemo(() => {
const inspectCol: Column<Row> = {
key: 'correlationId',
header: '',
width: '36px',
render: (_: unknown, row: Row) => (
<button
className={styles.inspectLink}
title="Inspect exchange"
onClick={(e) => {
e.stopPropagation()
navigate(`/exchanges/${row.executionId}`)
}}
>
&#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 (
<div>
<div className={styles.healthStrip}>
<StatCard
label="Exchanges"
value={totalCount.toLocaleString()}
detail={`${successRate.toFixed(1)}% success rate`}
trend={exchangeTrend > 0 ? 'up' : exchangeTrend < 0 ? 'down' : 'neutral'}
trendValue={exchangeTrend > 0 ? `+${exchangeTrend.toFixed(0)}%` : `${exchangeTrend.toFixed(0)}%`}
sparkline={sparkExchanges}
accent="amber"
/>
<StatCard
label="Success Rate"
value={`${successRate.toFixed(1)}%`}
detail={`${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`}
trend={successRateDelta >= 0 ? 'up' : 'down'}
trendValue={`${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`}
accent="success"
/>
<StatCard
label="Errors"
value={failedCount}
detail={`${failedCount} errors in selected period`}
trend={errorDelta > 0 ? 'up' : errorDelta < 0 ? 'down' : 'neutral'}
trendValue={errorDelta > 0 ? `+${errorDelta}` : `${errorDelta}`}
sparkline={sparkErrors}
accent="error"
/>
<StatCard
label="Throughput"
value={throughput.toFixed(1)}
detail={`${throughput.toFixed(1)} msg/s`}
sparkline={sparkThroughput}
accent="running"
/>
<StatCard
label="Latency p99"
value={(stats?.p99LatencyMs ?? 0).toLocaleString()}
detail={`${(stats?.p99LatencyMs ?? 0).toLocaleString()}ms`}
sparkline={sparkLatency}
accent="warning"
/>
</div>
<>
{/* Scrollable content */}
<div className={styles.content}>
{/* KPI strip */}
<KpiStrip items={kpiItems} />
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Recent Exchanges</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{rows.length} of {searchResult?.total ?? 0} exchanges</span>
<Badge label="LIVE" color="success" />
{/* Exchanges table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Recent Exchanges</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={columns}
data={rows}
onRowClick={handleRowClick}
selectedId={selectedId}
sortable
flush
rowAccent={handleRowAccent}
expandedContent={(row: Row) =>
row.errorMessage ? (
<div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}>{'\u26A0'}</span>
<div>
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
</div>
</div>
) : null
}
/>
</div>
<DataTable
columns={columns}
data={rows}
onRowClick={(row) => { setSelectedId(row.id); }}
selectedId={selectedId ?? undefined}
sortable
pageSize={25}
/>
</div>
{selectedId && detail && (
{/* Shortcuts bar */}
<ShortcutsBar shortcuts={SHORTCUTS} />
{/* Detail panel */}
{selectedRow && detail && (
<DetailPanel
key={selectedId}
open={true}
onClose={() => setSelectedId(null)}
title={`${detail.routeId}${selectedId.slice(0, 12)}`}
className={styles.detailPanelOverride}
open={panelOpen}
onClose={() => setPanelOpen(false)}
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
>
{/* Open full details link */}
{/* Link to full detail page */}
<div className={styles.panelSection}>
<button
className={styles.openDetailLink}
@@ -196,9 +427,9 @@ export default function Dashboard() {
<div className={styles.overviewGrid}>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Status</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : 'error'} />
<span>{detail.status}</span>
<span className={styles.statusCell}>
<StatusDot variant={statusToVariant(detail.status)} />
<span>{statusLabel(detail.status)}</span>
</span>
</div>
<div className={styles.overviewRow}>
@@ -211,44 +442,38 @@ export default function Dashboard() {
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Agent</span>
<MonoText size="sm">{detail.agentId ?? ''}</MonoText>
<MonoText size="sm">{detail.agentId ?? '\u2014'}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Correlation</span>
<MonoText size="xs">{detail.correlationId ?? ''}</MonoText>
<MonoText size="xs">{detail.correlationId ?? '\u2014'}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Timestamp</span>
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toLocaleString() : ''}</MonoText>
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'}</MonoText>
</div>
</div>
</div>
{/* Errors */}
{detail.errorMessage && (
{errorMsg && (
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Errors</div>
<Alert variant="error">
<strong>{detail.errorMessage.split(':')[0]}</strong>
<div>{detail.errorMessage.includes(':') ? detail.errorMessage.substring(detail.errorMessage.indexOf(':') + 1).trim() : ''}</div>
</Alert>
{detail.errorStackTrace && (
<Collapsible title="Stack Trace">
<CodeBlock content={detail.errorStackTrace} />
</Collapsible>
)}
<div className={styles.errorBlock}>
<div className={styles.errorClass}>{errorClass}</div>
<div className={styles.errorMessage}>{errorMsg}</div>
</div>
</div>
)}
{/* Route Flow */}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Route Flow</div>
{diagram ? (
<RouteFlow
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
onNodeClick={(_node, _i) => {}}
/>
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>}
{routeNodes.length > 0 ? (
<RouteFlow nodes={routeNodes} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
)}
</div>
{/* Processor Timeline */}
@@ -257,33 +482,17 @@ export default function Dashboard() {
Processor Timeline
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
</div>
{procList.length ? (
{flatProcs.length > 0 ? (
<ProcessorTimeline
processors={flattenProcessors(procList)}
processors={flatProcs}
totalMs={detail.durationMs}
/>
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>}
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>
)}
</div>
</DetailPanel>
)}
</div>
);
}
function flattenProcessors(nodes: any[]): any[] {
const result: any[] = [];
let offset = 0;
function walk(node: any) {
result.push({
name: node.processorId || node.processorType,
type: node.processorType,
durationMs: node.durationMs ?? 0,
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
startMs: offset,
});
offset += node.durationMs ?? 0;
if (node.children) node.children.forEach(walk);
}
nodes.forEach(walk);
return result;
</>
)
}

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 {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
@@ -38,14 +56,14 @@
}
.routeLink {
color: var(--accent, #c6820e);
color: var(--amber);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.routeLink:hover {
color: var(--amber-deep, #a36b0b);
color: var(--amber-deep);
}
.headerDivider {
@@ -78,7 +96,9 @@
color: var(--text-primary);
}
/* Correlation Chain */
/* ==========================================================================
CORRELATION CHAIN
========================================================================== */
.correlationChain {
display: flex;
flex-direction: row;
@@ -104,7 +124,7 @@
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm, 4px);
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
font-size: 11px;
font-family: var(--font-mono);
@@ -120,20 +140,37 @@
}
.chainNodeCurrent {
background: var(--amber-bg, rgba(198, 130, 14, 0.08));
border-color: var(--accent, #c6820e);
color: var(--accent, #c6820e);
background: var(--amber-bg);
border-color: var(--amber-light);
color: var(--amber-deep);
font-weight: 600;
}
.chainNodeSuccess { border-left: 3px solid var(--success); }
.chainNodeError { border-left: 3px solid var(--error); }
.chainNodeRunning { border-left: 3px solid var(--running); }
.chainNodeWarning { border-left: 3px solid var(--warning); }
.chainNodeSuccess {
border-left: 3px solid var(--success);
}
.chainMore { color: var(--text-muted); font-size: 11px; font-style: italic; }
.chainNodeError {
border-left: 3px solid var(--error);
}
/* Timeline Section */
.chainNodeRunning {
border-left: 3px solid var(--running);
}
.chainNodeWarning {
border-left: 3px solid var(--warning);
}
.chainMore {
color: var(--text-muted);
font-size: 11px;
font-style: italic;
}
/* ==========================================================================
TIMELINE SECTION
========================================================================== */
.timelineSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
@@ -174,7 +211,7 @@
display: inline-flex;
gap: 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm, 4px);
border-radius: var(--radius-sm);
overflow: hidden;
}
@@ -194,20 +231,22 @@
}
.toggleBtnActive {
background: var(--accent, #c6820e);
background: var(--amber);
color: #fff;
font-weight: 600;
}
.toggleBtnActive:hover {
background: var(--amber-deep, #a36b0b);
background: var(--amber-deep);
}
.timelineBody {
padding: 12px 16px;
}
/* Detail Split (IN / OUT panels) */
/* ==========================================================================
DETAIL SPLIT (IN / OUT panels)
========================================================================== */
.detailSplit {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -224,7 +263,7 @@
}
.detailPanelError {
border-color: var(--error-border, rgba(220, 38, 38, 0.3));
border-color: var(--error-border);
}
.panelHeader {
@@ -238,8 +277,8 @@
}
.detailPanelError .panelHeader {
background: var(--error-bg, rgba(220, 38, 38, 0.06));
border-bottom-color: var(--error-border, rgba(220, 38, 38, 0.3));
background: var(--error-bg);
border-bottom-color: var(--error-border);
}
.panelTitle {
@@ -350,14 +389,33 @@
}
/* Error panel styles */
.errorBadgeRow {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.errorHttpBadge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
background: var(--error-bg);
color: var(--error);
border: 1px solid var(--error-border);
}
.errorMessageBox {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
background: var(--error-bg, rgba(220, 38, 38, 0.06));
background: var(--error-bg);
padding: 10px 12px;
border-radius: var(--radius-sm, 4px);
border: 1px solid var(--error-border, rgba(220, 38, 38, 0.3));
border-radius: var(--radius-sm);
border: 1px solid var(--error-border);
margin-bottom: 12px;
line-height: 1.5;
word-break: break-word;
@@ -382,3 +440,11 @@
font-family: var(--font-mono);
word-break: break-all;
}
/* Snapshot loading */
.snapshotLoading {
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 20px;
}

View File

@@ -1,112 +1,187 @@
import React, { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useState, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router'
import {
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
ProcessorTimeline, Breadcrumb, Spinner, RouteFlow,
} from '@cameleer/design-system';
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
import { useCorrelationChain } from '../../api/queries/correlation';
import { useDiagramLayout } from '../../api/queries/diagrams';
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
import styles from './ExchangeDetail.module.css';
} from '@cameleer/design-system'
import type { ProcessorStep, RouteNode } from '@cameleer/design-system'
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
import { useCorrelationChain } from '../../api/queries/correlation'
import { useDiagramLayout } from '../../api/queries/diagrams'
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
import styles from './ExchangeDetail.module.css'
function countProcessors(nodes: any[]): number {
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0);
// ── Helpers ──────────────────────────────────────────────────────────────────
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms}ms`
}
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
return `${ms}ms`;
function backendStatusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
switch (status.toUpperCase()) {
case 'COMPLETED': return 'success'
case 'FAILED': return 'error'
case 'RUNNING': return 'running'
default: return 'warning'
}
}
function backendStatusToLabel(status: string): string {
return status.toUpperCase()
}
function procStatusToStep(status: string): 'ok' | 'slow' | 'fail' {
const s = status.toUpperCase()
if (s === 'FAILED') return 'fail'
if (s === 'RUNNING') return 'slow'
return 'ok'
}
function parseHeaders(raw: string | undefined | null): Record<string, string> {
if (!raw) return {};
if (!raw) return {}
try {
const parsed = JSON.parse(raw);
const parsed = JSON.parse(raw)
if (typeof parsed === 'object' && parsed !== null) {
const result: Record<string, string> = {};
const result: Record<string, string> = {}
for (const [k, v] of Object.entries(parsed)) {
result[k] = typeof v === 'string' ? v : JSON.stringify(v);
result[k] = typeof v === 'string' ? v : JSON.stringify(v)
}
return result;
return result
}
} catch { /* ignore */ }
return {};
return {}
}
function countProcessors(nodes: Array<{ children?: any[] }>): number {
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0)
}
// ── ExchangeDetail ───────────────────────────────────────────────────────────
export default function ExchangeDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { data: detail, isLoading } = useExecutionDetail(id ?? null);
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt');
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null);
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
const { data: detail, isLoading } = useExecutionDetail(id ?? null)
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null)
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null)
// Auto-select first failed processor, or 0
const defaultIndex = useMemo(() => {
if (!procList.length) return 0;
const failIdx = procList.findIndex((p: any) =>
(p.status || '').toUpperCase() === 'FAILED' || p.status === 'fail'
);
return failIdx >= 0 ? failIdx : 0;
}, [procList]);
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null);
const activeIndex = selectedProcessorIndex ?? defaultIndex;
const procList = detail
? (detail.processors?.length ? detail.processors : (detail.children ?? []))
: []
const { data: snapshot } = useProcessorSnapshot(id ?? null, procList.length > 0 ? activeIndex : null);
const processors = useMemo(() => {
if (!procList.length) return [];
const result: any[] = [];
let offset = 0;
// Flatten processor tree into ProcessorStep[]
const processors: ProcessorStep[] = useMemo(() => {
if (!procList.length) return []
const result: ProcessorStep[] = []
let offset = 0
function walk(node: any) {
result.push({
name: node.processorId || node.processorType,
type: node.processorType,
durationMs: node.durationMs ?? 0,
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
status: procStatusToStep(node.status ?? ''),
startMs: offset,
});
offset += node.durationMs ?? 0;
if (node.children) node.children.forEach(walk);
})
offset += node.durationMs ?? 0
if (node.children) node.children.forEach(walk)
}
procList.forEach(walk);
return result;
}, [procList]);
procList.forEach(walk)
return result
}, [procList])
const selectedProc = processors[activeIndex];
const isSelectedFailed = selectedProc?.status === 'fail';
// Default selected processor: first failed, or 0
const defaultIndex = useMemo(() => {
if (!processors.length) return 0
const failIdx = processors.findIndex((p) => p.status === 'fail')
return failIdx >= 0 ? failIdx : 0
}, [processors])
// Parse snapshot headers
const inputHeaders = parseHeaders(snapshot?.inputHeaders);
const outputHeaders = parseHeaders(snapshot?.outputHeaders);
const inputBody = snapshot?.inputBody ?? null;
const outputBody = snapshot?.outputBody ?? null;
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null)
const activeIndex = selectedProcessorIndex ?? defaultIndex
if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
if (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>;
const { data: snapshot } = useProcessorSnapshot(
id ?? null,
procList.length > 0 ? activeIndex : null,
)
const selectedProc = processors[activeIndex]
const isSelectedFailed = selectedProc?.status === 'fail'
// Parse snapshot data
const inputHeaders = parseHeaders(snapshot?.inputHeaders)
const outputHeaders = parseHeaders(snapshot?.outputHeaders)
const inputBody = snapshot?.inputBody ?? null
const outputBody = snapshot?.outputBody ?? null
// Build RouteFlow nodes from diagram + execution data
const routeNodes: RouteNode[] = useMemo(() => {
if (diagram?.nodes) {
return mapDiagramToRouteNodes(diagram.nodes, procList)
}
// Fallback: build from processor list
return processors.map((p) => ({
name: p.name,
type: 'process' as RouteNode['type'],
durationMs: p.durationMs,
status: p.status,
}))
}, [diagram, processors, procList])
// Correlation chain
const correlatedExchanges = useMemo(() => {
if (!correlationData?.data || correlationData.data.length <= 1) return []
return correlationData.data
}, [correlationData])
// ── Loading state ────────────────────────────────────────────────────────
if (isLoading) {
return (
<div className={styles.loadingContainer}>
<Spinner size="lg" />
</div>
)
}
// ── Not found state ──────────────────────────────────────────────────────
if (!detail) {
return (
<div className={styles.content}>
<Breadcrumb items={[
{ label: 'Applications', href: '/apps' },
{ label: 'Exchanges' },
{ label: id ?? 'Unknown' },
]} />
<InfoCallout variant="warning">Exchange &quot;{id}&quot; not found.</InfoCallout>
</div>
)
}
const statusVariant = backendStatusToVariant(detail.status)
const statusLabel = backendStatusToLabel(detail.status)
return (
<div>
<div className={styles.content}>
{/* Breadcrumb */}
<Breadcrumb items={[
{ label: 'Dashboard', href: '/apps' },
{ label: 'Applications', href: '/apps' },
{ label: detail.applicationName || 'App', href: `/apps/${detail.applicationName}` },
{ label: id?.slice(0, 12) || '' },
{ label: detail.routeId, href: `/apps/${detail.applicationName}/${detail.routeId}` },
{ label: detail.executionId?.slice(0, 12) || '' },
]} />
{/* Exchange header card */}
<div className={styles.exchangeHeader}>
<div className={styles.headerRow}>
<div className={styles.headerLeft}>
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
<StatusDot variant={statusVariant} />
<div>
<div className={styles.exchangeId}>
<MonoText size="md">{id}</MonoText>
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} variant="filled" />
<MonoText size="md">{detail.executionId}</MonoText>
<Badge label={statusLabel} color={statusVariant} variant="filled" />
</div>
<div className={styles.exchangeRoute}>
Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${detail.applicationName}/${detail.routeId}`)}>{detail.routeId}</span>
@@ -116,6 +191,12 @@ export default function ExchangeDetail() {
App: <MonoText size="xs">{detail.applicationName}</MonoText>
</>
)}
{detail.correlationId && (
<>
<span className={styles.headerDivider}>&middot;</span>
Correlation: <MonoText size="xs">{detail.correlationId}</MonoText>
</>
)}
</div>
</div>
</div>
@@ -131,7 +212,9 @@ export default function ExchangeDetail() {
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Started</div>
<div className={styles.headerStatValue}>
{detail.startTime ? new Date(detail.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—'}
{detail.startTime
? new Date(detail.startTime).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: '\u2014'}
</div>
</div>
<div className={styles.headerStat}>
@@ -142,43 +225,39 @@ export default function ExchangeDetail() {
</div>
{/* Correlation Chain */}
{correlationData?.data && correlationData.data.length > 1 && (
{correlatedExchanges.length > 1 && (
<div className={styles.correlationChain}>
<span className={styles.chainLabel}>Correlated Exchanges</span>
{correlationData.data.map((exec: any) => {
const isCurrent = exec.executionId === id;
const variant = exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'running';
{correlatedExchanges.map((ce) => {
const isCurrent = ce.executionId === id
const variant = backendStatusToVariant(ce.status)
const statusCls =
variant === 'success' ? styles.chainNodeSuccess
: variant === 'error' ? styles.chainNodeError
: styles.chainNodeRunning;
: variant === 'running' ? styles.chainNodeRunning
: styles.chainNodeWarning
return (
<button
key={exec.executionId}
key={ce.executionId}
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
onClick={() => { if (!isCurrent) navigate(`/exchanges/${exec.executionId}`); }}
title={`${exec.executionId}${exec.routeId}`}
onClick={() => {
if (!isCurrent) navigate(`/exchanges/${ce.executionId}`)
}}
title={`${ce.executionId} \u2014 ${ce.routeId}`}
>
<StatusDot variant={variant as any} />
<span>{exec.routeId}</span>
<StatusDot variant={variant} />
<span>{ce.routeId}</span>
</button>
);
)
})}
{correlationData.total > 20 && (
{correlationData && correlationData.total > 20 && (
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
)}
</div>
)}
</div>
{/* Error callout */}
{detail.errorMessage && (
<InfoCallout variant="error">
{detail.errorMessage}
</InfoCallout>
)}
{/* Processor Timeline / Flow Section */}
{/* Processor Timeline Section */}
<div className={styles.timelineSection}>
<div className={styles.timelineHeader}>
<span className={styles.timelineTitle}>
@@ -206,17 +285,17 @@ export default function ExchangeDetail() {
<ProcessorTimeline
processors={processors}
totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setSelectedProcessorIndex(i)}
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
selectedIndex={activeIndex}
/>
) : (
<InfoCallout>No processor data available</InfoCallout>
)
) : (
diagram ? (
routeNodes.length > 0 ? (
<RouteFlow
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
onNodeClick={(_node, i) => setSelectedProcessorIndex(i)}
nodes={routeNodes}
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
selectedIndex={activeIndex}
/>
) : (
@@ -226,7 +305,7 @@ export default function ExchangeDetail() {
</div>
</div>
{/* Processor Detail: Message IN / Message OUT or Error */}
{/* Processor Detail Panel (split IN / OUT) */}
{selectedProc && snapshot && (
<div className={styles.detailSplit}>
{/* Message IN */}
@@ -255,7 +334,7 @@ export default function ExchangeDetail() {
)}
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={inputBody ?? 'null'} />
<CodeBlock content={inputBody ?? 'null'} language="json" copyable />
</div>
</div>
</div>
@@ -309,7 +388,7 @@ export default function ExchangeDetail() {
)}
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={outputBody ?? 'null'} />
<CodeBlock content={outputBody ?? 'null'} language="json" copyable />
</div>
</div>
</div>
@@ -317,12 +396,13 @@ export default function ExchangeDetail() {
</div>
)}
{/* No snapshot loaded yet - show prompt */}
{/* Snapshot loading indicator */}
{selectedProc && !snapshot && procList.length > 0 && (
<div style={{ color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', padding: 20 }}>
<div className={styles.snapshotLoading}>
Loading exchange snapshot...
</div>
)}
</div>
);
)
}

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 {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card);
padding: 16px; margin-bottom: 16px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
margin-bottom: 16px;
}
.headerRow { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.headerLeft { display: flex; align-items: center; gap: 12px; }
.headerRight { display: flex; gap: 20px; }
.headerStat { text-align: center; }
.headerStatLabel { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 2px; }
.headerStatValue { font-size: 14px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
.diagramStatsGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
.diagramPane, .statsPane {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
.headerRow {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.paneTitle { font-size: 13px; font-weight: 700; color: var(--text-primary); margin-bottom: 12px; }
.tabSection { margin-top: 20px; }
.chartGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.headerLeft {
display: flex;
align-items: center;
gap: 12px;
}
.headerRight {
display: flex;
gap: 20px;
}
.headerStat {
text-align: center;
}
.headerStatLabel {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 2px;
}
.headerStatValue {
font-size: 14px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text-primary);
}
/* Diagram + Stats side-by-side */
.diagramStatsGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.diagramPane,
.statsPane {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
overflow: hidden;
}
.paneTitle {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 12px;
}
/* Processor type badges */
.processorType {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.typeConsumer {
background: var(--running-bg);
color: var(--running);
}
.typeProducer {
background: var(--success-bg);
color: var(--success);
}
.typeEnricher {
background: var(--amber-bg);
color: var(--amber);
}
.typeValidator {
background: var(--running-bg);
color: var(--running);
}
.typeTransformer {
background: var(--bg-hover);
color: var(--text-muted);
}
.typeRouter {
background: var(--purple-bg);
color: var(--purple);
}
.typeProcessor {
background: var(--bg-hover);
color: var(--text-secondary);
}
/* Tabs section */
.tabSection {
margin-top: 20px;
}
/* Rate color classes */
.rateGood {
color: var(--success);
}
.rateWarn {
color: var(--warning);
}
.rateBad {
color: var(--error);
}
.rateNeutral {
color: var(--text-secondary);
}
/* Route name in table */
.routeNameCell {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-mono);
}
/* Table section (reused for processor table) */
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 20px;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
.tableMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Chart grid */
.chartGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.chartCard {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
overflow: hidden;
}
.chartTitle { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 12px; }
.chartTitle {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
margin-bottom: 12px;
}
/* Executions table */
.executionsTable {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.errorPatterns { display: flex; flex-direction: column; gap: 8px; }
/* Error patterns */
.errorPatterns {
display: flex;
flex-direction: column;
gap: 8px;
}
.errorRow {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 12px; background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); font-size: 12px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
font-size: 12px;
}
.errorMessage {
flex: 1;
font-family: var(--font-mono);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
}
.errorCount {
font-weight: 700;
color: var(--error);
margin: 0 12px;
}
.errorTime {
color: var(--text-muted);
font-size: 11px;
}
/* Route flow section */
.routeFlowSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
margin-top: 16px;
}
/* Empty / muted text */
.emptyText {
color: var(--text-muted);
font-size: 13px;
padding: 8px 0;
}
.errorMessage { flex: 1; font-family: var(--font-mono); color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 400px; }
.errorCount { font-weight: 700; color: var(--error); margin: 0 12px; }
.errorTime { color: var(--text-muted); font-size: 11px; }
.backLink { font-size: 13px; color: var(--text-muted); text-decoration: none; margin-bottom: 12px; display: inline-block; }
.backLink:hover { color: var(--text-primary); }

View File

@@ -1,20 +1,31 @@
import { useState, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router';
import {
Badge, StatusDot, DataTable, Tabs,
AreaChart, LineChart, BarChart, RouteFlow, Spinner,
KpiStrip,
Badge,
StatusDot,
DataTable,
Tabs,
AreaChart,
LineChart,
BarChart,
RouteFlow,
Spinner,
MonoText,
Sparkline,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import type { KpiItem, Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useRouteCatalog } from '../../api/queries/catalog';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
import { useStatsTimeseries, useSearchExecutions } from '../../api/queries/executions';
import { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../../api/queries/executions';
import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
import styles from './RouteDetail.module.css';
// ── Row types ────────────────────────────────────────────────────────────────
interface ExchangeRow extends ExecutionSummary {
id: string;
}
@@ -26,6 +37,8 @@ interface ProcessorRow {
avgDurationMs: number;
p99DurationMs: number;
errorCount: number;
errorRate: number;
sparkline: number[];
}
interface ErrorPattern {
@@ -34,6 +47,211 @@ interface ErrorPattern {
lastSeen: string;
}
// ── Processor type badge classes ─────────────────────────────────────────────
const TYPE_STYLE_MAP: Record<string, string> = {
consumer: styles.typeConsumer,
producer: styles.typeProducer,
enricher: styles.typeEnricher,
validator: styles.typeValidator,
transformer: styles.typeTransformer,
router: styles.typeRouter,
processor: styles.typeProcessor,
};
function classifyProcessorType(processorId: string): string {
const lower = processorId.toLowerCase();
if (lower.startsWith('from(') || lower.includes('consumer')) return 'consumer';
if (lower.startsWith('to(')) return 'producer';
if (lower.includes('enrich')) return 'enricher';
if (lower.includes('validate') || lower.includes('check')) return 'validator';
if (lower.includes('unmarshal') || lower.includes('marshal')) return 'transformer';
if (lower.includes('route') || lower.includes('choice')) return 'router';
return 'processor';
}
// ── Processor table columns ──────────────────────────────────────────────────
function makeProcessorColumns(css: typeof styles): Column<ProcessorRow>[] {
return [
{
key: 'processorId',
header: 'Processor',
sortable: true,
render: (_, row) => (
<span className={css.routeNameCell}>{row.processorId}</span>
),
},
{
key: 'callCount',
header: 'Invocations',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.callCount.toLocaleString()}</MonoText>
),
},
{
key: 'avgDurationMs',
header: 'Avg Duration',
sortable: true,
render: (_, row) => {
const cls = row.avgDurationMs > 200 ? css.rateBad : row.avgDurationMs > 100 ? css.rateWarn : css.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.avgDurationMs)}ms</MonoText>;
},
},
{
key: 'p99DurationMs',
header: 'p99 Duration',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300 ? css.rateBad : row.p99DurationMs > 200 ? css.rateWarn : css.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
},
},
{
key: 'errorCount',
header: 'Errors',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={row.errorCount > 10 ? css.rateBad : css.rateNeutral}>
{row.errorCount}
</MonoText>
),
},
{
key: 'errorRate',
header: 'Error Rate',
sortable: true,
render: (_, row) => {
const cls = row.errorRate > 1 ? css.rateBad : row.errorRate > 0.5 ? css.rateWarn : css.rateGood;
return <MonoText size="sm" className={cls}>{row.errorRate.toFixed(2)}%</MonoText>;
},
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
];
}
// ── Exchange table columns ───────────────────────────────────────────────────
const EXCHANGE_COLUMNS: Column<ExchangeRow>[] = [
{
key: 'status',
header: 'Status',
width: '80px',
render: (_, row) => (
<StatusDot variant={row.status === 'COMPLETED' ? 'success' : row.status === 'FAILED' ? 'error' : 'running'} />
),
},
{
key: 'executionId',
header: 'Exchange ID',
render: (_, row) => <MonoText size="xs">{row.executionId.slice(0, 12)}</MonoText>,
},
{
key: 'startTime',
header: 'Started',
sortable: true,
render: (_, row) => new Date(row.startTime).toLocaleTimeString(),
},
{
key: 'durationMs',
header: 'Duration',
sortable: true,
render: (_, row) => `${row.durationMs}ms`,
},
];
// ── Build KPI items ──────────────────────────────────────────────────────────
function buildDetailKpiItems(
stats: {
totalCount: number;
failedCount: number;
avgDurationMs: number;
p99LatencyMs: number;
activeCount: number;
prevTotalCount: number;
prevFailedCount: number;
prevP99LatencyMs: number;
} | undefined,
throughputSparkline: number[],
errorSparkline: number[],
latencySparkline: number[],
): KpiItem[] {
const totalCount = stats?.totalCount ?? 0;
const failedCount = stats?.failedCount ?? 0;
const prevTotalCount = stats?.prevTotalCount ?? 0;
const p99Ms = stats?.p99LatencyMs ?? 0;
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
const avgMs = stats?.avgDurationMs ?? 0;
const activeCount = stats?.activeCount ?? 0;
const errorRate = totalCount > 0 ? (failedCount / totalCount) * 100 : 0;
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
const throughputPctChange = prevTotalCount > 0
? Math.round(((totalCount - prevTotalCount) / prevTotalCount) * 100)
: 0;
return [
{
label: 'Total Throughput',
value: totalCount.toLocaleString(),
trend: {
label: throughputPctChange >= 0 ? `\u25B2 +${throughputPctChange}%` : `\u25BC ${throughputPctChange}%`,
variant: throughputPctChange >= 0 ? 'success' as const : 'error' as const,
},
subtitle: `${activeCount} in-flight`,
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
{
label: 'System Error Rate',
value: `${errorRate.toFixed(2)}%`,
trend: {
label: errorRate < 1 ? '\u25BC low' : `\u25B2 ${errorRate.toFixed(1)}%`,
variant: errorRate < 1 ? 'success' as const : 'error' as const,
},
subtitle: `${failedCount} errors / ${totalCount.toLocaleString()} total`,
sparkline: errorSparkline,
borderColor: errorRate < 1 ? 'var(--success)' : 'var(--error)',
},
{
label: 'Latency P99',
value: `${p99Ms}ms`,
trend: {
label: p99Ms > prevP99Ms ? `\u25B2 +${p99Ms - prevP99Ms}ms` : `\u25BC ${prevP99Ms - p99Ms}ms`,
variant: p99Ms > 300 ? 'error' as const : 'warning' as const,
},
subtitle: `Avg ${avgMs}ms \u00B7 SLA <300ms`,
sparkline: latencySparkline,
borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)',
},
{
label: 'Success Rate',
value: `${successRate.toFixed(1)}%`,
trend: { label: '\u2194', variant: 'muted' as const },
subtitle: `${totalCount - failedCount} ok / ${failedCount} failed`,
borderColor: 'var(--success)',
},
{
label: 'In-Flight',
value: String(activeCount),
trend: { label: '\u2194', variant: 'muted' as const },
subtitle: `${activeCount} active exchanges`,
borderColor: 'var(--amber)',
},
];
}
// ── Component ────────────────────────────────────────────────────────────────
export default function RouteDetail() {
const { appId, routeId } = useParams();
const navigate = useNavigate();
@@ -43,9 +261,11 @@ export default function RouteDetail() {
const [activeTab, setActiveTab] = useState('performance');
// ── API queries ────────────────────────────────────────────────────────────
const { data: catalog } = useRouteCatalog();
const { data: diagram } = useDiagramByRoute(appId, routeId);
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId);
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
timeFrom,
@@ -65,6 +285,8 @@ export default function RouteDetail() {
limit: 200,
});
// ── Derived data ───────────────────────────────────────────────────────────
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
[catalog, appId],
@@ -79,7 +301,7 @@ export default function RouteDetail() {
const exchangeCount = routeSummary?.exchangeCount ?? 0;
const lastSeen = routeSummary?.lastSeen
? new Date(routeSummary.lastSeen).toLocaleString()
: '';
: '\u2014';
const healthVariant = useMemo((): 'success' | 'warning' | 'error' | 'dead' => {
const h = health.toLowerCase();
@@ -89,39 +311,70 @@ export default function RouteDetail() {
return 'dead';
}, [health]);
// Route flow from diagram
const diagramNodes = useMemo(() => {
if (!diagram?.nodes) return [];
return mapDiagramToRouteNodes(diagram.nodes, []);
}, [diagram]);
// Processor table rows
const processorRows: ProcessorRow[] = useMemo(() =>
(processorMetrics || []).map((p: any) => ({
id: p.processorId,
processorId: p.processorId,
callCount: p.callCount ?? 0,
avgDurationMs: p.avgDurationMs ?? 0,
p99DurationMs: p.p99DurationMs ?? 0,
errorCount: p.errorCount ?? 0,
})),
(processorMetrics || []).map((p: any) => {
const callCount = p.callCount ?? 0;
const errorCount = p.errorCount ?? 0;
const errRate = callCount > 0 ? (errorCount / callCount) * 100 : 0;
return {
id: p.processorId,
processorId: p.processorId,
type: classifyProcessorType(p.processorId ?? ''),
callCount,
avgDurationMs: p.avgDurationMs ?? 0,
p99DurationMs: p.p99DurationMs ?? 0,
errorCount,
errorRate: Number(errRate.toFixed(2)),
sparkline: p.sparkline ?? [],
};
}),
[processorMetrics],
);
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => ({
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
throughput: b.totalCount,
latency: b.avgDurationMs,
errors: b.failedCount,
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
})),
// Timeseries-derived data
const throughputSparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.totalCount),
[timeseries],
);
const errorSparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.failedCount),
[timeseries],
);
const latencySparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.p99DurationMs),
[timeseries],
);
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b) => {
const ts = new Date(b.time);
return {
time: !isNaN(ts.getTime())
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: '\u2014',
throughput: b.totalCount,
latency: b.avgDurationMs,
errors: b.failedCount,
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
};
}),
[timeseries],
);
// Exchange rows
const exchangeRows: ExchangeRow[] = useMemo(() =>
(recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[recentResult],
);
// Error patterns
const errorPatterns: ErrorPattern[] = useMemo(() => {
const failed = (errorResult?.data || []) as ExecutionSummary[];
const grouped = new Map<string, { count: number; lastSeen: string }>();
@@ -141,31 +394,18 @@ export default function RouteDetail() {
.map(([message, { count, lastSeen: ls }]) => ({
message,
count,
lastSeen: ls ? new Date(ls).toLocaleString() : '',
lastSeen: ls ? new Date(ls).toLocaleString() : '\u2014',
}))
.sort((a, b) => b.count - a.count);
}, [errorResult]);
const processorColumns: Column<ProcessorRow>[] = [
{ key: 'processorId', header: 'Processor', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'callCount', header: 'Calls', sortable: true },
{ key: 'avgDurationMs', header: 'Avg', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
{ key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
{ key: 'errorCount', header: 'Errors', sortable: true, render: (v) => {
const n = v as number;
return n > 0 ? <span style={{ color: 'var(--error)', fontWeight: 700 }}>{n}</span> : <span>0</span>;
}},
];
// KPI items
const kpiItems = useMemo(() =>
buildDetailKpiItems(stats, throughputSparkline, errorSparkline, latencySparkline),
[stats, throughputSparkline, errorSparkline, latencySparkline],
);
const exchangeColumns: Column<ExchangeRow>[] = [
{
key: 'status', header: 'Status', width: '80px',
render: (v) => <StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />,
},
{ key: 'executionId', header: 'Exchange ID', render: (v) => <MonoText size="xs">{String(v).slice(0, 12)}</MonoText> },
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => new Date(v as string).toLocaleTimeString() },
{ key: 'durationMs', header: 'Duration', sortable: true, render: (v) => `${v}ms` },
];
const processorColumns = useMemo(() => makeProcessorColumns(styles), []);
const tabs = [
{ label: 'Performance', value: 'performance' },
@@ -173,12 +413,15 @@ export default function RouteDetail() {
{ label: 'Error Patterns', value: 'errors', count: errorPatterns.length },
];
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div>
<Link to={`/routes/${appId}`} className={styles.backLink}>
{appId} routes
&larr; {appId} routes
</Link>
{/* Route header card */}
<div className={styles.headerCard}>
<div className={styles.headerRow}>
<div className={styles.headerLeft}>
@@ -199,13 +442,17 @@ export default function RouteDetail() {
</div>
</div>
{/* KPI strip */}
<KpiStrip items={kpiItems} />
{/* Diagram + Processor Stats grid */}
<div className={styles.diagramStatsGrid}>
<div className={styles.diagramPane}>
<div className={styles.paneTitle}>Route Diagram</div>
{diagramNodes.length > 0 ? (
<RouteFlow nodes={diagramNodes} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
<div className={styles.emptyText}>
No diagram available for this route.
</div>
)}
@@ -217,13 +464,40 @@ export default function RouteDetail() {
) : processorRows.length > 0 ? (
<DataTable columns={processorColumns} data={processorRows} sortable pageSize={10} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
<div className={styles.emptyText}>
No processor data available.
</div>
)}
</div>
</div>
{/* Processor Performance table (full width) */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Processor Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{processorRows.length} processors</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={processorColumns}
data={processorRows}
sortable
/>
</div>
{/* Route Flow section */}
{diagramNodes.length > 0 && (
<div className={styles.routeFlowSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Route Flow</span>
</div>
<RouteFlow nodes={diagramNodes} />
</div>
)}
{/* Tabbed section: Performance charts, Recent Executions, Error Patterns */}
<div className={styles.tabSection}>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
@@ -232,28 +506,41 @@ export default function RouteDetail() {
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Throughput</div>
<AreaChart
series={[{ label: 'Throughput', data: chartData.map((d, i) => ({ x: i, y: d.throughput })) }]}
series={[{
label: 'Throughput',
data: chartData.map((d, i) => ({ x: i, y: d.throughput })),
}]}
height={200}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Latency</div>
<LineChart
series={[{ label: 'Latency', data: chartData.map((d, i) => ({ x: i, y: d.latency })) }]}
series={[{
label: 'Latency',
data: chartData.map((d, i) => ({ x: i, y: d.latency })),
}]}
height={200}
threshold={{ value: 300, label: 'SLA 300ms' }}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Errors</div>
<BarChart
series={[{ label: 'Errors', data: chartData.map((d) => ({ x: d.time, y: d.errors })) }]}
series={[{
label: 'Errors',
data: chartData.map((d) => ({ x: d.time, y: d.errors })),
}]}
height={200}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Success Rate</div>
<AreaChart
series={[{ label: 'Success Rate', data: chartData.map((d, i) => ({ x: i, y: d.successRate })) }]}
series={[{
label: 'Success Rate',
data: chartData.map((d, i) => ({ x: i, y: d.successRate })),
}]}
height={200}
/>
</div>
@@ -268,7 +555,7 @@ export default function RouteDetail() {
</div>
) : (
<DataTable
columns={exchangeColumns}
columns={EXCHANGE_COLUMNS}
data={exchangeRows}
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
sortable
@@ -281,7 +568,7 @@ export default function RouteDetail() {
{activeTab === 'errors' && (
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
{errorPatterns.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
<div className={styles.emptyText}>
No error patterns found in the selected time range.
</div>
) : (

View File

@@ -1,17 +1,44 @@
.statStrip {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
margin-bottom: 16px;
/* Scrollable content area */
.content {
display: flex;
flex-direction: column;
gap: 20px;
}
.refreshIndicator {
display: flex;
align-items: center;
gap: 6px;
justify-content: flex-end;
}
.refreshDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 4px rgba(61, 124, 71, 0.5);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.refreshText {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Route performance table */
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 20px;
}
.tableHeader {
@@ -28,36 +55,56 @@
color: var(--text-primary);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
.tableMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Route name in table */
.routeNameCell {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-mono);
}
/* Application column */
.appCell {
font-size: 12px;
color: var(--text-secondary);
}
/* Rate color classes */
.rateGood {
color: var(--success);
}
.rateWarn {
color: var(--warning);
}
.rateBad {
color: var(--error);
}
.rateNeutral {
color: var(--text-secondary);
}
/* 2x2 chart grid */
.chartGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.chartCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
overflow: hidden;
.chart {
width: 100%;
}
.chartTitle {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.rateGood { color: var(--success); }
.rateWarn { color: var(--warning); }
.rateBad { color: var(--error); }

View File

@@ -1,13 +1,21 @@
import { useMemo } from 'react';
import { useParams } from 'react-router';
import { useParams, useNavigate } from 'react-router';
import {
StatCard, Sparkline, MonoText, Badge,
DataTable, AreaChart, LineChart, BarChart,
KpiStrip,
DataTable,
AreaChart,
LineChart,
BarChart,
Card,
Sparkline,
MonoText,
Badge,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import type { KpiItem, Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useRouteMetrics } from '../../api/queries/catalog';
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { useGlobalFilters } from '@cameleer/design-system';
import type { RouteMetrics } from '../../api/types';
import styles from './RoutesMetrics.module.css';
interface RouteRow {
@@ -23,186 +31,322 @@ interface RouteRow {
sparkline: number[];
}
// ── Route table columns ──────────────────────────────────────────────────────
const ROUTE_COLUMNS: Column<RouteRow>[] = [
{
key: 'routeId',
header: 'Route',
sortable: true,
render: (_, row) => (
<span className={styles.routeNameCell}>{row.routeId}</span>
),
},
{
key: 'appId',
header: 'Application',
sortable: true,
render: (_, row) => (
<span className={styles.appCell}>{row.appId}</span>
),
},
{
key: 'exchangeCount',
header: 'Exchanges',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.exchangeCount.toLocaleString()}</MonoText>
),
},
{
key: 'successRate',
header: 'Success %',
sortable: true,
render: (_, row) => {
const pct = row.successRate * 100;
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
},
},
{
key: 'avgDurationMs',
header: 'Avg Duration',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{Math.round(row.avgDurationMs)}ms</MonoText>
),
},
{
key: 'p99DurationMs',
header: 'p99 Duration',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
},
},
{
key: 'errorRate',
header: 'Error Rate',
sortable: true,
render: (_, row) => {
const pct = row.errorRate * 100;
const cls = pct > 5 ? styles.rateBad : pct > 1 ? styles.rateWarn : styles.rateGood;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
},
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
];
// ── Build KPI items from backend stats ───────────────────────────────────────
function buildKpiItems(
stats: {
totalCount: number;
failedCount: number;
avgDurationMs: number;
p99LatencyMs: number;
activeCount: number;
prevTotalCount: number;
prevFailedCount: number;
prevP99LatencyMs: number;
} | undefined,
routeCount: number,
throughputSparkline: number[],
errorSparkline: number[],
): KpiItem[] {
const totalCount = stats?.totalCount ?? 0;
const failedCount = stats?.failedCount ?? 0;
const prevTotalCount = stats?.prevTotalCount ?? 0;
const p99Ms = stats?.p99LatencyMs ?? 0;
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
const avgMs = stats?.avgDurationMs ?? 0;
const activeCount = stats?.activeCount ?? 0;
const errorRate = totalCount > 0 ? (failedCount / totalCount) * 100 : 0;
const throughputPctChange = prevTotalCount > 0
? Math.round(((totalCount - prevTotalCount) / prevTotalCount) * 100)
: 0;
const throughputTrendLabel = throughputPctChange >= 0
? `\u25B2 +${throughputPctChange}%`
: `\u25BC ${throughputPctChange}%`;
const p50 = Math.round(avgMs * 0.5);
const p95 = Math.round(avgMs * 1.4);
const slaStatus = p99Ms > 300 ? 'BREACH' : 'OK';
const prevErrorRate = prevTotalCount > 0
? ((stats?.prevFailedCount ?? 0) / prevTotalCount) * 100
: 0;
const errorDelta = (errorRate - prevErrorRate).toFixed(1);
return [
{
label: 'Total Throughput',
value: totalCount.toLocaleString(),
trend: {
label: throughputTrendLabel,
variant: throughputPctChange >= 0 ? 'success' as const : 'error' as const,
},
subtitle: `${activeCount} active exchanges`,
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
{
label: 'System Error Rate',
value: `${errorRate.toFixed(2)}%`,
trend: {
label: errorRate <= prevErrorRate ? `\u25BC ${errorDelta}%` : `\u25B2 +${errorDelta}%`,
variant: errorRate < 1 ? 'success' as const : 'error' as const,
},
subtitle: `${failedCount} errors / ${totalCount.toLocaleString()} total`,
sparkline: errorSparkline,
borderColor: errorRate < 1 ? 'var(--success)' : 'var(--error)',
},
{
label: 'Latency Percentiles',
value: `${p99Ms}ms`,
trend: {
label: p99Ms > prevP99Ms ? `\u25B2 +${p99Ms - prevP99Ms}ms` : `\u25BC ${prevP99Ms - p99Ms}ms`,
variant: p99Ms > 300 ? 'error' as const : 'warning' as const,
},
subtitle: `P50 ${p50}ms \u00B7 P95 ${p95}ms \u00B7 SLA <300ms P99: ${slaStatus}`,
borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)',
},
{
label: 'Active Routes',
value: `${routeCount}`,
trend: { label: '\u2194 stable', variant: 'muted' as const },
subtitle: `${routeCount} routes reporting`,
borderColor: 'var(--running)',
},
{
label: 'In-Flight Exchanges',
value: String(activeCount),
trend: { label: '\u2194', variant: 'muted' as const },
subtitle: `${activeCount} active`,
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
];
}
// ── Component ────────────────────────────────────────────────────────────────
export default function RoutesMetrics() {
const { appId, routeId } = useParams();
const { appId } = useParams();
const navigate = useNavigate();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: stats } = useExecutionStats(timeFrom, timeTo, undefined, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
// Map backend RouteMetrics[] to table rows
const rows: RouteRow[] = useMemo(() =>
(metrics || []).map((m: any) => ({
(metrics || []).map((m: RouteMetrics) => ({
id: `${m.appId}/${m.routeId}`,
...m,
routeId: m.routeId,
appId: m.appId,
exchangeCount: m.exchangeCount,
successRate: m.successRate,
avgDurationMs: m.avgDurationMs,
p99DurationMs: m.p99DurationMs,
errorRate: m.errorRate,
throughputPerSec: m.throughputPerSec,
sparkline: m.sparkline ?? [],
})),
[metrics],
);
const sparklineData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
// Sparkline data from timeseries buckets
const throughputSparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.totalCount),
[timeseries],
);
const errorSparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.failedCount),
[timeseries],
);
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b: any, i: number) => {
const ts = b.timestamp ? new Date(b.timestamp) : null;
const time = ts && !isNaN(ts.getTime())
// Chart series from timeseries buckets
const throughputChartSeries = useMemo(() => [{
label: 'Throughput',
data: (timeseries?.buckets || []).map((b, i) => ({
x: i as number,
y: b.totalCount,
})),
}], [timeseries]);
const latencyChartSeries = useMemo(() => [{
label: 'Latency',
data: (timeseries?.buckets || []).map((b, i) => ({
x: i as number,
y: b.avgDurationMs,
})),
}], [timeseries]);
const errorBarSeries = useMemo(() => [{
label: 'Errors',
data: (timeseries?.buckets || []).map((b) => {
const ts = new Date(b.time);
const label = !isNaN(ts.getTime())
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: String(i);
return {
time,
throughput: b.totalCount ?? 0,
latency: b.avgDurationMs ?? 0,
errors: b.failedCount ?? 0,
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
};
: '—';
return { x: label, y: b.failedCount };
}),
[timeseries],
}], [timeseries]);
const volumeChartSeries = useMemo(() => [{
label: 'Volume',
data: (timeseries?.buckets || []).map((b, i) => ({
x: i as number,
y: b.totalCount,
})),
}], [timeseries]);
const kpiItems = useMemo(() =>
buildKpiItems(stats, rows.length, throughputSparkline, errorSparkline),
[stats, rows.length, throughputSparkline, errorSparkline],
);
const columns: Column<RouteRow>[] = [
{ key: 'routeId', header: 'Route', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'appId', header: 'App', render: (v) => <Badge label={String(v)} color="auto" /> },
{ key: 'exchangeCount', header: 'Exchanges', sortable: true },
{
key: 'successRate', header: 'Success', sortable: true,
render: (v) => `${((v as number) * 100).toFixed(1)}%`,
},
{ key: 'avgDurationMs', header: 'Avg Duration', sortable: true, render: (v) => `${(v as number).toFixed(0)}ms` },
{ key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(0)}ms` },
{
key: 'errorRate', header: 'Error Rate', sortable: true,
render: (v) => {
const rate = v as number;
const cls = rate > 0.05 ? styles.rateBad : rate > 0.01 ? styles.rateWarn : styles.rateGood;
return <span className={cls}>{(rate * 100).toFixed(1)}%</span>;
},
},
{
key: 'sparkline', header: 'Trend', width: '80px',
render: (v) => <Sparkline data={v as number[]} />,
},
];
const errorRate = stats?.totalCount
? (((stats.failedCount ?? 0) / stats.totalCount) * 100)
: 0;
const prevErrorRate = stats?.prevTotalCount
? (((stats.prevFailedCount ?? 0) / stats.prevTotalCount) * 100)
: 0;
const errorTrend: 'up' | 'down' | 'neutral' = errorRate > prevErrorRate ? 'up' : errorRate < prevErrorRate ? 'down' : 'neutral';
const errorTrendValue = stats?.prevTotalCount
? `${Math.abs(errorRate - prevErrorRate).toFixed(2)}%`
: undefined;
const p99Ms = stats?.p99LatencyMs ?? 0;
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
const latencyTrend: 'up' | 'down' | 'neutral' = p99Ms > prevP99Ms ? 'up' : p99Ms < prevP99Ms ? 'down' : 'neutral';
const latencyTrendValue = prevP99Ms ? `${Math.abs(p99Ms - prevP99Ms)}ms` : undefined;
const totalCount = stats?.totalCount ?? 0;
const prevTotalCount = stats?.prevTotalCount ?? 0;
const throughputTrend: 'up' | 'down' | 'neutral' = totalCount > prevTotalCount ? 'up' : totalCount < prevTotalCount ? 'down' : 'neutral';
const throughputTrendValue = prevTotalCount
? `${Math.abs(((totalCount - prevTotalCount) / prevTotalCount) * 100).toFixed(0)}%`
: undefined;
const successRate = stats?.totalCount
? (((stats.totalCount - (stats.failedCount ?? 0)) / stats.totalCount) * 100)
: 100;
const activeCount = stats?.activeCount ?? 0;
const errorSparkline = (timeseries?.buckets || []).map((b: any) => b.failedCount as number);
const latencySparkline = (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number);
return (
<div>
<div className={styles.statStrip}>
<StatCard
label="Total Throughput"
value={totalCount.toLocaleString()}
detail="exchanges"
trend={throughputTrend}
trendValue={throughputTrendValue}
accent="amber"
sparkline={sparklineData}
/>
<StatCard
label="System Error Rate"
value={`${errorRate.toFixed(2)}%`}
detail={`${stats?.failedCount ?? 0} errors / ${totalCount.toLocaleString()} total`}
trend={errorTrend}
trendValue={errorTrendValue}
accent={errorRate < 1 ? 'success' : 'error'}
sparkline={errorSparkline}
/>
<StatCard
label="P99 Latency"
value={`${p99Ms}ms`}
detail={`Avg: ${stats?.avgDurationMs ?? 0}ms`}
trend={latencyTrend}
trendValue={latencyTrendValue}
accent={p99Ms > 300 ? 'error' : p99Ms > 200 ? 'warning' : 'success'}
sparkline={latencySparkline}
/>
<StatCard
label="Success Rate"
value={`${successRate.toFixed(1)}%`}
detail={`${activeCount} active routes`}
accent="success"
sparkline={sparklineData.map((v, i) => {
const failed = errorSparkline[i] ?? 0;
return v > 0 ? ((v - failed) / v) * 100 : 100;
})}
/>
<StatCard
label="In-Flight"
value={activeCount}
detail="active exchanges"
accent="amber"
/>
<div className={styles.content}>
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI header cards */}
<KpiStrip items={kpiItems} />
{/* Per-route performance table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Per-Route Performance</span>
<span className={styles.tableMeta}>{rows.length} routes</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{rows.length} routes</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={columns}
columns={ROUTE_COLUMNS}
data={rows}
sortable
pageSize={20}
onRowClick={(row) => {
const targetAppId = appId ?? row.appId;
navigate(`/routes/${targetAppId}/${row.routeId}`);
}}
/>
</div>
{chartData.length > 0 && (
{/* 2x2 chart grid */}
{(timeseries?.buckets?.length ?? 0) > 0 && (
<div className={styles.chartGrid}>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/s" height={200} />
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Latency (ms)</div>
<LineChart
series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]}
yLabel="ms"
<Card title="Throughput (msg/s)">
<AreaChart
series={throughputChartSeries}
yLabel="msg/s"
height={200}
threshold={{ value: 300, label: 'SLA 300ms' }}
className={styles.chart}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Errors by Route</div>
<BarChart series={[{ label: 'Errors', data: chartData.map((d: any) => ({ x: d.time as string, y: d.errors })) }]} height={200} />
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
<AreaChart series={[{ label: 'Volume', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/min" height={200} />
</div>
</Card>
<Card title="Latency (ms)">
<LineChart
series={latencyChartSeries}
yLabel="ms"
threshold={{ value: 300, label: 'SLA 300ms' }}
height={200}
className={styles.chart}
/>
</Card>
<Card title="Errors by Route">
<BarChart
series={errorBarSeries}
height={200}
className={styles.chart}
/>
</Card>
<Card title="Message Volume (msg/min)">
<AreaChart
series={volumeChartSeries}
yLabel="msg/min"
height={200}
className={styles.chart}
/>
</Card>
</div>
)}
</div>