feat: wire up application logs from OpenSearch, fix event autoscroll
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:
46
ui/src/api/queries/logs.ts
Normal file
46
ui/src/api/queries/logs.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { config } from '../../config';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { useRefreshInterval } from './use-refresh-interval';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
|
||||
export interface LogEntryResponse {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
loggerName: string | null;
|
||||
message: string;
|
||||
threadName: string | null;
|
||||
stackTrace: string | null;
|
||||
}
|
||||
|
||||
export function useApplicationLogs(
|
||||
application?: string,
|
||||
agentId?: string,
|
||||
options?: { limit?: number },
|
||||
) {
|
||||
const refetchInterval = useRefreshInterval(15_000);
|
||||
const { timeRange } = useGlobalFilters();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['logs', application, agentId, timeRange.start.toISOString(), timeRange.end.toISOString(), options?.limit],
|
||||
queryFn: async () => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const params = new URLSearchParams();
|
||||
params.set('application', application!);
|
||||
if (agentId) params.set('agentId', agentId);
|
||||
params.set('from', timeRange.start.toISOString());
|
||||
params.set('to', timeRange.end.toISOString());
|
||||
if (options?.limit) params.set('limit', String(options.limit));
|
||||
const res = await fetch(`${config.apiBaseUrl}/logs?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Cameleer-Protocol-Version': '1',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load application logs');
|
||||
return res.json() as Promise<LogEntryResponse[]>;
|
||||
},
|
||||
enabled: !!application,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user