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:
@@ -204,9 +204,106 @@
|
|||||||
color: var(--text-muted);
|
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) */
|
/* Event card (timeline panel) */
|
||||||
.eventCard {
|
.eventCard {
|
||||||
margin-top: 20px;
|
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { useParams, Link } from 'react-router';
|
|||||||
import {
|
import {
|
||||||
StatCard, StatusDot, Badge, MonoText, ProgressBar,
|
StatCard, StatusDot, Badge, MonoText, ProgressBar,
|
||||||
GroupCard, DataTable, LineChart, EventFeed, DetailPanel,
|
GroupCard, DataTable, LineChart, EventFeed, DetailPanel,
|
||||||
|
LogViewer, ButtonGroup, SectionHeader,
|
||||||
} from '@cameleer/design-system';
|
} 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 styles from './AgentHealth.module.css';
|
||||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
import { useAgents, useAgentEvents } 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 type { AgentInstance } from '../../api/types';
|
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 ─────────────────────────────────────────────────────────
|
// ── AgentHealth page ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AgentHealth() {
|
export default function AgentHealth() {
|
||||||
@@ -227,6 +245,24 @@ export default function AgentHealth() {
|
|||||||
const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>();
|
const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>();
|
||||||
const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo);
|
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 [selectedInstance, setSelectedInstance] = useState<AgentInstance | null>(null);
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
|
||||||
@@ -500,8 +536,58 @@ export default function AgentHealth() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* EventFeed */}
|
{/* Log + Timeline side by side */}
|
||||||
{feedEvents.length > 0 && (
|
<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.eventCard}>
|
||||||
<div className={styles.eventCardHeader}>
|
<div className={styles.eventCardHeader}>
|
||||||
<span className={styles.sectionTitle}>Timeline</span>
|
<span className={styles.sectionTitle}>Timeline</span>
|
||||||
@@ -515,9 +601,13 @@ export default function AgentHealth() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{feedEvents.length > 0 ? (
|
||||||
<EventFeed events={feedEvents} maxItems={100} />
|
<EventFeed events={feedEvents} maxItems={100} />
|
||||||
</div>
|
) : (
|
||||||
|
<div className={styles.logEmpty}>No events in the selected time range.</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Detail panel — auto-portals to AppShell level via design system */}
|
{/* Detail panel — auto-portals to AppShell level via design system */}
|
||||||
{selectedInstance && (
|
{selectedInstance && (
|
||||||
|
|||||||
Reference in New Issue
Block a user