From fb7d6db37596ba770e27c9f1fe675f3419e3fc8f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:46:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20AgentHealth=20=E2=80=94=20server-si?= =?UTF-8?q?de=20multi-select=20filters=20+=20infinite=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Application Log: source + level filters move server-side; text search stays client-side. Timeline: cursor-paginated via useInfiniteAgentEvents. Both wrapped in InfiniteScrollArea with top-gated auto-refetch. --- ui/src/pages/AgentHealth/AgentHealth.tsx | 110 ++++++++++++++++------- 1 file changed, 77 insertions(+), 33 deletions(-) diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 99f3a1c0..a93035d5 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -11,8 +11,10 @@ import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/des import styles from './AgentHealth.module.css'; import sectionStyles from '../../styles/section-card.module.css'; import logStyles from '../../styles/log-panel.module.css'; -import { useAgents, useAgentEvents } from '../../api/queries/agents'; -import { useApplicationLogs } from '../../api/queries/logs'; +import { useAgents } from '../../api/queries/agents'; +import { useInfiniteApplicationLogs } from '../../api/queries/logs'; +import { useInfiniteAgentEvents } from '../../api/queries/agents'; +import { InfiniteScrollArea } from '../../components/InfiniteScrollArea'; import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; import { useCatalog, useDismissApp } from '../../api/queries/catalog'; import { useIsAdmin } from '../../auth/auth-store'; @@ -281,8 +283,9 @@ export default function AgentHealth() { }); }, [appConfig, configDraft, updateConfig, toast, appId]); const [eventSortAsc, setEventSortAsc] = useState(false); - const [eventRefreshTo, setEventRefreshTo] = useState(); - const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo); + const [isTimelineAtTop, setIsTimelineAtTop] = useState(true); + const timelineScrollRef = useRef(null); + const eventStream = useInfiniteAgentEvents({ appId, isAtTop: isTimelineAtTop }); const [appFilter, setAppFilter] = useState(''); type AppSortKey = 'status' | 'name' | 'tps' | 'cpu' | 'heartbeat'; @@ -300,22 +303,30 @@ export default function AgentHealth() { const [logSearch, setLogSearch] = useState(''); const [logLevels, setLogLevels] = useState>(new Set()); - const [logSource, setLogSource] = useState(''); // '' = all, 'app', 'agent' + const [logSources, setLogSources] = useState>(new Set()); const [logSortAsc, setLogSortAsc] = useState(false); - const [logRefreshTo, setLogRefreshTo] = useState(); - const { data: rawLogs } = useApplicationLogs(appId, undefined, { toOverride: logRefreshTo, source: logSource || undefined }); + const [isLogAtTop, setIsLogAtTop] = useState(true); + const logScrollRef = useRef(null); + + const logStream = useInfiniteApplicationLogs({ + application: appId, + sources: [...logSources], + levels: [...logLevels], + isAtTop: isLogAtTop, + }); const logEntries = useMemo(() => { - const mapped = (rawLogs || []).map((l) => ({ + const mapped = logStream.items.map((l) => ({ timestamp: l.timestamp ?? '', level: mapLogLevel(l.level), message: l.message ?? '', + source: l.source ?? undefined, })); return logSortAsc ? mapped.toReversed() : mapped; - }, [rawLogs, logSortAsc]); + }, [logStream.items, logSortAsc]); const logSearchLower = logSearch.toLowerCase(); - const filteredLogs = logEntries - .filter((l) => logLevels.size === 0 || logLevels.has(l.level)) - .filter((l) => !logSearchLower || l.message.toLowerCase().includes(logSearchLower)); + const filteredLogs = logSearchLower + ? logEntries.filter((l) => l.message.toLowerCase().includes(logSearchLower)) + : logEntries; const agentList = agents ?? []; @@ -359,15 +370,15 @@ export default function AgentHealth() { // Map events to FeedEvent const feedEvents: FeedEvent[] = useMemo(() => { - const mapped = (events ?? []).map((e: { id: number; instanceId: string; eventType: string; detail: string; timestamp: string }) => ({ - id: String(e.id), + const mapped = eventStream.items.map((e) => ({ + id: `${e.timestamp}:${e.instanceId}:${e.eventType}`, severity: eventSeverity(e.eventType), icon: eventIcon(e.eventType), message: `${e.instanceId}: ${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`, timestamp: new Date(e.timestamp), })); return eventSortAsc ? mapped.toReversed() : mapped; - }, [events, eventSortAsc]); + }, [eventStream.items, eventSortAsc]); // Column definitions for the instance DataTable const instanceColumns: Column[] = useMemo( @@ -890,11 +901,14 @@ export default function AgentHealth() {
Application Log
- {logEntries.length} entries + {logStream.items.length} entries -
@@ -922,9 +936,14 @@ export default function AgentHealth() {
setLogSource(v.size === 0 ? '' : [...v][0])} + value={logSources} + onChange={setLogSources} /> + {logSources.size > 0 && ( + + )} {logLevels.size > 0 && ( )} - {filteredLogs.length > 0 ? ( - - ) : ( -
- {logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'} -
- )} + + {filteredLogs.length > 0 ? ( + + ) : ( +
+ {logSearch || logLevels.size > 0 || logSources.size > 0 + ? 'No matching log entries' + : logStream.isLoading ? 'Loading logs\u2026' : 'No log entries available'} +
+ )} +
Timeline
- {feedEvents.length} events + {eventStream.items.length} events -
- {feedEvents.length > 0 ? ( - - ) : ( -
No events in the selected time range.
- )} + + {feedEvents.length > 0 ? ( + + ) : ( +
+ {eventStream.isLoading ? 'Loading events\u2026' : 'No events in the selected time range.'} +
+ )} +