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} /> -
+ + + + + + + + + + + + +
)}