From 81f85aa82d77d60f8c78e8c9f2721de24bfdae0e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:42:16 +0100 Subject: [PATCH] feat: replace UI with design system example pages wired to real API Migrate all page components from the @cameleer/design-system v0.0.3 example UI, replacing mock data with real backend API hooks. This brings richer visuals (KpiStrip, GroupCard, RouteFlow, ProcessorTimeline, DateRangePicker, expandable rows) while preserving all existing API integration, auth, and routing infrastructure. Pages migrated: Dashboard, RoutesMetrics, RouteDetail, ExchangeDetail, AgentHealth, AgentInstance, OidcConfig, AuditLog, RBAC (Users/Groups/Roles). Also enhanced LayoutShell CommandPalette with real search data from catalog. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/LayoutShell.tsx | 74 +- ui/src/pages/Admin/AdminLayout.tsx | 4 +- ui/src/pages/Admin/AuditLogPage.module.css | 86 ++ ui/src/pages/Admin/AuditLogPage.tsx | 167 ++- ui/src/pages/Admin/GroupsTab.tsx | 655 ++++++------ ui/src/pages/Admin/OidcConfigPage.module.css | 53 +- ui/src/pages/Admin/OidcConfigPage.tsx | 252 +++-- ui/src/pages/Admin/RbacPage.tsx | 25 +- ui/src/pages/Admin/RolesTab.tsx | 432 ++++---- ui/src/pages/Admin/UserManagement.module.css | 200 ++-- ui/src/pages/Admin/UsersTab.tsx | 968 ++++++++++-------- .../pages/AgentHealth/AgentHealth.module.css | 359 +++---- ui/src/pages/AgentHealth/AgentHealth.tsx | 666 +++++++----- .../AgentInstance/AgentInstance.module.css | 214 ++-- ui/src/pages/AgentInstance/AgentInstance.tsx | 421 +++++--- ui/src/pages/Dashboard/Dashboard.module.css | 185 +++- ui/src/pages/Dashboard/Dashboard.tsx | 599 +++++++---- .../ExchangeDetail/ExchangeDetail.module.css | 112 +- .../pages/ExchangeDetail/ExchangeDetail.tsx | 272 +++-- ui/src/pages/Routes/RouteDetail.module.css | 309 +++++- ui/src/pages/Routes/RouteDetail.tsx | 387 ++++++- ui/src/pages/Routes/RoutesMetrics.module.css | 99 +- ui/src/pages/Routes/RoutesMetrics.tsx | 442 +++++--- 23 files changed, 4439 insertions(+), 2542 deletions(-) create mode 100644 ui/src/pages/Admin/AuditLogPage.module.css diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 6d9abcd5..f3abaef2 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -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 ( setPaletteOpen(false)} onSelect={handlePaletteSelect} - data={[]} + data={searchData} />
diff --git a/ui/src/pages/Admin/AdminLayout.tsx b/ui/src/pages/Admin/AdminLayout.tsx index 07bce973..458a67db 100644 --- a/ui/src/pages/Admin/AdminLayout.tsx +++ b/ui/src/pages/Admin/AdminLayout.tsx @@ -20,7 +20,9 @@ export default function AdminLayout() { active={location.pathname} onChange={(path) => navigate(path)} /> - +
+ +
); } diff --git a/ui/src/pages/Admin/AuditLogPage.module.css b/ui/src/pages/Admin/AuditLogPage.module.css new file mode 100644 index 00000000..a0de051a --- /dev/null +++ b/ui/src/pages/Admin/AuditLogPage.module.css @@ -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); +} diff --git a/ui/src/pages/Admin/AuditLogPage.tsx b/ui/src/pages/Admin/AuditLogPage.tsx index 357f9d26..7813e438 100644 --- a/ui/src/pages/Admin/AuditLogPage.tsx +++ b/ui/src/pages/Admin/AuditLogPage.tsx @@ -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 & { id: string }; + +const COLUMNS: Column[] = [ + { + key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true, + render: (_, row) => {formatTimestamp(row.timestamp)}, + }, + { + key: 'username', header: 'User', sortable: true, + render: (_, row) => {row.username}, + }, + { + key: 'category', header: 'Category', width: '110px', sortable: true, + render: (_, row) => , + }, + { key: 'action', header: 'Action' }, + { + key: 'target', header: 'Target', + render: (_, row) => {row.target}, + }, + { + key: 'result', header: 'Result', width: '90px', sortable: true, + render: (_, row) => ( + + ), + }, +]; 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[] = [ - { key: 'timestamp', header: 'Time', sortable: true, render: (v) => new Date(v as string).toLocaleString() }, - { key: 'username', header: 'User', render: (v) => {String(v)} }, - { key: 'action', header: 'Action' }, - { key: 'category', header: 'Category', render: (v) => }, - { key: 'target', header: 'Target', render: (v) => v ? {String(v)} : null }, - { key: 'result', header: 'Result', render: (v) => }, - ]; - - 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 (
-

Audit Log

- -
- setSearch(e.target.value)} /> +
+ { setDateRange(range); setPage(0); }} + /> + { setUserFilter(e.target.value); setPage(0); }} + onClear={() => { setUserFilter(''); setPage(0); }} + className={styles.filterInput} + /> { setSearchFilter(e.target.value); setPage(0); }} + onClear={() => { setSearchFilter(''); setPage(0); }} + className={styles.filterInput} />
- ( -
- +
+
+ Audit Log +
+ + {totalCount} events + +
- )} - /> +
+ row.result === 'FAILURE' ? 'error' : undefined} + expandedContent={(row) => ( +
+
+
+ IP Address + {row.ipAddress} +
+
+ User Agent + {row.userAgent} +
+
+
+ Detail + +
+
+ )} + /> +
); } diff --git a/ui/src/pages/Admin/GroupsTab.tsx b/ui/src/pages/Admin/GroupsTab.tsx index c9b65bb6..88056eef 100644 --- a/ui/src/pages/Admin/GroupsTab.tsx +++ b/ui/src/pages/Admin/GroupsTab.tsx @@ -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(null); - const [showCreate, setShowCreate] = useState(false); - const [newGroupName, setNewGroupName] = useState(''); - const [newGroupParentId, setNewGroupParentId] = useState(''); - const [deleteOpen, setDeleteOpen] = useState(false); - const [addMemberUserId, setAddMemberUserId] = useState(''); - const [addRoleId, setAddRoleId] = useState(''); - 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(null); + const [creating, setCreating] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [removeRoleTarget, setRemoveRoleTarget] = useState(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 ; return ( -
- {/* Left pane */} -
-
- setSearch(e.target.value)} - onClear={() => setSearch('')} - /> - -
- - {showCreate && ( -
- setNewGroupName(e.target.value)} - /> -
- setNewName(e.target.value)} + /> + {duplicateGroupName && ( + + Group name already exists + + )} + ({ - value: u.userId, - label: u.displayName, - })), - ]} - value={addMemberUserId} - onChange={(e) => setAddMemberUserId(e.target.value)} - /> - -
- - {/* Assigned roles */} -
Assigned Roles
-
- {(selectedGroup.directRoles ?? []).map((role) => ( - handleRemoveRole(role.id)} - /> - ))} - {(selectedGroup.directRoles ?? []).length === 0 && ( - No roles assigned - )} -
- {(selectedGroup.effectiveRoles ?? []).length > - (selectedGroup.directRoles ?? []).length && ( -
- + - {(selectedGroup.effectiveRoles ?? []).length - - (selectedGroup.directRoles ?? []).length}{' '} - inherited role(s)
)} -
- setConfig({ ...config, issuerUri: e.target.value })} /> - setConfig({ ...config, clientId: e.target.value })} /> - setConfig({ ...config, clientSecret: e.target.value })} /> - setConfig({ ...config, rolesClaim: e.target.value })} /> - setConfig({ ...config, displayNameClaim: e.target.value })} /> - setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" /> +
+
+ + +
-
-

Default Roles

-
- {(config.defaultRoles || []).map(role => ( - { - setConfig(prev => ({ ...prev!, defaultRoles: prev!.defaultRoles.filter(r => r !== role) })); - }} /> - ))} -
-
- setNewRole(e.target.value)} /> - -
-
+ {error &&
{error}
} -
- - -
- - {error && {error}} - {success && Configuration saved} +
+ Behavior +
+ update('enabled', e.target.checked)} + />
- +
+ update('autoSignup', e.target.checked)} + /> + Automatically create accounts for new OIDC users +
+
- setDeleteOpen(false)} - onConfirm={handleDelete} - title="Delete OIDC Configuration" - message="Delete OIDC configuration? All OIDC users will lose access." - confirmText="DELETE" - /> +
+ Provider Settings + + update('issuerUri', e.target.value)} + /> + + + update('clientId', e.target.value)} + /> + + + update('clientSecret', e.target.value)} + /> + +
+ +
+ Claim Mapping + + update('rolesClaim', e.target.value)} + /> + + + update('displayNameClaim', e.target.value)} + /> + +
+ +
+ Default Roles +
+ {form.defaultRoles.map((role) => ( + removeRole(role)} /> + ))} + {form.defaultRoles.length === 0 && ( + No default roles configured + )} +
+
+ setNewRole(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }} + className={styles.roleInput} + /> + +
+
+ +
+ Danger Zone + + setDeleteOpen(false)} + onConfirm={handleDelete} + message="Delete OIDC configuration? All users signed in via OIDC will lose access." + confirmText="delete oidc" + /> +
); } diff --git a/ui/src/pages/Admin/RbacPage.tsx b/ui/src/pages/Admin/RbacPage.tsx index 9d4d2481..c4159d7d 100644 --- a/ui/src/pages/Admin/RbacPage.tsx +++ b/ui/src/pages/Admin/RbacPage.tsx @@ -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 (
-

User Management

- - {tab === 'users' && } - {tab === 'groups' && } - {tab === 'roles' && } + +
+ {tab === 'users' && } + {tab === 'groups' && } + {tab === 'roles' && } +
); } diff --git a/ui/src/pages/Admin/RolesTab.tsx b/ui/src/pages/Admin/RolesTab.tsx index 9202c214..b11be314 100644 --- a/ui/src/pages/Admin/RolesTab.tsx +++ b/ui/src/pages/Admin/RolesTab.tsx @@ -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(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(null); + const [creating, setCreating] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(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 ; + return ( -
- {/* Left pane — list */} -
-
- setSearch(e.target.value)} - /> - -
+ <> + + {creating && ( +
+ setNewName(e.target.value)} + /> + {duplicateRoleName && ( + + Role name already exists + + )} + setNewDesc(e.target.value)} + /> +
+ + +
+
+ )} - {showCreate && ( -
- setNewName(e.target.value.toUpperCase())} - style={{ marginBottom: 8 }} - /> - setNewDescription(e.target.value)} - /> -
- - -
-
- )} - - {isLoading ? ( - - ) : ( -
- {filtered.map((role) => { - const assignmentCount = - (role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0); - return ( -
setSelectedId(role.id)} - > + ( + <>
{role.name} - {role.system && } + {role.system && ( + + )}
- {role.description || '—'} · {assignmentCount} assignment - {assignmentCount !== 1 ? 's' : ''} + {role.description || '\u2014'} \u00b7{' '} + {getAssignmentCount(role)} assignments +
+
+ {(role.assignedGroups ?? []).map((g) => ( + + ))} + {(role.directUsers ?? []).map((u) => ( + + ))}
- {((role.assignedGroups?.length ?? 0) > 0 || - (role.directUsers?.length ?? 0) > 0) && ( -
- {(role.assignedGroups ?? []).map((g) => ( - - ))} - {(role.directUsers ?? []).map((u) => ( - - ))} -
- )}
-
- ); - })} -
- )} -
+ + )} + 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) ? ( + + ) : detail ? ( + setDeleteTarget(detail)} + /> + ) : null + } + emptyMessage="Select a role to view details" + /> - {/* Right pane — detail */} -
- {!selectedId ? ( -
Select a role to view details
- ) : detailLoading || !detail ? ( - - ) : ( - setConfirmDelete(true)} - /> - )} -
- - {detail && ( - 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} - /> - )} -
+ 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 ( -
- {/* Header */} + <>
- -
-
{role.name}
+ +
+
{role.name}
{role.description && ( -
- {role.description} -
+
{role.description}
)}
- + {!role.system && ( + + )}
- {/* Metadata */}
ID {role.id} - Scope - {role.scope || '—'} - - Type - {role.system ? 'System role (read-only)' : 'Custom role'} -
- - {/* Assigned to groups */} -
Assigned to groups
-
- {(role.assignedGroups ?? []).length === 0 ? ( - None - ) : ( - (role.assignedGroups ?? []).map((g) => ( - - )) + {role.scope || '\u2014'} + {role.system && ( + <> + Type + System role (read-only) + )}
- {/* Assigned to users (direct) */} -
Assigned to users (direct)
+ Assigned to groups
- {(role.directUsers ?? []).length === 0 ? ( - None - ) : ( - (role.directUsers ?? []).map((u) => ( - - )) + {assignedGroups.map((g) => ( + + ))} + {assignedGroups.length === 0 && ( + (none) )}
- {/* Effective principals */} -
Effective principals
+ Assigned to users (direct)
- {(role.effectivePrincipals ?? []).length === 0 ? ( - None - ) : ( - (role.effectivePrincipals ?? []).map((u) => { - const isDirect = directUserIds.has(u.userId); - return isDirect ? ( - - ) : ( - - ); - }) + {directUsers.map((u) => ( + + ))} + {directUsers.length === 0 && ( + (none) )}
- {(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && ( -
+ + Effective principals +
+ {effectivePrincipals.map((u) => { + const isDirect = directUserIds.has(u.userId); + return isDirect ? ( + + ) : ( + + ); + })} + {effectivePrincipals.length === 0 && ( + (none) + )} +
+ {effectivePrincipals.some((u) => !directUserIds.has(u.userId)) && ( + Dashed entries inherit this role through group membership -
+ )} -
+ ); } diff --git a/ui/src/pages/Admin/UserManagement.module.css b/ui/src/pages/Admin/UserManagement.module.css index 8613d45c..532dd110 100644 --- a/ui/src/pages/Admin/UserManagement.module.css +++ b/ui/src/pages/Admin/UserManagement.module.css @@ -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; } diff --git a/ui/src/pages/Admin/UsersTab.tsx b/ui/src/pages/Admin/UsersTab.tsx index 7ed9c8b9..c6b44703 100644 --- a/ui/src/pages/Admin/UsersTab.tsx +++ b/ui/src/pages/Admin/UsersTab.tsx @@ -5,17 +5,24 @@ import { Button, Input, MonoText, + SectionHeader, Tag, - InfoCallout, - ConfirmDialog, - Select, - Spinner, InlineEdit, + RadioGroup, + RadioItem, + InfoCallout, + MultiSelect, + ConfirmDialog, + AlertDialog, + SplitPane, + EntityList, + Spinner, useToast, } from '@cameleer/design-system'; import { useUsers, useCreateUser, + useUpdateUser, useDeleteUser, useAssignRoleToUser, useRemoveRoleFromUser, @@ -25,35 +32,37 @@ import { useGroups, useRoles, } from '../../api/queries/admin/rbac'; +import type { UserDetail } from '../../api/queries/admin/rbac'; import { useAuthStore } from '../../auth/auth-store'; import styles from './UserManagement.module.css'; export default function UsersTab() { + const { toast } = useToast(); const { data: users, isLoading } = useUsers(); const { data: allGroups } = useGroups(); const { data: allRoles } = useRoles(); const currentUsername = useAuthStore((s) => s.username); - const { toast } = useToast(); const [search, setSearch] = useState(''); - const [selectedUserId, setSelectedUserId] = useState(null); - const [showCreateForm, setShowCreateForm] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [creating, setCreating] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [removeGroupTarget, setRemoveGroupTarget] = useState(null); // Create form state - const [createUsername, setCreateUsername] = useState(''); - const [createDisplayName, setCreateDisplayName] = useState(''); - const [createEmail, setCreateEmail] = useState(''); - const [createPassword, setCreatePassword] = useState(''); - - // Detail pane state - const [showPasswordForm, setShowPasswordForm] = useState(false); + const [newUsername, setNewUsername] = useState(''); + const [newDisplay, setNewDisplay] = useState(''); + const [newEmail, setNewEmail] = useState(''); const [newPassword, setNewPassword] = useState(''); - const [addGroupId, setAddGroupId] = useState(''); - const [addRoleId, setAddRoleId] = useState(''); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [newProvider, setNewProvider] = useState<'local' | 'oidc'>('local'); + + // Password reset state + const [resettingPassword, setResettingPassword] = useState(false); + const [newPw, setNewPw] = useState(''); // Mutations const createUser = useCreateUser(); + const updateUser = useUpdateUser(); const deleteUser = useDeleteUser(); const assignRole = useAssignRoleToUser(); const removeRole = useRemoveRoleFromUser(); @@ -61,120 +70,37 @@ export default function UsersTab() { const removeFromGroup = useRemoveUserFromGroup(); const setPassword = useSetPassword(); - // Filtered user list - const filteredUsers = useMemo(() => { - if (!users) return []; + const userList = users ?? []; + + const filtered = useMemo(() => { + if (!search) return userList; const q = search.toLowerCase(); - if (!q) return users; - return users.filter( + return userList.filter( (u) => u.displayName.toLowerCase().includes(q) || (u.email ?? '').toLowerCase().includes(q) || u.userId.toLowerCase().includes(q), ); - }, [users, search]); + }, [userList, search]); - const selectedUser = useMemo( - () => users?.find((u) => u.userId === selectedUserId) ?? null, - [users, selectedUserId], - ); + const selected = userList.find((u) => u.userId === selectedId) ?? null; - // ── Handlers ────────────────────────────────────────────────────────── + const isSelf = + currentUsername != null && + selected != null && + selected.displayName === currentUsername; - function handleCreateUser() { - if (!createUsername.trim() || !createPassword.trim()) return; - createUser.mutate( - { - username: createUsername.trim(), - displayName: createDisplayName.trim() || undefined, - email: createEmail.trim() || undefined, - password: createPassword, - }, - { - onSuccess: () => { - toast({ title: 'User created', variant: 'success' }); - setShowCreateForm(false); - setCreateUsername(''); - setCreateDisplayName(''); - setCreateEmail(''); - setCreatePassword(''); - }, - onError: () => { - toast({ title: 'Failed to create user', variant: 'error' }); - }, - }, + const duplicateUsername = + newUsername.trim() !== '' && + userList.some( + (u) => u.displayName.toLowerCase() === newUsername.trim().toLowerCase(), ); - } - - function handleResetPassword() { - if (!selectedUser || !newPassword.trim()) return; - setPassword.mutate( - { userId: selectedUser.userId, password: newPassword }, - { - onSuccess: () => { - toast({ title: 'Password updated', variant: 'success' }); - setShowPasswordForm(false); - setNewPassword(''); - }, - onError: () => { - toast({ title: 'Failed to update password', variant: 'error' }); - }, - }, - ); - } - - function handleAddGroup() { - if (!selectedUser || !addGroupId) return; - addToGroup.mutate( - { userId: selectedUser.userId, groupId: addGroupId }, - { - onSuccess: () => { - toast({ title: 'Added to group', variant: 'success' }); - setAddGroupId(''); - }, - onError: () => { - toast({ title: 'Failed to add group', variant: 'error' }); - }, - }, - ); - } - - function handleAddRole() { - if (!selectedUser || !addRoleId) return; - assignRole.mutate( - { userId: selectedUser.userId, roleId: addRoleId }, - { - onSuccess: () => { - toast({ title: 'Role assigned', variant: 'success' }); - setAddRoleId(''); - }, - onError: () => { - toast({ title: 'Failed to assign role', variant: 'error' }); - }, - }, - ); - } - - function handleDeleteUser() { - if (!selectedUser) return; - deleteUser.mutate(selectedUser.userId, { - onSuccess: () => { - toast({ title: 'User deleted', variant: 'success' }); - setSelectedUserId(null); - setShowDeleteDialog(false); - }, - onError: () => { - toast({ title: 'Failed to delete user', variant: 'error' }); - setShowDeleteDialog(false); - }, - }); - } // Derived data for detail pane - const directGroupIds = new Set(selectedUser?.directGroups.map((g) => g.id) ?? []); - const directRoleIds = new Set(selectedUser?.directRoles.map((r) => r.id) ?? []); - - const inheritedRoles = selectedUser?.effectiveRoles.filter((r) => !directRoleIds.has(r.id)) ?? []; + const directGroupIds = new Set(selected?.directGroups.map((g) => g.id) ?? []); + const directRoleIds = new Set(selected?.directRoles.map((r) => r.id) ?? []); + const inheritedRoles = + selected?.effectiveRoles.filter((r) => !directRoleIds.has(r.id)) ?? []; const availableGroups = (allGroups ?? []) .filter((g) => !directGroupIds.has(g.id)) @@ -184,352 +110,490 @@ export default function UsersTab() { .filter((r) => !directRoleIds.has(r.id)) .map((r) => ({ value: r.id, label: r.name })); - // Find group name for inherited role display - function findInheritingGroupName(roleId: string): string { - if (!selectedUser) return ''; - for (const g of selectedUser.effectiveGroups) { - // We don't have group→roles in the summary, so just show "group" - void roleId; - return g.name; - } - return 'group'; + function handleCreate() { + if (!newUsername.trim()) return; + if (newProvider === 'local' && !newPassword.trim()) return; + createUser.mutate( + { + username: newUsername.trim(), + displayName: newDisplay.trim() || undefined, + email: newEmail.trim() || undefined, + password: newProvider === 'local' ? newPassword : undefined, + }, + { + onSuccess: () => { + toast({ + title: 'User created', + description: newDisplay.trim() || newUsername.trim(), + variant: 'success', + }); + setCreating(false); + setNewUsername(''); + setNewDisplay(''); + setNewEmail(''); + setNewPassword(''); + setNewProvider('local'); + }, + onError: () => { + toast({ title: 'Failed to create user', variant: 'error' }); + }, + }, + ); } - const isSelf = - currentUsername != null && - selectedUser != null && - selectedUser.displayName === currentUsername; + function handleDelete() { + if (!deleteTarget) return; + deleteUser.mutate(deleteTarget.userId, { + onSuccess: () => { + toast({ + title: 'User deleted', + description: deleteTarget.displayName, + variant: 'warning', + }); + if (selectedId === deleteTarget.userId) setSelectedId(null); + setDeleteTarget(null); + }, + onError: () => { + toast({ title: 'Failed to delete user', variant: 'error' }); + setDeleteTarget(null); + }, + }); + } - // ── Render ──────────────────────────────────────────────────────────── + function handleResetPassword() { + if (!selected || !newPw.trim()) return; + setPassword.mutate( + { userId: selected.userId, password: newPw }, + { + onSuccess: () => { + toast({ + title: 'Password updated', + description: selected.displayName, + variant: 'success', + }); + setResettingPassword(false); + setNewPw(''); + }, + onError: () => { + toast({ title: 'Failed to update password', variant: 'error' }); + }, + }, + ); + } + + function getUserGroupPath(user: UserDetail): string { + if (user.directGroups.length === 0) return 'no groups'; + return user.directGroups.map((g) => g.name).join(', '); + } + + if (isLoading) return ; return ( -
- {/* ── Left pane ── */} -
-
- setSearch(e.target.value)} - onClear={() => setSearch('')} - style={{ flex: 1 }} - /> - -
- - {showCreateForm && ( -
- setCreateUsername(e.target.value)} - style={{ marginBottom: 6 }} - /> - setCreateDisplayName(e.target.value)} - style={{ marginBottom: 6 }} - /> - setCreateEmail(e.target.value)} - style={{ marginBottom: 6 }} - /> - setCreatePassword(e.target.value)} - style={{ marginBottom: 6 }} - /> -
- - -
-
- )} - - {isLoading && } - -
- {filteredUsers.map((user) => ( -
setSelectedUserId(user.userId)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setSelectedUserId(user.userId); - } - }} - > - -
-
- {user.displayName} - {user.provider !== 'local' && ( - - )} -
-
- {user.email || user.userId} - {user.directGroups.length > 0 && ` · ${user.directGroups.map((g) => g.name).join(', ')}`} - {user.directGroups.length === 0 && ' · no groups'} -
- {(user.directRoles.length > 0 || user.directGroups.length > 0) && ( -
- {user.directRoles.map((r) => ( - - ))} - {user.directGroups.map((g) => ( - - ))} -
- )} -
-
- ))} -
-
- - {/* ── Right pane ── */} -
- {!selectedUser ? ( -
Select a user to view details
- ) : ( + <> + - {/* Header */} -
- -
- { - // useUpdateUser not imported here to keep things clean; - // display only — wired via displayName update if desired - void val; - }} - /> - {selectedUser.email && ( -
{selectedUser.email}
- )} -
- -
- - {/* Metadata grid */} -
- User ID - {selectedUser.userId} - - Created - {new Date(selectedUser.createdAt).toLocaleString()} - - Provider - {selectedUser.provider} -
- - {/* Security section */} -
-
Security
- {selectedUser.provider === 'local' ? ( - <> - {!showPasswordForm ? ( - - ) : ( -
- setNewPassword(e.target.value)} - style={{ flex: 1 }} - /> - - -
- )} - - ) : ( - - Password managed by identity provider - - )} -
- - {/* Group membership */} -
Group Membership
-
- {selectedUser.directGroups.map((g) => ( - - removeFromGroup.mutate( - { userId: selectedUser.userId, groupId: g.id }, - { - onError: () => - toast({ title: 'Failed to remove group', variant: 'error' }), - }, - ) - } - /> - ))} -
- {availableGroups.length > 0 && ( -
- setNewUsername(e.target.value)} /> - - ))} -
- {inheritedRoles.length > 0 && ( -
- Roles with ↑ are inherited through group membership -
- )} - {availableRoles.length > 0 && ( -
- setNewDisplay(e.target.value)} + /> +
+ {duplicateUsername && ( + + Username already exists + + )} + setNewEmail(e.target.value)} /> - + {newProvider === 'local' && ( + setNewPassword(e.target.value)} + /> + )} + {newProvider === 'oidc' && ( + + OIDC users authenticate via the configured identity provider. + Pre-register to assign roles/groups before their first login. + + )} +
+ + +
)} - {/* Delete confirmation */} - setShowDeleteDialog(false)} - onConfirm={handleDeleteUser} - title="Delete user" - message={`This will permanently delete the user "${selectedUser.displayName}". Type their username to confirm.`} - confirmText={selectedUser.displayName} - confirmLabel="Delete" - variant="danger" - loading={deleteUser.isPending} + ( + <> + +
+
+ {user.displayName} + {user.provider !== 'local' && ( + + )} +
+
+ {user.email || user.userId} ·{' '} + {getUserGroupPath(user)} +
+
+ {user.directRoles.map((r) => ( + + ))} + {user.directGroups.map((g) => ( + + ))} +
+
+ + )} + getItemId={(user) => user.userId} + selectedId={selectedId ?? undefined} + onSelect={(id) => { + setSelectedId(id); + setResettingPassword(false); + }} + searchPlaceholder="Search users..." + onSearch={setSearch} + addLabel="+ Add user" + onAdd={() => setCreating(true)} + emptyMessage="No users match your search" /> - )} -
-
+ } + detail={ + selected ? ( + <> +
+ +
+
+ + updateUser.mutate( + { userId: selected.userId, displayName: v }, + { + onSuccess: () => + toast({ + title: 'Display name updated', + variant: 'success', + }), + onError: () => + toast({ + title: 'Failed to update name', + variant: 'error', + }), + }, + ) + } + /> +
+
+ {selected.email || selected.userId} +
+
+ +
+ + Status +
+ +
+ +
+ ID + {selected.userId} + Created + + {new Date(selected.createdAt).toLocaleDateString()} + + Provider + {selected.provider} +
+ + Security +
+ {selected.provider === 'local' ? ( + <> +
+ Password + + •••••••• + + {!resettingPassword && ( + + )} +
+ {resettingPassword && ( +
+ setNewPw(e.target.value)} + className={styles.resetInput} + /> + + +
+ )} + + ) : ( + <> +
+ Authentication + + OIDC ({selected.provider}) + +
+ + Password managed by the identity provider. + + + )} +
+ + Group membership (direct only) +
+ {selected.directGroups.map((g) => ( + { + removeFromGroup.mutate( + { userId: selected.userId, groupId: g.id }, + { + onSuccess: () => + toast({ title: 'Group removed', variant: 'success' }), + onError: () => + toast({ + title: 'Failed to remove group', + variant: 'error', + }), + }, + ); + }} + /> + ))} + {selected.directGroups.length === 0 && ( + (no groups) + )} + { + for (const groupId of ids) { + addToGroup.mutate( + { userId: selected.userId, groupId }, + { + onSuccess: () => + toast({ title: 'Added to group', variant: 'success' }), + onError: () => + toast({ + title: 'Failed to add group', + variant: 'error', + }), + }, + ); + } + }} + placeholder="+ Add" + /> +
+ + + Effective roles (direct + inherited) + +
+ {selected.directRoles.map((r) => ( + { + removeRole.mutate( + { userId: selected.userId, roleId: r.id }, + { + onSuccess: () => + toast({ + title: 'Role removed', + description: r.name, + variant: 'success', + }), + onError: () => + toast({ + title: 'Failed to remove role', + variant: 'error', + }), + }, + ); + }} + /> + ))} + {inheritedRoles.map((r) => ( + + ))} + {selected.directRoles.length === 0 && + inheritedRoles.length === 0 && ( + (no roles) + )} + { + for (const roleId of roleIds) { + assignRole.mutate( + { userId: selected.userId, roleId }, + { + onSuccess: () => + toast({ + title: 'Role assigned', + variant: 'success', + }), + onError: () => + toast({ + title: 'Failed to assign role', + variant: 'error', + }), + }, + ); + } + }} + placeholder="+ Add" + /> +
+ {inheritedRoles.length > 0 && ( + + Roles with ↑ are inherited through group membership + + )} + + ) : null + } + emptyMessage="Select a user to view details" + /> + + setDeleteTarget(null)} + onConfirm={handleDelete} + message={`Delete user "${deleteTarget?.displayName}"? This cannot be undone.`} + confirmText={deleteTarget?.displayName ?? ''} + loading={deleteUser.isPending} + /> + setRemoveGroupTarget(null)} + onConfirm={() => { + if (removeGroupTarget && selected) { + removeFromGroup.mutate( + { userId: selected.userId, groupId: removeGroupTarget }, + { + onSuccess: () => + toast({ title: 'Group removed', variant: 'success' }), + onError: () => + toast({ + title: 'Failed to remove group', + variant: 'error', + }), + }, + ); + } + setRemoveGroupTarget(null); + }} + title="Remove group membership" + description="Removing this group may also revoke inherited roles. Continue?" + confirmLabel="Remove" + variant="warning" + /> + ); } diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index 0c7fa67f..53fbc3ed 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -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; } diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index dbe4f8fc..1e9a8cc8 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -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(); + 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 ( -
-
- - +
+
+ Status +
- -
-
-
Application
-
{agent.application ?? '—'}
-
-
-
Version
-
{agent.version ?? '—'}
-
-
-
Uptime
-
{formatUptime(agent.uptimeSeconds)}
-
-
-
Last Heartbeat
-
{formatRelativeTime(agent.lastHeartbeat)}
-
-
-
TPS
-
{agent.tps != null ? (agent.tps as number).toFixed(2) : '—'}
-
-
-
Error Rate
-
{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}
-
-
-
Routes
-
{agent.activeRoutes ?? '—'} active / {agent.totalRoutes ?? '—'} total
-
-
- -
-
- Heap Memory{heapUsed != null && heapMax != null - ? ` — ${Math.round(heapUsed / 1024 / 1024)}MB / ${Math.round(heapMax / 1024 / 1024)}MB` - : ''} -
- 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'} - indeterminate={heapPercent == null} - size="sm" - /> +
+ Application + {agent.application}
- -
-
- CPU Usage{cpuPercent != null ? ` — ${cpuPercent}%` : ''} +
+ Uptime + {formatUptime(agent.uptimeSeconds)} +
+
+ Last Seen + {timeAgo(agent.lastHeartbeat)} +
+
+ Throughput + {agent.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'} +
+
+ Errors + + {formatErrorRate(agent.errorRate)} + +
+
+ Routes + {agent.activeRoutes ?? 0}/{agent.totalRoutes ?? 0} active +
+
+ Heap Memory +
+ 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'} + indeterminate={heapPercent == null} + size="sm" + /> + {heapPercent != null ? `${heapPercent}%` : '\u2014'} +
+
+
+ CPU +
+ 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'} + indeterminate={cpuPercent == null} + size="sm" + /> + {cpuPercent != null ? `${cpuPercent}%` : '\u2014'}
- 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'} - indeterminate={cpuPercent == null} - size="sm" - />
); } -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 ( -
-
-
Throughput (TPS)
+
+
+
Throughput (msg/s)
{tpsSeries[0].data.length > 0 ? ( - + ) : (
No data available
)}
- -
-
Error Rate (%)
+
+
Error Rate (%)
{errSeries[0].data.length > 0 ? ( - + ) : (
No data available
)} @@ -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(null); + const [selectedInstance, setSelectedInstance] = useState(null); + const [panelOpen, setPanelOpen] = useState(false); - const agentsByApp = useMemo(() => { - const map: Record = {}; - (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[] = useMemo( + () => [ + { + key: 'status', + header: '', + width: '12px', + render: (_val, row) => , + }, + { + key: 'name', + header: 'Instance', + render: (_val, row) => ( + {row.name ?? row.id} + ), + }, + { + key: 'state', + header: 'State', + render: (_val, row) => { + const ns = normalizeStatus(row.status); + return ; + }, + }, + { + key: 'uptime', + header: 'Uptime', + render: (_val, row) => ( + {formatUptime(row.uptimeSeconds)} + ), + }, + { + key: 'tps', + header: 'TPS', + render: (_val, row) => ( + + {row.tps != null ? `${row.tps.toFixed(1)}/s` : '\u2014'} + + ), + }, + { + key: 'errorRate', + header: 'Errors', + render: (_val, row) => ( + + {formatErrorRate(row.errorRate)} + + ), + }, + { + key: 'lastHeartbeat', + header: 'Heartbeat', + render: (_val, row) => { + const ns = normalizeStatus(row.status); + return ( + + {timeAgo(row.lastHeartbeat)} + + ); + }, + }, + ], + [], + ); + + function handleInstanceClick(inst: AgentInstance) { + setSelectedInstance(inst); + setPanelOpen(true); + } + + // Detail panel tabs + const detailTabs = selectedInstance + ? [ + { + label: 'Overview', + value: 'overview', + content: , + }, + { + label: 'Performance', + value: 'performance', + content: , + }, + ] + : []; + + const isFullWidth = !!appId; return ( -
+
+ {/* Stat strip */}
0 ? 'warning' : 'amber'} detail={ - - {liveCount} live - {staleCount} stale - {deadCount} dead + + {liveCount} live + {staleCount} stale + {deadCount} dead } /> - - - - 0 ? 'error' : undefined} detail={deadCount > 0 ? 'requires attention' : undefined} /> -
- -
- {liveCount}/{(agents || []).length} live -
- -
- {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 ( - a.status === 'DEAD') ? 'error' - : groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning' - : 'success' - } - variant="filled" - /> - } - meta={ -
- {groupTps.toFixed(1)} msg/s - {groupActiveRoutes}/{groupTotalRoutes} routes -
- } - accent={ - groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error' - : groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning' - : 'success' + + + {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy + + + {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded + + + {groups.filter((g) => g.deadCount > 0).length} critical + + + } + /> + - {deadInGroup.length > 0 && ( - {deadInGroup.length} instance(s) unreachable - )} - - - - - - - - - - - - - {(groupAgents || []).map((agent: any) => ( - { - setSelectedAgent(agent); - navigate(`/agents/${group}/${agent.id}`); - }} - > - - - - - - - - - ))} - -
- InstanceStateUptimeTPSErrorsHeartbeat
- - - {agent.name ?? agent.id} - - - - {formatUptime(agent.uptimeSeconds)} - - {agent.tps != null ? `${(agent.tps as number).toFixed(1)}/s` : '—'} - - - {agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'} - - - - {formatRelativeTime(agent.lastHeartbeat)} - -
-
- ); - })} + {totalActiveRoutes}/{totalRoutes} + + } + accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'} + detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'} + /> + + 0 ? 'error' : 'success'} + detail={deadCount > 0 ? 'requires attention' : 'all healthy'} + />
+ {/* Scope trail + badges */} +
+ {appId && ( + <> + All Agents + + {appId} + + )} + 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'} + variant="filled" + /> +
+ + {/* Group cards grid */} +
+ {groups.map((group) => ( + + } + meta={ +
+ {group.totalTps.toFixed(1)} msg/s + {group.totalActiveRoutes}/{group.totalRoutes} routes + + + +
+ } + footer={ + group.deadCount > 0 ? ( +
+ + + Single point of failure —{' '} + {group.deadCount === group.instances.length + ? 'no redundancy' + : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`} + +
+ ) : undefined + } + > + + columns={instanceColumns} + data={group.instances} + onRowClick={handleInstanceClick} + selectedId={panelOpen ? selectedInstance?.id : undefined} + pageSize={50} + flush + /> +
+ ))} +
+ + {/* EventFeed */} {feedEvents.length > 0 && (
- Timeline - + Timeline + {feedEvents.length} events
)} - {selectedAgent && ( + {/* Detail panel */} + {selectedInstance && ( setSelectedAgent(null)} - className={styles.detailPanelOverride} - > - -
- - + open={panelOpen} + onClose={() => { + setPanelOpen(false); + setSelectedInstance(null); + }} + title={selectedInstance.name ?? selectedInstance.id} + tabs={detailTabs} + /> )}
); diff --git a/ui/src/pages/AgentInstance/AgentInstance.module.css b/ui/src/pages/AgentInstance/AgentInstance.module.css index d98a791b..74ea6911 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.module.css +++ b/ui/src/pages/AgentInstance/AgentInstance.module.css @@ -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); } diff --git a/ui/src/pages/AgentInstance/AgentInstance.tsx b/ui/src/pages/AgentInstance/AgentInstance.tsx index 3578269a..bd609ce1 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.tsx +++ b/ui/src/pages/AgentInstance/AgentInstance.tsx @@ -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( + () => + (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(() => [], []); + const filteredLogs = + logFilter === 'all' ? logEntries : logEntries.filter((l) => l.level === logFilter); + if (isLoading) return ; - return ( -
- + 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 ( +
+ {/* Stat strip — 5 columns */} +
+ 85 + ? 'error' + : Number(cpuDisplay) > 70 + ? 'warning' + : 'success' + : undefined + } + /> + 85 + ? 'error' + : memPct > 70 + ? 'warning' + : 'success' + : undefined + } + detail={ + heapUsedMB != null && heapMaxMB != null + ? `${heapUsedMB} MB / ${heapMaxMB} MB` + : undefined + } + /> + + 0 ? 'error' : 'success'} + /> + +
+ + {/* Scope trail + badges */} {agent && ( <> -
- -

{agent.name}

- - {agent.version && } -
- -
- - - - 0 ? 'error' : undefined} /> - -
-
- All Agents + + All Agents + - {appId} + + {appId} + {agent.name} - - {agent.version && } + + + {agent.version && }
- -
Process Information
-
- {agent?.capabilities?.jvmVersion && ( -
- JVM - {agent.capabilities.jvmVersion} -
+ {/* Process info card */} +
+ Process Information +
+ {agent.capabilities?.jvmVersion && ( + <> + JVM + {agent.capabilities.jvmVersion} + )} - {agent?.capabilities?.camelVersion && ( -
- Camel - {agent.capabilities.camelVersion} -
+ {agent.capabilities?.camelVersion && ( + <> + Camel + {agent.capabilities.camelVersion} + )} - {agent?.capabilities?.springBootVersion && ( -
- Spring Boot - {agent.capabilities.springBootVersion} -
+ {agent.capabilities?.springBootVersion && ( + <> + Spring Boot + {agent.capabilities.springBootVersion} + + )} + Started + + {agent.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '\u2014'} + + {agent.capabilities && ( + <> + Capabilities + + {Object.entries(agent.capabilities) + .filter(([, v]) => typeof v === 'boolean' && v) + .map(([k]) => ( + + ))} + + )} -
- Started - {agent?.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '—'} -
-
- Capabilities - - {Object.entries(agent?.capabilities || {}) - .filter(([, v]) => typeof v === 'boolean' && v) - .map(([k]) => ( - - ))} - -
- - -
Routes
-
- {(agent.routeIds || []).map((r: string) => ( - - ))}
+ + {/* Routes */} + {(agent.routeIds?.length ?? 0) > 0 && ( + <> + Routes +
+ {(agent.routeIds || []).map((r: string) => ( + + ))} +
+ + )} )} + {/* Charts grid — 3x2 */}
-
CPU Usage
-
{cpuPct != null ? `${(cpuPct * 100).toFixed(0)}% current` : ''}
+ CPU Usage + + {cpuDisplay != null ? `${cpuDisplay}% current` : ''} +
- {cpuSeries - ? - : } + {cpuSeries ? ( + + ) : ( + + )}
+
-
Memory (Heap)
-
{heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : ''}
+ Memory (Heap) + + {heapUsedMB != null && heapMaxMB != null + ? `${heapUsedMB} MB / ${heapMaxMB} MB` + : ''} +
- {heapSeries - ? - : } + {heapSeries ? ( + + ) : ( + + )}
+
-
Throughput
-
{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}
+ Throughput + + {agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''} +
- {throughputSeries - ? - : } + {throughputSeries ? ( + + ) : ( + + )}
+
-
Error Rate
-
{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}
+ Error Rate + + {agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''} +
- {errorSeries - ? - : } + {errorSeries ? ( + + ) : ( + + )}
+
-
Thread Count
- {threadSeries &&
{threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active
} + Thread Count + + {threadSeries + ? `${threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active` + : ''} +
- {threadSeries - ? - : } + {threadSeries ? ( + + ) : ( + + )}
+
-
GC Pauses
+ GC Pauses +
- {gcSeries - ? - : } + {gcSeries ? ( + + ) : ( + + )}
-
- - -
-
- Timeline - {feedEvents.length} events + {/* Log + Timeline side by side */} +
+
+
+ Application Log +
- {feedEvents.length > 0 - ? - :
No events in the selected time range.
} + {filteredLogs.length > 0 ? ( + + ) : ( +
+ Application log streaming is not yet available. +
+ )} +
+ +
+
+ Timeline + {feedEvents.length} events +
+ {feedEvents.length > 0 ? ( + + ) : ( +
No events in the selected time range.
+ )}
-
); } 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); diff --git a/ui/src/pages/Dashboard/Dashboard.module.css b/ui/src/pages/Dashboard/Dashboard.module.css index df225b34..2272aba6 100644 --- a/ui/src/pages/Dashboard/Dashboard.module.css +++ b/ui/src/pages/Dashboard/Dashboard.module.css @@ -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; } diff --git a/ui/src/pages/Dashboard/Dashboard.tsx b/ui/src/pages/Dashboard/Dashboard.tsx index 0d156952..b09e2b21 100644 --- a/ui/src/pages/Dashboard/Dashboard.tsx +++ b/ui/src/pages/Dashboard/Dashboard.tsx @@ -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(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[] = [ +function buildBaseColumns(): Column[] { + return [ { - key: 'status', header: 'Status', width: '80px', - render: (v, row) => ( - - - {v === 'COMPLETED' ? 'OK' : v === 'FAILED' ? 'ERR' : 'RUN'} + key: 'status', + header: 'Status', + width: '80px', + render: (_: unknown, row: Row) => ( + + + {statusLabel(row.status)} ), }, { - key: '_inspect' as any, header: '', width: '36px', - render: (_v, row) => ( - { e.stopPropagation(); e.preventDefault(); navigate(`/exchanges/${row.executionId}`); }} - className={styles.inspectLink} - title="Open full details" - >↗ + key: 'routeId', + header: 'Route', + sortable: true, + render: (_: unknown, row: Row) => ( + {row.routeId} ), }, - { key: 'routeId', header: 'Route', sortable: true, render: (v) => {String(v)} }, - { key: 'applicationName', header: 'Application', sortable: true, render: (v) => {String(v ?? '')} }, - { key: 'executionId', header: 'Exchange ID', sortable: true, render: (v) => {String(v)} }, - { key: 'startTime', header: 'Started', sortable: true, render: (v) => {new Date(v as string).toLocaleString()} }, { - key: 'durationMs', header: 'Duration', sortable: true, - render: (v) => {formatDuration(v as number)}, + key: 'applicationName', + header: 'Application', + sortable: true, + render: (_: unknown, row: Row) => ( + {row.applicationName ?? ''} + ), }, { - key: 'agentId', header: 'Agent', - render: (v) => v ? : null, + key: 'executionId', + header: 'Exchange ID', + sortable: true, + render: (_: unknown, row: Row) => ( + {row.executionId} + ), }, - ]; + { + key: 'startTime', + header: 'Started', + sortable: true, + render: (_: unknown, row: Row) => ( + {formatTimestamp(row.startTime)} + ), + }, + { + key: 'durationMs', + header: 'Duration', + sortable: true, + render: (_: unknown, row: Row) => ( + + {formatDuration(row.durationMs)} + + ), + }, + { + key: 'agentId', + header: 'Agent', + render: (_: unknown, row: Row) => ( + + + {row.agentId} + + ), + }, + ] +} - 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() + 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[] = useMemo(() => { + const inspectCol: Column = { + key: 'correlationId', + header: '', + width: '36px', + render: (_: unknown, row: Row) => ( + + ), + } + 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 ( -
-
- 0 ? 'up' : exchangeTrend < 0 ? 'down' : 'neutral'} - trendValue={exchangeTrend > 0 ? `+${exchangeTrend.toFixed(0)}%` : `${exchangeTrend.toFixed(0)}%`} - sparkline={sparkExchanges} - accent="amber" - /> - = 0 ? 'up' : 'down'} - trendValue={`${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`} - accent="success" - /> - 0 ? 'up' : errorDelta < 0 ? 'down' : 'neutral'} - trendValue={errorDelta > 0 ? `+${errorDelta}` : `${errorDelta}`} - sparkline={sparkErrors} - accent="error" - /> - - -
+ <> + {/* Scrollable content */} +
+ {/* KPI strip */} + -
-
- Recent Exchanges -
- {rows.length} of {searchResult?.total ?? 0} exchanges - + {/* Exchanges table */} +
+
+ Recent Exchanges +
+ + {rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges + + +
+ + + row.errorMessage ? ( +
+ {'\u26A0'} +
+
{row.errorMessage}
+
Click to view full stack trace
+
+
+ ) : null + } + />
- { setSelectedId(row.id); }} - selectedId={selectedId ?? undefined} - sortable - pageSize={25} - />
- {selectedId && detail && ( + {/* Shortcuts bar */} + + + {/* Detail panel */} + {selectedRow && detail && ( 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 */}
{/* Errors */} - {detail.errorMessage && ( + {errorMsg && (
Errors
- - {detail.errorMessage.split(':')[0]} -
{detail.errorMessage.includes(':') ? detail.errorMessage.substring(detail.errorMessage.indexOf(':') + 1).trim() : ''}
-
- {detail.errorStackTrace && ( - - - - )} +
+
{errorClass}
+
{errorMsg}
+
)} {/* Route Flow */}
Route Flow
- {diagram ? ( - {}} - /> - ) :
No diagram available
} + {routeNodes.length > 0 ? ( + + ) : ( +
No diagram available
+ )}
{/* Processor Timeline */} @@ -257,33 +482,17 @@ export default function Dashboard() { Processor Timeline {formatDuration(detail.durationMs)}
- {procList.length ? ( + {flatProcs.length > 0 ? ( - ) :
No processor data
} + ) : ( +
No processor data
+ )}
)} -
- ); -} - -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; + + ) } diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css b/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css index 51cc83df..1928b04d 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css @@ -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; +} diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx index f03b2e69..274004d1 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx @@ -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 { - 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 = {}; + const result: Record = {} 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(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(null) + const activeIndex = selectedProcessorIndex ?? defaultIndex - if (isLoading) return
; - if (!detail) return Exchange not found; + 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 ( +
+ +
+ ) + } + + // ── Not found state ────────────────────────────────────────────────────── + if (!detail) { + return ( +
+ + Exchange "{id}" not found. +
+ ) + } + + const statusVariant = backendStatusToVariant(detail.status) + const statusLabel = backendStatusToLabel(detail.status) return ( -
+
+ + {/* Breadcrumb */} {/* Exchange header card */}
- +
- {id} - + {detail.executionId} +
Route: navigate(`/apps/${detail.applicationName}/${detail.routeId}`)}>{detail.routeId} @@ -116,6 +191,12 @@ export default function ExchangeDetail() { App: {detail.applicationName} )} + {detail.correlationId && ( + <> + · + Correlation: {detail.correlationId} + + )}
@@ -131,7 +212,9 @@ export default function ExchangeDetail() {
Started
- {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'}
@@ -142,43 +225,39 @@ export default function ExchangeDetail() {
{/* Correlation Chain */} - {correlationData?.data && correlationData.data.length > 1 && ( + {correlatedExchanges.length > 1 && (
Correlated Exchanges - {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 ( - ); + ) })} - {correlationData.total > 20 && ( + {correlationData && correlationData.total > 20 && ( +{correlationData.total - 20} more )}
)}
- {/* Error callout */} - {detail.errorMessage && ( - - {detail.errorMessage} - - )} - - {/* Processor Timeline / Flow Section */} + {/* Processor Timeline Section */}
@@ -206,17 +285,17 @@ export default function ExchangeDetail() { setSelectedProcessorIndex(i)} + onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)} selectedIndex={activeIndex} /> ) : ( No processor data available ) ) : ( - diagram ? ( + routeNodes.length > 0 ? ( setSelectedProcessorIndex(i)} + nodes={routeNodes} + onNodeClick={(_node, index) => setSelectedProcessorIndex(index)} selectedIndex={activeIndex} /> ) : ( @@ -226,7 +305,7 @@ export default function ExchangeDetail() {
- {/* Processor Detail: Message IN / Message OUT or Error */} + {/* Processor Detail Panel (split IN / OUT) */} {selectedProc && snapshot && (
{/* Message IN */} @@ -255,7 +334,7 @@ export default function ExchangeDetail() { )}
Body
- +
@@ -309,7 +388,7 @@ export default function ExchangeDetail() { )}
Body
- +
@@ -317,12 +396,13 @@ export default function ExchangeDetail() {
)} - {/* No snapshot loaded yet - show prompt */} + {/* Snapshot loading indicator */} {selectedProc && !snapshot && procList.length > 0 && ( -
+
Loading exchange snapshot...
)} +
- ); + ) } diff --git a/ui/src/pages/Routes/RouteDetail.module.css b/ui/src/pages/Routes/RouteDetail.module.css index 69f75003..943834de 100644 --- a/ui/src/pages/Routes/RouteDetail.module.css +++ b/ui/src/pages/Routes/RouteDetail.module.css @@ -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); } diff --git a/ui/src/pages/Routes/RouteDetail.tsx b/ui/src/pages/Routes/RouteDetail.tsx index 2f23e9a6..3572a07d 100644 --- a/ui/src/pages/Routes/RouteDetail.tsx +++ b/ui/src/pages/Routes/RouteDetail.tsx @@ -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 = { + 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[] { + return [ + { + key: 'processorId', + header: 'Processor', + sortable: true, + render: (_, row) => ( + {row.processorId} + ), + }, + { + key: 'callCount', + header: 'Invocations', + sortable: true, + render: (_, row) => ( + {row.callCount.toLocaleString()} + ), + }, + { + key: 'avgDurationMs', + header: 'Avg Duration', + sortable: true, + render: (_, row) => { + const cls = row.avgDurationMs > 200 ? css.rateBad : row.avgDurationMs > 100 ? css.rateWarn : css.rateGood; + return {Math.round(row.avgDurationMs)}ms; + }, + }, + { + key: 'p99DurationMs', + header: 'p99 Duration', + sortable: true, + render: (_, row) => { + const cls = row.p99DurationMs > 300 ? css.rateBad : row.p99DurationMs > 200 ? css.rateWarn : css.rateGood; + return {Math.round(row.p99DurationMs)}ms; + }, + }, + { + key: 'errorCount', + header: 'Errors', + sortable: true, + render: (_, row) => ( + 10 ? css.rateBad : css.rateNeutral}> + {row.errorCount} + + ), + }, + { + 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 {row.errorRate.toFixed(2)}%; + }, + }, + { + key: 'sparkline', + header: 'Trend', + render: (_, row) => ( + + ), + }, + ]; +} + +// ── Exchange table columns ─────────────────────────────────────────────────── + +const EXCHANGE_COLUMNS: Column[] = [ + { + key: 'status', + header: 'Status', + width: '80px', + render: (_, row) => ( + + ), + }, + { + key: 'executionId', + header: 'Exchange ID', + render: (_, row) => {row.executionId.slice(0, 12)}, + }, + { + 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(); @@ -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[] = [ - { key: 'processorId', header: 'Processor', render: (v) => {String(v)} }, - { 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 ? {n} : 0; - }}, - ]; + // KPI items + const kpiItems = useMemo(() => + buildDetailKpiItems(stats, throughputSparkline, errorSparkline, latencySparkline), + [stats, throughputSparkline, errorSparkline, latencySparkline], + ); - const exchangeColumns: Column[] = [ - { - key: 'status', header: 'Status', width: '80px', - render: (v) => , - }, - { key: 'executionId', header: 'Exchange ID', render: (v) => {String(v).slice(0, 12)} }, - { 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 (
- ← {appId} routes + ← {appId} routes + {/* Route header card */}
@@ -199,13 +442,17 @@ export default function RouteDetail() {
+ {/* KPI strip */} + + + {/* Diagram + Processor Stats grid */}
Route Diagram
{diagramNodes.length > 0 ? ( ) : ( -
+
No diagram available for this route.
)} @@ -217,13 +464,40 @@ export default function RouteDetail() { ) : processorRows.length > 0 ? ( ) : ( -
+
No processor data available.
)}
+ {/* Processor Performance table (full width) */} +
+
+ Processor Performance +
+ {processorRows.length} processors + +
+
+ +
+ + {/* Route Flow section */} + {diagramNodes.length > 0 && ( +
+
+ Route Flow +
+ +
+ )} + + {/* Tabbed section: Performance charts, Recent Executions, Error Patterns */}
@@ -232,28 +506,41 @@ export default function RouteDetail() {
Throughput
({ x: i, y: d.throughput })) }]} + series={[{ + label: 'Throughput', + data: chartData.map((d, i) => ({ x: i, y: d.throughput })), + }]} height={200} />
Latency
({ 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' }} />
Errors
({ x: d.time, y: d.errors })) }]} + series={[{ + label: 'Errors', + data: chartData.map((d) => ({ x: d.time, y: d.errors })), + }]} height={200} />
Success Rate
({ x: i, y: d.successRate })) }]} + series={[{ + label: 'Success Rate', + data: chartData.map((d, i) => ({ x: i, y: d.successRate })), + }]} height={200} />
@@ -268,7 +555,7 @@ export default function RouteDetail() {
) : ( navigate(`/exchanges/${row.executionId}`)} sortable @@ -281,7 +568,7 @@ export default function RouteDetail() { {activeTab === 'errors' && (
{errorPatterns.length === 0 ? ( -
+
No error patterns found in the selected time range.
) : ( diff --git a/ui/src/pages/Routes/RoutesMetrics.module.css b/ui/src/pages/Routes/RoutesMetrics.module.css index 27298703..b68e29d5 100644 --- a/ui/src/pages/Routes/RoutesMetrics.module.css +++ b/ui/src/pages/Routes/RoutesMetrics.module.css @@ -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); } diff --git a/ui/src/pages/Routes/RoutesMetrics.tsx b/ui/src/pages/Routes/RoutesMetrics.tsx index 6aa568a1..cfbe8022 100644 --- a/ui/src/pages/Routes/RoutesMetrics.tsx +++ b/ui/src/pages/Routes/RoutesMetrics.tsx @@ -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[] = [ + { + key: 'routeId', + header: 'Route', + sortable: true, + render: (_, row) => ( + {row.routeId} + ), + }, + { + key: 'appId', + header: 'Application', + sortable: true, + render: (_, row) => ( + {row.appId} + ), + }, + { + key: 'exchangeCount', + header: 'Exchanges', + sortable: true, + render: (_, row) => ( + {row.exchangeCount.toLocaleString()} + ), + }, + { + 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 {pct.toFixed(1)}%; + }, + }, + { + key: 'avgDurationMs', + header: 'Avg Duration', + sortable: true, + render: (_, row) => ( + {Math.round(row.avgDurationMs)}ms + ), + }, + { + key: 'p99DurationMs', + header: 'p99 Duration', + sortable: true, + render: (_, row) => { + const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood; + return {Math.round(row.p99DurationMs)}ms; + }, + }, + { + 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 {pct.toFixed(1)}%; + }, + }, + { + key: 'sparkline', + header: 'Trend', + render: (_, row) => ( + + ), + }, +]; + +// ── 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[] = [ - { key: 'routeId', header: 'Route', render: (v) => {String(v)} }, - { key: 'appId', header: 'App', render: (v) => }, - { 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 {(rate * 100).toFixed(1)}%; - }, - }, - { - key: 'sparkline', header: 'Trend', width: '80px', - render: (v) => , - }, - ]; - - 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 ( -
-
- - - 300 ? 'error' : p99Ms > 200 ? 'warning' : 'success'} - sparkline={latencySparkline} - /> - { - const failed = errorSparkline[i] ?? 0; - return v > 0 ? ((v - failed) / v) * 100 : 100; - })} - /> - +
+
+ + Auto-refresh: 30s
+ {/* KPI header cards */} + + + {/* Per-route performance table */}
Per-Route Performance - {rows.length} routes +
+ {rows.length} routes + +
{ + const targetAppId = appId ?? row.appId; + navigate(`/routes/${targetAppId}/${row.routeId}`); + }} />
- {chartData.length > 0 && ( + {/* 2x2 chart grid */} + {(timeseries?.buckets?.length ?? 0) > 0 && (
-
-
Throughput (msg/s)
- ({ x: i, y: d.throughput })) }]} yLabel="msg/s" height={200} /> -
-
-
Latency (ms)
- ({ x: i, y: d.latency })) }]} - yLabel="ms" + + -
-
-
Errors by Route
- ({ x: d.time as string, y: d.errors })) }]} height={200} /> -
-
-
Message Volume (msg/min)
- ({ x: i, y: d.throughput })) }]} yLabel="msg/min" height={200} /> -
+ + + + + + + + + + + + +
)}