feat(ui): AgentHealth — server-side multi-select filters + infinite scroll
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.
This commit is contained in:
@@ -11,8 +11,10 @@ import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/des
|
|||||||
import styles from './AgentHealth.module.css';
|
import styles from './AgentHealth.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 { useAgents, useAgentEvents } from '../../api/queries/agents';
|
import { useAgents } from '../../api/queries/agents';
|
||||||
import { useApplicationLogs } from '../../api/queries/logs';
|
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 { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
||||||
import { useCatalog, useDismissApp } from '../../api/queries/catalog';
|
import { useCatalog, useDismissApp } from '../../api/queries/catalog';
|
||||||
import { useIsAdmin } from '../../auth/auth-store';
|
import { useIsAdmin } from '../../auth/auth-store';
|
||||||
@@ -281,8 +283,9 @@ export default function AgentHealth() {
|
|||||||
});
|
});
|
||||||
}, [appConfig, configDraft, updateConfig, toast, appId]);
|
}, [appConfig, configDraft, updateConfig, toast, appId]);
|
||||||
const [eventSortAsc, setEventSortAsc] = useState(false);
|
const [eventSortAsc, setEventSortAsc] = useState(false);
|
||||||
const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>();
|
const [isTimelineAtTop, setIsTimelineAtTop] = useState(true);
|
||||||
const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo);
|
const timelineScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const eventStream = useInfiniteAgentEvents({ appId, isAtTop: isTimelineAtTop });
|
||||||
|
|
||||||
const [appFilter, setAppFilter] = useState('');
|
const [appFilter, setAppFilter] = useState('');
|
||||||
type AppSortKey = 'status' | 'name' | 'tps' | 'cpu' | 'heartbeat';
|
type AppSortKey = 'status' | 'name' | 'tps' | 'cpu' | 'heartbeat';
|
||||||
@@ -300,22 +303,30 @@ export default function AgentHealth() {
|
|||||||
|
|
||||||
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 [logRefreshTo, setLogRefreshTo] = useState<string | undefined>();
|
const [isLogAtTop, setIsLogAtTop] = useState(true);
|
||||||
const { data: rawLogs } = useApplicationLogs(appId, undefined, { toOverride: logRefreshTo, source: logSource || undefined });
|
const logScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const logStream = useInfiniteApplicationLogs({
|
||||||
|
application: appId,
|
||||||
|
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 logSearchLower = logSearch.toLowerCase();
|
const logSearchLower = logSearch.toLowerCase();
|
||||||
const filteredLogs = logEntries
|
const filteredLogs = logSearchLower
|
||||||
.filter((l) => logLevels.size === 0 || logLevels.has(l.level))
|
? logEntries.filter((l) => l.message.toLowerCase().includes(logSearchLower))
|
||||||
.filter((l) => !logSearchLower || l.message.toLowerCase().includes(logSearchLower));
|
: logEntries;
|
||||||
|
|
||||||
const agentList = agents ?? [];
|
const agentList = agents ?? [];
|
||||||
|
|
||||||
@@ -359,15 +370,15 @@ export default function AgentHealth() {
|
|||||||
|
|
||||||
// Map events to FeedEvent
|
// Map events to FeedEvent
|
||||||
const feedEvents: FeedEvent[] = useMemo(() => {
|
const feedEvents: FeedEvent[] = useMemo(() => {
|
||||||
const mapped = (events ?? []).map((e: { id: number; instanceId: string; eventType: string; detail: string; timestamp: string }) => ({
|
const mapped = eventStream.items.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.instanceId}: ${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
|
message: `${e.instanceId}: ${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, eventSortAsc]);
|
}, [eventStream.items, eventSortAsc]);
|
||||||
|
|
||||||
// Column definitions for the instance DataTable
|
// Column definitions for the instance DataTable
|
||||||
const instanceColumns: Column<AgentInstance>[] = useMemo(
|
const instanceColumns: Column<AgentInstance>[] = useMemo(
|
||||||
@@ -890,11 +901,14 @@ export default function AgentHealth() {
|
|||||||
<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.sectionMeta}>{logEntries.length} entries</span>
|
<span className={styles.sectionMeta}>{logStream.items.length} entries</span>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setLogSortAsc((v) => !v)} title={logSortAsc ? 'Oldest first' : 'Newest first'}>
|
<Button variant="ghost" size="sm" onClick={() => setLogSortAsc((v) => !v)} title={logSortAsc ? 'Oldest first' : 'Newest first'}>
|
||||||
{logSortAsc ? '\u2191' : '\u2193'}
|
{logSortAsc ? '\u2191' : '\u2193'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setLogRefreshTo(new Date().toISOString())} title="Refresh">
|
<Button variant="ghost" size="sm" onClick={() => {
|
||||||
|
logStream.refresh();
|
||||||
|
logScrollRef.current?.scrollTo({ top: 0 });
|
||||||
|
}} title="Refresh">
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -922,9 +936,14 @@ export default function AgentHealth() {
|
|||||||
</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 variant="ghost" size="sm" 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 variant="ghost" size="sm" onClick={() => setLogLevels(new Set())}>
|
<Button variant="ghost" size="sm" onClick={() => setLogLevels(new Set())}>
|
||||||
@@ -932,33 +951,58 @@ export default function AgentHealth() {
|
|||||||
</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}
|
||||||
)}
|
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\u2026' : 'No log entries available'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfiniteScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${sectionStyles.section} ${styles.eventCard}`}>
|
<div className={`${sectionStyles.section} ${styles.eventCard}`}>
|
||||||
<div className={styles.eventCardHeader}>
|
<div className={styles.eventCardHeader}>
|
||||||
<span className={styles.sectionTitle}>Timeline</span>
|
<span className={styles.sectionTitle}>Timeline</span>
|
||||||
<div className={logStyles.headerActions}>
|
<div className={logStyles.headerActions}>
|
||||||
<span className={styles.sectionMeta}>{feedEvents.length} events</span>
|
<span className={styles.sectionMeta}>{eventStream.items.length} events</span>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setEventSortAsc((v) => !v)} title={eventSortAsc ? 'Oldest first' : 'Newest first'}>
|
<Button variant="ghost" size="sm" onClick={() => setEventSortAsc((v) => !v)} title={eventSortAsc ? 'Oldest first' : 'Newest first'}>
|
||||||
{eventSortAsc ? '\u2191' : '\u2193'}
|
{eventSortAsc ? '\u2191' : '\u2193'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setEventRefreshTo(new Date().toISOString())} title="Refresh">
|
<Button variant="ghost" size="sm" 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={100} />
|
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}
|
||||||
|
maxHeight={360}
|
||||||
|
>
|
||||||
|
{feedEvents.length > 0 ? (
|
||||||
|
<EventFeed events={feedEvents} />
|
||||||
|
) : (
|
||||||
|
<div className={logStyles.logEmpty}>
|
||||||
|
{eventStream.isLoading ? 'Loading events\u2026' : 'No events in the selected time range.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfiniteScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user