feat(ui): AgentInstance — server-side multi-select filters + infinite scroll

Same pattern as AgentHealth, scoped to a single agent instance
(passes agentId to both log and timeline streams).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-17 12:55:39 +02:00
parent 7f233460aa
commit ef9bc5a614

View File

@@ -1,6 +1,9 @@
import { useMemo, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { useInfiniteApplicationLogs } from '../../api/queries/logs';
import { useInfiniteAgentEvents } from '../../api/queries/agents';
import { InfiniteScrollArea } from '../../components/InfiniteScrollArea';
import { import {
StatCard, StatusDot, Badge, ThemedChart, Line, Area, ReferenceLine, CHART_COLORS, StatCard, StatusDot, Badge, ThemedChart, Line, Area, ReferenceLine, CHART_COLORS,
EventFeed, Spinner, EmptyState, SectionHeader, MonoText, EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
@@ -11,8 +14,7 @@ import styles from './AgentInstance.module.css';
import sectionStyles from '../../styles/section-card.module.css'; import sectionStyles from '../../styles/section-card.module.css';
import logStyles from '../../styles/log-panel.module.css'; import logStyles from '../../styles/log-panel.module.css';
import chartCardStyles from '../../styles/chart-card.module.css'; import chartCardStyles from '../../styles/chart-card.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useAgents } from '../../api/queries/agents';
import { useApplicationLogs } from '../../api/queries/logs';
import { useAgentMetrics } from '../../api/queries/agent-metrics'; import { useAgentMetrics } from '../../api/queries/agent-metrics';
import { formatUptime, mapLogLevel, eventSeverity, eventIcon } from '../../utils/agent-utils'; import { formatUptime, mapLogLevel, eventSeverity, eventIcon } from '../../utils/agent-utils';
import { useEnvironmentStore } from '../../api/environment-store'; import { useEnvironmentStore } from '../../api/environment-store';
@@ -36,17 +38,19 @@ export default function AgentInstance() {
const { timeRange } = useGlobalFilters(); const { timeRange } = useGlobalFilters();
const [logSearch, setLogSearch] = useState(''); const [logSearch, setLogSearch] = useState('');
const [logLevels, setLogLevels] = useState<Set<string>>(new Set()); const [logLevels, setLogLevels] = useState<Set<string>>(new Set());
const [logSource, setLogSource] = useState<string>(''); // '' = all, 'app', 'agent' const [logSources, setLogSources] = useState<Set<string>>(new Set());
const [logSortAsc, setLogSortAsc] = useState(false); const [logSortAsc, setLogSortAsc] = useState(false);
const [eventSortAsc, setEventSortAsc] = useState(false); const [eventSortAsc, setEventSortAsc] = useState(false);
const [logRefreshTo, setLogRefreshTo] = useState<string | undefined>(); const [isLogAtTop, setIsLogAtTop] = useState(true);
const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>(); const [isTimelineAtTop, setIsTimelineAtTop] = useState(true);
const logScrollRef = useRef<HTMLDivElement | null>(null);
const timelineScrollRef = useRef<HTMLDivElement | null>(null);
const timeFrom = timeRange.start.toISOString(); const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString(); const timeTo = timeRange.end.toISOString();
const selectedEnv = useEnvironmentStore((s) => s.environment); const selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: agents, isLoading } = useAgents(undefined, appId); const { data: agents, isLoading } = useAgents(undefined, appId);
const { data: events } = useAgentEvents(appId, instanceId, 50, eventRefreshTo); const eventStream = useInfiniteAgentEvents({ appId, agentId: instanceId, isAtTop: isTimelineAtTop });
const agent = useMemo( const agent = useMemo(
() => (agents || []).find((a: any) => a.instanceId === instanceId) as any, () => (agents || []).find((a: any) => a.instanceId === instanceId) as any,
@@ -80,17 +84,17 @@ export default function AgentInstance() {
); );
const feedEvents = useMemo<FeedEvent[]>(() => { const feedEvents = useMemo<FeedEvent[]>(() => {
const mapped = (events || []) const mapped = eventStream.items
.filter((e: any) => !instanceId || e.instanceId === instanceId) .filter((e) => !instanceId || e.instanceId === instanceId)
.map((e: any) => ({ .map((e) => ({
id: String(e.id), id: `${e.timestamp}:${e.instanceId}:${e.eventType}`,
severity: eventSeverity(e.eventType), severity: eventSeverity(e.eventType),
icon: eventIcon(e.eventType), icon: eventIcon(e.eventType),
message: `${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`, message: `${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
timestamp: new Date(e.timestamp), timestamp: new Date(e.timestamp),
})); }));
return eventSortAsc ? mapped.toReversed() : mapped; return eventSortAsc ? mapped.toReversed() : mapped;
}, [events, instanceId, eventSortAsc]); }, [eventStream.items, instanceId, eventSortAsc]);
const formatTime = (t: string) => const formatTime = (t: string) =>
new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@@ -132,19 +136,26 @@ export default function AgentInstance() {
}, [agentMetrics]); }, [agentMetrics]);
// Application logs // Application logs
const { data: rawLogs } = useApplicationLogs(appId, instanceId, { toOverride: logRefreshTo, source: logSource || undefined }); const logStream = useInfiniteApplicationLogs({
application: appId,
agentId: instanceId,
sources: [...logSources],
levels: [...logLevels],
isAtTop: isLogAtTop,
});
const logEntries = useMemo<LogEntry[]>(() => { const logEntries = useMemo<LogEntry[]>(() => {
const mapped = (rawLogs || []).map((l) => ({ const mapped = logStream.items.map((l) => ({
timestamp: l.timestamp ?? '', timestamp: l.timestamp ?? '',
level: mapLogLevel(l.level), level: mapLogLevel(l.level),
message: l.message ?? '', message: l.message ?? '',
source: l.source ?? undefined,
})); }));
return logSortAsc ? mapped.toReversed() : mapped; return logSortAsc ? mapped.toReversed() : mapped;
}, [rawLogs, logSortAsc]); }, [logStream.items, logSortAsc]);
const searchLower = logSearch.toLowerCase(); const searchLower = logSearch.toLowerCase();
const filteredLogs = logEntries const filteredLogs = searchLower
.filter((l) => logLevels.size === 0 || logLevels.has(l.level)) ? logEntries.filter((l) => l.message.toLowerCase().includes(searchLower))
.filter((l) => !searchLower || l.message.toLowerCase().includes(searchLower)); : logEntries;
if (isLoading) return <Spinner size="lg" />; if (isLoading) return <Spinner size="lg" />;
@@ -396,11 +407,14 @@ export default function AgentInstance() {
<div className={logStyles.logHeader}> <div className={logStyles.logHeader}>
<SectionHeader>Application Log</SectionHeader> <SectionHeader>Application Log</SectionHeader>
<div className={logStyles.headerActions}> <div className={logStyles.headerActions}>
<span className={styles.chartMeta}>{logEntries.length} entries</span> <span className={styles.chartMeta}>{logStream.items.length} entries</span>
<button className={logStyles.sortBtn} onClick={() => setLogSortAsc((v) => !v)} title={logSortAsc ? 'Oldest first' : 'Newest first'}> <button className={logStyles.sortBtn} onClick={() => setLogSortAsc((v) => !v)} title={logSortAsc ? 'Oldest first' : 'Newest first'}>
{logSortAsc ? '\u2191' : '\u2193'} {logSortAsc ? '\u2191' : '\u2193'}
</button> </button>
<button className={logStyles.refreshBtn} onClick={() => setLogRefreshTo(new Date().toISOString())} title="Refresh"> <button className={logStyles.refreshBtn} onClick={() => {
logStream.refresh();
logScrollRef.current?.scrollTo({ top: 0 });
}} title="Refresh">
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </button>
</div> </div>
@@ -428,9 +442,14 @@ export default function AgentInstance() {
</div> </div>
<ButtonGroup <ButtonGroup
items={LOG_SOURCE_ITEMS} items={LOG_SOURCE_ITEMS}
value={logSource ? new Set([logSource]) : new Set()} value={logSources}
onChange={(v) => setLogSource(v.size === 0 ? '' : [...v][0])} onChange={setLogSources}
/> />
{logSources.size > 0 && (
<button className={logStyles.logClearFilters} onClick={() => setLogSources(new Set())}>
Clear
</button>
)}
<ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} /> <ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} />
{logLevels.size > 0 && ( {logLevels.size > 0 && (
<button className={logStyles.logClearFilters} onClick={() => setLogLevels(new Set())}> <button className={logStyles.logClearFilters} onClick={() => setLogLevels(new Set())}>
@@ -438,33 +457,62 @@ export default function AgentInstance() {
</button> </button>
)} )}
</div> </div>
{filteredLogs.length > 0 ? ( <InfiniteScrollArea
<LogViewer entries={filteredLogs} maxHeight={360} /> scrollRef={logScrollRef}
) : ( onEndReached={logStream.fetchNextPage}
<div className={logStyles.logEmpty}> onTopVisibilityChange={setIsLogAtTop}
{logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'} isFetchingNextPage={logStream.isFetchingNextPage}
</div> hasNextPage={logStream.hasNextPage}
)} isLoading={logStream.isLoading}
hasItems={logStream.items.length > 0}
maxHeight={360}
>
{filteredLogs.length > 0 ? (
<LogViewer entries={filteredLogs} />
) : (
<div className={logStyles.logEmpty}>
{logSearch || logLevels.size > 0 || logSources.size > 0
? 'No matching log entries'
: logStream.isLoading ? 'Loading logs…' : 'No log entries available'}
</div>
)}
</InfiniteScrollArea>
</div> </div>
<div className={`${sectionStyles.section} ${styles.timelineCard}`}> <div className={`${sectionStyles.section} ${styles.timelineCard}`}>
<div className={styles.timelineHeader}> <div className={styles.timelineHeader}>
<span className={styles.chartTitle}>Timeline</span> <span className={styles.chartTitle}>Timeline</span>
<div className={logStyles.headerActions}> <div className={logStyles.headerActions}>
<span className={styles.chartMeta}>{feedEvents.length} events</span> <span className={styles.chartMeta}>{eventStream.items.length} events</span>
<button className={logStyles.sortBtn} onClick={() => setEventSortAsc((v) => !v)} title={eventSortAsc ? 'Oldest first' : 'Newest first'}> <button className={logStyles.sortBtn} onClick={() => setEventSortAsc((v) => !v)} title={eventSortAsc ? 'Oldest first' : 'Newest first'}>
{eventSortAsc ? '\u2191' : '\u2193'} {eventSortAsc ? '\u2191' : '\u2193'}
</button> </button>
<button className={logStyles.refreshBtn} onClick={() => setEventRefreshTo(new Date().toISOString())} title="Refresh"> <button className={logStyles.refreshBtn} onClick={() => {
eventStream.refresh();
timelineScrollRef.current?.scrollTo({ top: 0 });
}} title="Refresh">
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </button>
</div> </div>
</div> </div>
{feedEvents.length > 0 ? ( <InfiniteScrollArea
<EventFeed events={feedEvents} maxItems={50} /> scrollRef={timelineScrollRef}
) : ( onEndReached={eventStream.fetchNextPage}
<div className={logStyles.logEmpty}>No events in the selected time range.</div> onTopVisibilityChange={setIsTimelineAtTop}
)} isFetchingNextPage={eventStream.isFetchingNextPage}
hasNextPage={eventStream.hasNextPage}
isLoading={eventStream.isLoading}
hasItems={eventStream.items.length > 0}
maxHeight={360}
>
{feedEvents.length > 0 ? (
<EventFeed events={feedEvents} />
) : (
<div className={logStyles.logEmpty}>
{eventStream.isLoading ? 'Loading events…' : 'No events in the selected time range.'}
</div>
)}
</InfiniteScrollArea>
</div> </div>
</div> </div>
</div> </div>