feat: add application log panel to agent health page
Add the same log + timeline side-by-side layout from AgentInstance to
the AgentHealth page (/agents/{appId}). Includes search input, level
filter pills, sort toggle, and refresh button — matching the instance
page design exactly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,12 @@ import { useParams, Link } from 'react-router';
|
||||
import {
|
||||
StatCard, StatusDot, Badge, MonoText, ProgressBar,
|
||||
GroupCard, DataTable, LineChart, EventFeed, DetailPanel,
|
||||
LogViewer, ButtonGroup, SectionHeader,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column, FeedEvent } from '@cameleer/design-system';
|
||||
import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system';
|
||||
import styles from './AgentHealth.module.css';
|
||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||
import { useApplicationLogs } from '../../api/queries/logs';
|
||||
import { useAgentMetrics } from '../../api/queries/agent-metrics';
|
||||
import type { AgentInstance } from '../../api/types';
|
||||
|
||||
@@ -218,6 +220,22 @@ function AgentPerformanceContent({ agent }: { agent: AgentInstance }) {
|
||||
);
|
||||
}
|
||||
|
||||
const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
|
||||
{ value: 'error', label: 'Error', color: 'var(--error)' },
|
||||
{ value: 'warn', label: 'Warn', color: 'var(--warning)' },
|
||||
{ value: 'info', label: 'Info', color: 'var(--success)' },
|
||||
{ value: 'debug', label: 'Debug', color: 'var(--running)' },
|
||||
];
|
||||
|
||||
function mapLogLevel(level: string): LogEntry['level'] {
|
||||
switch (level?.toUpperCase()) {
|
||||
case 'ERROR': return 'error';
|
||||
case 'WARN': case 'WARNING': return 'warn';
|
||||
case 'DEBUG': case 'TRACE': return 'debug';
|
||||
default: return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
// ── AgentHealth page ─────────────────────────────────────────────────────────
|
||||
|
||||
export default function AgentHealth() {
|
||||
@@ -227,6 +245,24 @@ export default function AgentHealth() {
|
||||
const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>();
|
||||
const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo);
|
||||
|
||||
const [logSearch, setLogSearch] = useState('');
|
||||
const [logLevels, setLogLevels] = useState<Set<string>>(new Set());
|
||||
const [logSortAsc, setLogSortAsc] = useState(false);
|
||||
const [logRefreshTo, setLogRefreshTo] = useState<string | undefined>();
|
||||
const { data: rawLogs } = useApplicationLogs(appId, undefined, { toOverride: logRefreshTo });
|
||||
const logEntries = useMemo<LogEntry[]>(() => {
|
||||
const mapped = (rawLogs || []).map((l) => ({
|
||||
timestamp: l.timestamp ?? '',
|
||||
level: mapLogLevel(l.level),
|
||||
message: l.message ?? '',
|
||||
}));
|
||||
return logSortAsc ? mapped.toReversed() : mapped;
|
||||
}, [rawLogs, 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 [selectedInstance, setSelectedInstance] = useState<AgentInstance | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
@@ -500,8 +536,58 @@ export default function AgentHealth() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* EventFeed */}
|
||||
{feedEvents.length > 0 && (
|
||||
{/* Log + Timeline side by side */}
|
||||
<div className={styles.bottomRow}>
|
||||
<div className={styles.logCard}>
|
||||
<div className={styles.logHeader}>
|
||||
<SectionHeader>Application Log</SectionHeader>
|
||||
<div className={styles.headerActions}>
|
||||
<span className={styles.sectionMeta}>{logEntries.length} entries</span>
|
||||
<button className={styles.sortBtn} onClick={() => setLogSortAsc((v) => !v)} title={logSortAsc ? 'Oldest first' : 'Newest first'}>
|
||||
{logSortAsc ? '\u2191' : '\u2193'}
|
||||
</button>
|
||||
<button className={styles.refreshBtn} onClick={() => setLogRefreshTo(new Date().toISOString())} title="Refresh">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.logToolbar}>
|
||||
<div className={styles.logSearchWrap}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.logSearchInput}
|
||||
placeholder="Search logs\u2026"
|
||||
value={logSearch}
|
||||
onChange={(e) => setLogSearch(e.target.value)}
|
||||
aria-label="Search logs"
|
||||
/>
|
||||
{logSearch && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.logSearchClear}
|
||||
onClick={() => setLogSearch('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} />
|
||||
{logLevels.size > 0 && (
|
||||
<button className={styles.logClearFilters} onClick={() => setLogLevels(new Set())}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filteredLogs.length > 0 ? (
|
||||
<LogViewer entries={filteredLogs} maxHeight={360} />
|
||||
) : (
|
||||
<div className={styles.logEmpty}>
|
||||
{logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.eventCard}>
|
||||
<div className={styles.eventCardHeader}>
|
||||
<span className={styles.sectionTitle}>Timeline</span>
|
||||
@@ -515,9 +601,13 @@ export default function AgentHealth() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EventFeed events={feedEvents} maxItems={100} />
|
||||
{feedEvents.length > 0 ? (
|
||||
<EventFeed events={feedEvents} maxItems={100} />
|
||||
) : (
|
||||
<div className={styles.logEmpty}>No events in the selected time range.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail panel — auto-portals to AppShell level via design system */}
|
||||
{selectedInstance && (
|
||||
|
||||
Reference in New Issue
Block a user