feat: add Logs tab with cursor-paginated search, level filters, and live tail
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m3s
CI / docker (push) Successful in 1m11s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 49s

- Extend GET /api/v1/logs with cursor pagination, multi-level filtering,
  optional application scoping, and level count aggregation
- Add exchangeId, instanceId, application, mdc fields to log responses
- Refactor ClickHouseLogStore with keyset pagination (N+1 pattern)
- Add LogSearchRequest/LogSearchResponse core domain records
- Create LogSearchPageResponse wrapper DTO
- Add Logs as 4th content tab (Exchanges | Dashboard | Runtime | Logs)
- Implement LogSearch component with debounced search, level filter bar,
  expandable log entries, cursor pagination, and live tail mode
- Add cross-navigation: exchange header → logs, log tab → logs tab
- Update ClickHouseLogStoreIT with cursor, multi-level, cross-app tests

Closes: #104

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-02 08:47:16 +02:00
parent a52751da1b
commit b73f5e6dd4
22 changed files with 1405 additions and 119 deletions

View File

@@ -11,8 +11,81 @@ export interface LogEntryResponse {
message: string;
threadName: string | null;
stackTrace: string | null;
exchangeId: string | null;
instanceId: string | null;
application: string | null;
mdc: Record<string, string> | null;
}
export interface LogSearchPageResponse {
data: LogEntryResponse[];
nextCursor: string | null;
hasMore: boolean;
levelCounts: Record<string, number>;
}
export interface LogSearchParams {
q?: string;
level?: string;
application?: string;
agentId?: string;
exchangeId?: string;
logger?: string;
from?: string;
to?: string;
cursor?: string;
limit?: number;
sort?: 'asc' | 'desc';
}
async function fetchLogs(params: LogSearchParams): Promise<LogSearchPageResponse> {
const token = useAuthStore.getState().accessToken;
const urlParams = new URLSearchParams();
if (params.q) urlParams.set('q', params.q);
if (params.level) urlParams.set('level', params.level);
if (params.application) urlParams.set('application', params.application);
if (params.agentId) urlParams.set('agentId', params.agentId);
if (params.exchangeId) urlParams.set('exchangeId', params.exchangeId);
if (params.logger) urlParams.set('logger', params.logger);
if (params.from) urlParams.set('from', params.from);
if (params.to) urlParams.set('to', params.to);
if (params.cursor) urlParams.set('cursor', params.cursor);
if (params.limit) urlParams.set('limit', String(params.limit));
if (params.sort) urlParams.set('sort', params.sort);
const res = await fetch(`${config.apiBaseUrl}/logs?${urlParams}`, {
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
},
});
if (!res.ok) throw new Error('Failed to load logs');
return res.json() as Promise<LogSearchPageResponse>;
}
/**
* Primary log search hook with cursor pagination and level counts.
*/
export function useLogs(
params: LogSearchParams,
options?: { enabled?: boolean; refetchInterval?: number | false },
) {
const defaultRefetch = useRefreshInterval(15_000);
return useQuery({
queryKey: ['logs', params],
queryFn: () => fetchLogs(params),
enabled: options?.enabled ?? true,
placeholderData: (prev) => prev,
refetchInterval: options?.refetchInterval ?? defaultRefetch,
staleTime: 300,
});
}
/**
* Backward-compatible wrapper for existing consumers (LogTab, AgentHealth, AgentInstance).
* Returns the same shape they expect: data is the LogEntryResponse[] (unwrapped from the page response).
*/
export function useApplicationLogs(
application?: string,
agentId?: string,
@@ -21,36 +94,31 @@ export function useApplicationLogs(
const refetchInterval = useRefreshInterval(15_000);
const { timeRange } = useGlobalFilters();
const to = options?.toOverride ?? timeRange.end.toISOString();
// When filtering by exchangeId, skip the global time range — exchange logs are historical
const useTimeRange = !options?.exchangeId;
return useQuery({
queryKey: ['logs', application, agentId,
const params: LogSearchParams = {
application: application || undefined,
agentId: agentId || undefined,
exchangeId: options?.exchangeId || undefined,
from: useTimeRange ? timeRange.start.toISOString() : undefined,
to: useTimeRange ? to : undefined,
limit: options?.limit,
};
const query = useQuery({
queryKey: ['logs', 'compat', application, agentId,
useTimeRange ? timeRange.start.toISOString() : null,
useTimeRange ? to : null,
options?.limit, options?.exchangeId],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams();
params.set('application', application!);
if (agentId) params.set('agentId', agentId);
if (options?.exchangeId) params.set('exchangeId', options.exchangeId);
if (useTimeRange) {
params.set('from', timeRange.start.toISOString());
params.set('to', to);
}
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[]>;
},
queryFn: () => fetchLogs(params),
enabled: !!application,
placeholderData: (prev) => prev,
refetchInterval,
});
// Unwrap: existing consumers expect data to be LogEntryResponse[] directly
return {
...query,
data: query.data?.data ?? (undefined as LogEntryResponse[] | undefined),
};
}