feat: wire up application logs from OpenSearch, fix event autoscroll
All checks were successful
CI / build (push) Successful in 55s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 51s
CI / deploy (push) Successful in 37s
CI / deploy-feature (push) Has been skipped

Add GET /api/v1/logs endpoint to query application logs stored in
OpenSearch with filters for application, agent, level, time range,
and text search. Wire up the AgentInstance LogViewer with real data
and an EventFeed-style toolbar (search input + level filter pills).

Fix agent events timeline autoscroll by reversing the DESC-ordered
events so newest entries appear at the bottom where EventFeed
autoscrolls to.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-25 18:56:13 +01:00
parent 20ee448f4e
commit b612941aae
7 changed files with 299 additions and 15 deletions

View File

@@ -256,7 +256,7 @@ export default function AgentHealth() {
: ('running' as const),
message: `${e.agentId}: ${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
})).toReversed(),
[events],
);

View File

@@ -142,6 +142,69 @@
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);
}
/* Empty state (shared) */
.logEmpty {
padding: 24px;

View File

@@ -3,24 +3,36 @@ import { useParams, Link } from 'react-router';
import {
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
LogViewer, Tabs, useGlobalFilters,
LogViewer, ButtonGroup, useGlobalFilters,
} from '@cameleer/design-system';
import type { FeedEvent, LogEntry } from '@cameleer/design-system';
import type { FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system';
import styles from './AgentInstance.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useApplicationLogs } from '../../api/queries/logs';
import { useStatsTimeseries } from '../../api/queries/executions';
import { useAgentMetrics } from '../../api/queries/agent-metrics';
const LOG_TABS = [
{ label: 'All', value: 'all' },
{ label: 'Warnings', value: 'warn' },
{ label: 'Errors', value: 'error' },
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';
}
}
export default function AgentInstance() {
const { appId, instanceId } = useParams();
const { timeRange } = useGlobalFilters();
const [logFilter, setLogFilter] = useState('all');
const [logSearch, setLogSearch] = useState('');
const [logLevels, setLogLevels] = useState<Set<string>>(new Set());
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
@@ -78,7 +90,8 @@ export default function AgentInstance() {
: ('running' as const),
message: `${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
}))
.toReversed(),
[events, instanceId],
);
@@ -123,10 +136,20 @@ export default function AgentInstance() {
[chartData],
);
// Placeholder log entries (backend does not stream logs yet)
const logEntries = useMemo<LogEntry[]>(() => [], []);
const filteredLogs =
logFilter === 'all' ? logEntries : logEntries.filter((l) => l.level === logFilter);
// Application logs from OpenSearch
const { data: rawLogs } = useApplicationLogs(appId, instanceId);
const logEntries = useMemo<LogEntry[]>(
() => (rawLogs || []).map((l) => ({
timestamp: l.timestamp ?? '',
level: mapLogLevel(l.level),
message: l.message ?? '',
})),
[rawLogs],
);
const searchLower = logSearch.toLowerCase();
const filteredLogs = logEntries
.filter((l) => logLevels.size === 0 || logLevels.has(l.level))
.filter((l) => !searchLower || l.message.toLowerCase().includes(searchLower));
if (isLoading) return <Spinner size="lg" />;
@@ -374,13 +397,41 @@ export default function AgentInstance() {
<div className={styles.logCard}>
<div className={styles.logHeader}>
<SectionHeader>Application Log</SectionHeader>
<Tabs tabs={LOG_TABS} active={logFilter} onChange={setLogFilter} />
<span className={styles.chartMeta}>{logEntries.length} entries</span>
</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"
>
&times;
</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}>
Application log streaming is not yet available.
{logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'}
</div>
)}
</div>