diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index c8d06bcc..7253a6ad 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -204,9 +204,106 @@ color: var(--text-muted); } +/* Log + Timeline side by side */ +.bottomRow { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-top: 20px; +} + +/* Log viewer */ +.logCard { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; + display: flex; + flex-direction: column; + max-height: 420px; +} + +.logHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.logToolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-surface); +} + +.logSearchWrap { + position: relative; + flex: 1; + min-width: 0; +} + +.logSearchInput { + width: 100%; + padding: 5px 28px 5px 10px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-body); + color: var(--text-primary); + font-size: 12px; + font-family: var(--font-body); + outline: none; +} + +.logSearchInput:focus { + border-color: var(--amber); +} + +.logSearchInput::placeholder { + color: var(--text-faint); +} + +.logSearchClear { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + padding: 0 4px; + line-height: 1; +} + +.logClearFilters { + background: none; + border: none; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + padding: 2px 6px; + white-space: nowrap; +} + +.logClearFilters:hover { + color: var(--text-primary); +} + +.logEmpty { + padding: 24px; + text-align: center; + color: var(--text-faint); + font-size: 12px; +} + /* Event card (timeline panel) */ .eventCard { - margin-top: 20px; background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index ae820768..2b35260f 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -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(); const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo); + const [logSearch, setLogSearch] = useState(''); + const [logLevels, setLogLevels] = useState>(new Set()); + const [logSortAsc, setLogSortAsc] = useState(false); + const [logRefreshTo, setLogRefreshTo] = useState(); + const { data: rawLogs } = useApplicationLogs(appId, undefined, { toOverride: logRefreshTo }); + const logEntries = useMemo(() => { + 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(null); const [panelOpen, setPanelOpen] = useState(false); @@ -500,8 +536,58 @@ export default function AgentHealth() { ))} - {/* EventFeed */} - {feedEvents.length > 0 && ( + {/* Log + Timeline side by side */} +
+
+
+ Application Log +
+ {logEntries.length} entries + + +
+
+
+
+ setLogSearch(e.target.value)} + aria-label="Search logs" + /> + {logSearch && ( + + )} +
+ + {logLevels.size > 0 && ( + + )} +
+ {filteredLogs.length > 0 ? ( + + ) : ( +
+ {logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'} +
+ )} +
+
Timeline @@ -515,9 +601,13 @@ export default function AgentHealth() {
- + {feedEvents.length > 0 ? ( + + ) : ( +
No events in the selected time range.
+ )}
- )} + {/* Detail panel — auto-portals to AppShell level via design system */} {selectedInstance && (