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:
@@ -0,0 +1,49 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.LogEntryResponse;
|
||||
import com.cameleer3.server.app.search.OpenSearchLogIndex;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/logs")
|
||||
@Tag(name = "Application Logs", description = "Query application logs stored in OpenSearch")
|
||||
public class LogQueryController {
|
||||
|
||||
private final OpenSearchLogIndex logIndex;
|
||||
|
||||
public LogQueryController(OpenSearchLogIndex logIndex) {
|
||||
this.logIndex = logIndex;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Search application log entries",
|
||||
description = "Returns log entries for a given application, optionally filtered by agent, level, time range, and text query")
|
||||
public ResponseEntity<List<LogEntryResponse>> searchLogs(
|
||||
@RequestParam String application,
|
||||
@RequestParam(required = false) String agentId,
|
||||
@RequestParam(required = false) String level,
|
||||
@RequestParam(required = false) String query,
|
||||
@RequestParam(required = false) String from,
|
||||
@RequestParam(required = false) String to,
|
||||
@RequestParam(defaultValue = "200") int limit) {
|
||||
|
||||
limit = Math.min(limit, 1000);
|
||||
|
||||
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||
|
||||
List<LogEntryResponse> entries = logIndex.search(
|
||||
application, agentId, level, query, fromInstant, toInstant, limit);
|
||||
|
||||
return ResponseEntity.ok(entries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cameleer3.server.app.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(description = "Application log entry from OpenSearch")
|
||||
public record LogEntryResponse(
|
||||
@Schema(description = "Log timestamp (ISO-8601)") String timestamp,
|
||||
@Schema(description = "Log level (INFO, WARN, ERROR, DEBUG)") String level,
|
||||
@Schema(description = "Logger name") String loggerName,
|
||||
@Schema(description = "Log message") String message,
|
||||
@Schema(description = "Thread name") String threadName,
|
||||
@Schema(description = "Stack trace (if present)") String stackTrace
|
||||
) {}
|
||||
@@ -1,9 +1,15 @@
|
||||
package com.cameleer3.server.app.search;
|
||||
|
||||
import com.cameleer3.common.model.LogEntry;
|
||||
import com.cameleer3.server.app.dto.LogEntryResponse;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.opensearch.client.json.JsonData;
|
||||
import org.opensearch.client.opensearch.OpenSearchClient;
|
||||
import org.opensearch.client.opensearch._types.FieldValue;
|
||||
import org.opensearch.client.opensearch._types.SortOrder;
|
||||
import org.opensearch.client.opensearch._types.mapping.Property;
|
||||
import org.opensearch.client.opensearch._types.query_dsl.BoolQuery;
|
||||
import org.opensearch.client.opensearch._types.query_dsl.Query;
|
||||
import org.opensearch.client.opensearch.core.BulkRequest;
|
||||
import org.opensearch.client.opensearch.core.BulkResponse;
|
||||
import org.opensearch.client.opensearch.core.bulk.BulkResponseItem;
|
||||
@@ -15,8 +21,10 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -91,6 +99,60 @@ public class OpenSearchLogIndex {
|
||||
}
|
||||
}
|
||||
|
||||
public List<LogEntryResponse> search(String application, String agentId, String level,
|
||||
String query, Instant from, Instant to, int limit) {
|
||||
try {
|
||||
BoolQuery.Builder bool = new BoolQuery.Builder();
|
||||
bool.must(Query.of(q -> q.term(t -> t.field("application").value(FieldValue.of(application)))));
|
||||
if (agentId != null && !agentId.isEmpty()) {
|
||||
bool.must(Query.of(q -> q.term(t -> t.field("agentId").value(FieldValue.of(agentId)))));
|
||||
}
|
||||
if (level != null && !level.isEmpty()) {
|
||||
bool.must(Query.of(q -> q.term(t -> t.field("level").value(FieldValue.of(level.toUpperCase())))));
|
||||
}
|
||||
if (query != null && !query.isEmpty()) {
|
||||
bool.must(Query.of(q -> q.match(m -> m.field("message").query(FieldValue.of(query)))));
|
||||
}
|
||||
if (from != null || to != null) {
|
||||
bool.must(Query.of(q -> q.range(r -> {
|
||||
r.field("@timestamp");
|
||||
if (from != null) r.gte(JsonData.of(from.toString()));
|
||||
if (to != null) r.lte(JsonData.of(to.toString()));
|
||||
return r;
|
||||
})));
|
||||
}
|
||||
|
||||
var response = client.search(s -> s
|
||||
.index(indexPrefix + "*")
|
||||
.query(Query.of(q -> q.bool(bool.build())))
|
||||
.sort(so -> so.field(f -> f.field("@timestamp").order(SortOrder.Desc)))
|
||||
.size(limit), Map.class);
|
||||
|
||||
List<LogEntryResponse> results = new ArrayList<>();
|
||||
for (var hit : response.hits().hits()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> src = (Map<String, Object>) hit.source();
|
||||
if (src == null) continue;
|
||||
results.add(new LogEntryResponse(
|
||||
str(src, "@timestamp"),
|
||||
str(src, "level"),
|
||||
str(src, "loggerName"),
|
||||
str(src, "message"),
|
||||
str(src, "threadName"),
|
||||
str(src, "stackTrace")));
|
||||
}
|
||||
return results;
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to search log entries for application={}", application, e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Map<String, Object> map, String key) {
|
||||
Object v = map.get(key);
|
||||
return v != null ? v.toString() : null;
|
||||
}
|
||||
|
||||
public void indexBatch(String agentId, String application, List<LogEntry> entries) {
|
||||
if (entries == null || entries.isEmpty()) {
|
||||
return;
|
||||
|
||||
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