import {useEffect, useRef, useState} from 'react' import {createPortal} from 'react-dom' import {ChevronRight, Maximize2, Minimize2, Radio, RefreshCw, Search, ScrollText, Square} from 'lucide-react' import {useQueries} from '@tanstack/react-query' import {filterLogEvents, listChildCollections, listCollectionItems} from '@/api/cloudProxyClient' import {EmptyState} from '@/components/EmptyState' import {ServiceIcon} from '@/components/serviceIcons' import {formatBytes, formatDateTime} from '@/lib/format' import {timeAgo} from '@/lib/utils' import type {CloudProvider} from '@/types/cloud' import type {CloudResource} from '@/types/resource' const LIVE_TAIL_INTERVAL_MS = 2_000 interface LogsExplorerPanelProps { cloud: CloudProvider resource?: CloudResource runtimeReachable: boolean } /** * Lives in ResourceInspector next to a selected log group's metadata and the * Insights query panel, rather than as a full-width section under the * resource table. This is the two-level drill-in beneath a selected group: * streams (a child collection) and events (the leaf item), both served by the * generic child-collections SPI that AwsLogsAdapter implements — three * min-width columns (~1000px), too wide for the inspector's narrow column, so * the inline view is a compact summary and the real browser opens in the same * full-page modal the query panel uses. * * Both streams and events use one query per loaded cursor page (via * useQueries) rather than useInfiniteQuery, following the precedent set for * events by the original implementation this panel ported from. Refresh * resets the cursor array to a single page and explicitly refetches it, * rather than only refetching the first query: with more than one page * loaded, refetching in place would otherwise combine a fresh first page * with stale trailing pages built from the old cursor chain. */ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplorerPanelProps) { const groupId = resource?.id const [selectedStreamId, setSelectedStreamId] = useState() const [streamCursors, setStreamCursors] = useState>([undefined]) const [eventCursors, setEventCursors] = useState>([undefined]) const [expanded, setExpanded] = useState(false) const [eventMode, setEventMode] = useState<'stream' | 'filter'>('stream') const [filterPattern, setFilterPattern] = useState('') const [appliedFilterPattern, setAppliedFilterPattern] = useState('') const [filteredEvents, setFilteredEvents] = useState>['items']>([]) const [filterCursor, setFilterCursor] = useState(null) const [filterLoading, setFilterLoading] = useState(false) const [filterError, setFilterError] = useState() const [liveTail, setLiveTail] = useState(false) const filteredEventsRef = useRef(filteredEvents) useEffect(() => { filteredEventsRef.current = filteredEvents }, [filteredEvents]) useEffect(() => { setSelectedStreamId(undefined) setStreamCursors([undefined]) setEventCursors([undefined]) setEventMode('stream') setFilterPattern('') setAppliedFilterPattern('') setFilteredEvents([]) setFilterCursor(null) setFilterError(undefined) setLiveTail(false) }, [cloud, groupId]) useEffect(() => { setEventCursors([undefined]) }, [selectedStreamId]) const streamPages = useQueries({ queries: streamCursors.map((cursor) => ({ queryKey: ['log-streams', cloud, groupId, cursor], queryFn: ({signal}: {signal: AbortSignal}) => listChildCollections(cloud, 'logs', groupId ?? '', cursor, signal), enabled: Boolean(groupId) && runtimeReachable, })), }) const firstStreamPage = streamPages[0] const lastStreamPage = streamPages[streamPages.length - 1] const streams = streamPages.flatMap((page) => page.data?.items ?? []) const nextStreamCursor = lastStreamPage?.data?.nextCursor ?? null const loadingMoreStreams = streamPages.length > 1 && lastStreamPage?.isLoading const eventPages = useQueries({ queries: eventCursors.map((cursor) => ({ queryKey: ['log-events', cloud, groupId, selectedStreamId, cursor], queryFn: ({signal}: {signal: AbortSignal}) => listCollectionItems(cloud, 'logs', groupId ?? '', selectedStreamId ?? '', cursor, signal), enabled: Boolean(groupId && selectedStreamId) && runtimeReachable, })), }) const firstEventPage = eventPages[0] const lastEventPage = eventPages[eventPages.length - 1] const events = eventPages.flatMap((page) => page.data?.items ?? []) const nextEventCursor = lastEventPage?.data?.nextCursor ?? null const loadingMoreEvents = eventPages.length > 1 && lastEventPage?.isLoading function refreshStreams() { setStreamCursors([undefined]) void firstStreamPage?.refetch() } function refreshEvents() { setEventCursors([undefined]) void firstEventPage?.refetch() } async function loadFilteredEvents(pattern: string, cursor?: string, append = false, signal?: AbortSignal) { setFilterLoading(true) setFilterError(undefined) try { const page = await filterLogEvents(cloud, groupId ?? '', {filterPattern: pattern || undefined}, cursor, signal) setFilteredEvents((current) => append ? mergeEvents(current, page.items) : page.items) setFilterCursor(page.nextCursor) } catch (error) { if (!signal?.aborted) setFilterError(error instanceof Error ? error.message : 'Unable to filter log events.') } finally { if (!signal?.aborted) setFilterLoading(false) } } useEffect(() => { if (!liveTail || !runtimeReachable || !groupId) return const controller = new AbortController() let timer: ReturnType | undefined const poll = async () => { try { const latestTimestamp = filteredEventsRef.current.reduce((latest, event) => { const timestamp = event.timestamp ? Date.parse(event.timestamp) : 0 return Math.max(latest, timestamp) }, 0) let cursor: string | undefined do { const page = await filterLogEvents(cloud, groupId, { filterPattern: appliedFilterPattern || undefined, startTime: latestTimestamp || undefined, }, cursor, controller.signal) setFilteredEvents((current) => mergeEvents(current, page.items)) cursor = page.nextCursor ?? undefined } while (cursor && !controller.signal.aborted) if (!controller.signal.aborted) timer = setTimeout(poll, LIVE_TAIL_INTERVAL_MS) } catch (error) { if (!controller.signal.aborted) { setFilterError(error instanceof Error ? error.message : 'Live tail stopped after an error.') setLiveTail(false) } } } void poll() return () => { controller.abort() if (timer) clearTimeout(timer) } }, [appliedFilterPattern, cloud, groupId, liveTail, runtimeReachable]) if (!groupId) return null const content = (
Streams {groupId} {streams.length} log streams
{firstStreamPage?.error instanceof Error &&
{firstStreamPage.error.message}
}
{firstStreamPage?.isLoading &&
Loading log streams
} {!firstStreamPage?.isLoading && streams.length === 0 && ( )} {streams.map((stream) => ( ))} {nextStreamCursor && (
)}
Events {selectedStreamId ?? 'Select a stream'} {events.length} events loaded
{eventMode === 'stream' ? :
{ event.preventDefault() setAppliedFilterPattern(filterPattern.trim()) setLiveTail(false) void loadFilteredEvents(filterPattern.trim()) }}>
}
{eventMode === 'stream' && firstEventPage?.error instanceof Error &&
{firstEventPage.error.message}
} {eventMode === 'filter' && filterError &&
{filterError}
}
{eventMode === 'stream' && !selectedStreamId && (

Select a stream

Events are scoped to a log stream.

)} {eventMode === 'stream' && selectedStreamId && !firstEventPage?.isLoading && events.length === 0 && ( )} {eventMode === 'stream' && selectedStreamId && (firstEventPage?.isLoading ? (
Loading log events
) : events.length > 0 && ( <> {events.map((event) => ( ))}
Timestamp Message
{formatDateTime(event.timestamp) ?? '-'} {String(event.body.message ?? '')}
{nextEventCursor && (
)} ))} {eventMode === 'filter' && !filterLoading && filteredEvents.length === 0 && } {eventMode === 'filter' && filteredEvents.length > 0 && <> {liveTail &&
Live tail active
} {filteredEvents.map((event) => )}
TimestampLog streamMessage
{formatDateTime(event.timestamp) ?? '-'}{event.collectionId ?? '-'}{String(event.body.message ?? '')}
{filterCursor && !liveTail &&
} }
) const summary = (
Streams & events {groupId} {firstStreamPage?.isLoading ? 'Loading…' : `${streams.length} log streams`}
) if (!expanded) return summary return createPortal(
setExpanded(false)}>
event.stopPropagation()}> {content}
, document.body, ) } function mergeEvents(current: Awaited>['items'], incoming: Awaited>['items']) { const byId = new Map(current.map((event) => [event.id, event])) for (const event of incoming) byId.set(event.id, event) return [...byId.values()].sort((left, right) => Date.parse(left.timestamp ?? '') - Date.parse(right.timestamp ?? '')) }