diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index 1e060d06..98041c8d 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -428,6 +428,42 @@ color: var(--text-primary); } +/* Sort buttons */ +.sortGroup { + display: flex; + align-items: center; + gap: 2px; + margin-left: auto; +} + +.sortBtn { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 4px 8px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 11px; + font-family: var(--font-body); + cursor: pointer; + transition: color 150ms ease, border-color 150ms ease; + white-space: nowrap; +} + +.sortBtn:hover { + color: var(--text-primary); + border-color: var(--border-subtle); +} + +.sortBtnActive { + color: var(--text-primary); + border-color: var(--border-subtle); + background: var(--bg-raised); + font-weight: 600; +} + /* View mode toggle */ .viewToggle { display: flex; diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index afb3511e..103c6fb5 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router'; -import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown } from 'lucide-react'; +import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown, ArrowUp, ArrowDown } from 'lucide-react'; import { StatCard, StatusDot, Badge, MonoText, GroupCard, DataTable, EventFeed, @@ -285,6 +285,19 @@ export default function AgentHealth() { const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo, selectedEnv); const [appFilter, setAppFilter] = useState(''); + type AppSortKey = 'status' | 'name' | 'tps' | 'cpu' | 'heartbeat'; + const [appSortKey, setAppSortKey] = useState('name'); + const [appSortAsc, setAppSortAsc] = useState(true); + + const cycleSort = useCallback((key: AppSortKey) => { + if (appSortKey === key) { + setAppSortAsc((prev) => !prev); + } else { + setAppSortKey(key); + setAppSortAsc(key === 'name'); // name defaults asc, others desc + } + }, [appSortKey]); + const [logSearch, setLogSearch] = useState(''); const [logLevels, setLogLevels] = useState>(new Set()); const [logSource, setLogSource] = useState(''); // '' = all, 'app', 'agent' @@ -306,9 +319,34 @@ export default function AgentHealth() { const agentList = agents ?? []; - const allGroups = useMemo(() => groupByApp(agentList).sort((a, b) => a.appId.localeCompare(b.appId)), [agentList]); + const allGroups = useMemo(() => groupByApp(agentList), [agentList]); + + const sortedGroups = useMemo(() => { + const healthPriority = (g: AppGroup) => g.deadCount > 0 ? 0 : g.staleCount > 0 ? 1 : 2; + const nameCmp = (a: AppGroup, b: AppGroup) => a.appId.localeCompare(b.appId); + + const sorted = [...allGroups].sort((a, b) => { + let cmp = 0; + switch (appSortKey) { + case 'status': cmp = healthPriority(a) - healthPriority(b); break; + case 'name': cmp = a.appId.localeCompare(b.appId); break; + case 'tps': cmp = a.totalTps - b.totalTps; break; + case 'cpu': cmp = a.maxCpu - b.maxCpu; break; + case 'heartbeat': { + const ha = latestHeartbeat(a) ?? ''; + const hb = latestHeartbeat(b) ?? ''; + cmp = ha < hb ? -1 : ha > hb ? 1 : 0; + break; + } + } + if (!appSortAsc) cmp = -cmp; + return cmp !== 0 ? cmp : nameCmp(a, b); + }); + return sorted; + }, [allGroups, appSortKey, appSortAsc]); + const appFilterLower = appFilter.toLowerCase(); - const groups = appFilterLower ? allGroups.filter((g) => g.appId.toLowerCase().includes(appFilterLower)) : allGroups; + const groups = appFilterLower ? sortedGroups.filter((g) => g.appId.toLowerCase().includes(appFilterLower)) : sortedGroups; // Aggregate stats const totalInstances = agentList.length; @@ -673,7 +711,7 @@ export default function AgentHealth() { setAppFilter(e.target.value)} aria-label="Filter applications" @@ -689,6 +727,21 @@ export default function AgentHealth() { )} +
+ {([['status', 'Status'], ['name', 'Name'], ['tps', 'TPS'], ['cpu', 'CPU'], ['heartbeat', 'Heartbeat']] as const).map(([key, label]) => ( + + ))} +
)}