From f2e44d67c0a8857d7cf985fcab93c3b0de3b676c Mon Sep 17 00:00:00 2001 From: John Chantzigoulas Date: Mon, 14 Sep 2026 18:52:24 +0300 Subject: [PATCH 1/5] fix(logs): keep the logs console from overflowing its panel Bound drawer and viewer scrolling to one flex viewport, and wire search into Loki so the sheet can expand without stretching. --- src/app/(dashboard)/logs-viewer/page.tsx | 44 +-- src/app/(dashboard)/template.tsx | 14 +- src/components/logs-viewer/JsonViewer.tsx | 16 +- .../logs-viewer/LogsAccordionList.tsx | 331 +++++++++++++----- src/components/logs-viewer/LogsDrawer.tsx | 144 ++++---- .../logs-viewer/LogsFiltersOptions.tsx | 89 +++-- .../logs-viewer/LogsFiltersPanel.tsx | 327 +++++++++++------ src/components/logs-viewer/LogsViewer.tsx | 30 +- src/components/logs-viewer/LogsWorkspace.tsx | 72 ++++ src/components/ui/drawer.tsx | 12 +- src/lib/loki/requests.ts | 6 +- src/lib/models/logs-viewer/index.ts | 23 +- src/lib/models/logs-viewer/utils.ts | 61 +++- 13 files changed, 782 insertions(+), 387 deletions(-) create mode 100644 src/components/logs-viewer/LogsWorkspace.tsx diff --git a/src/app/(dashboard)/logs-viewer/page.tsx b/src/app/(dashboard)/logs-viewer/page.tsx index b39a69e3d..bf447fe61 100644 --- a/src/app/(dashboard)/logs-viewer/page.tsx +++ b/src/app/(dashboard)/logs-viewer/page.tsx @@ -1,12 +1,13 @@ import LogsViewer from '@/components/logs-viewer/LogsViewer'; import { knownModuleNames } from '@/lib/models/logs-viewer/constants'; -import { LogsData } from '@/lib/models/logs-viewer'; +import { LogsData, LogsQueryParams } from '@/lib/models/logs-viewer'; import { getLogsLevels, getLogsQueryRange, getModules, } from '@/lib/loki/requests'; import { getLokiAvailabilityCore } from '@/lib/observability/lokiAvailabilityCore'; +import { EmptyState } from '@/components/ui/empty-state'; export default async function LogsViewerPage() { const lokiAvailability = await getLokiAvailabilityCore(); @@ -16,26 +17,22 @@ export default async function LogsViewerPage() { if (lokiAvailability.state === 'not_configured') { return ( -
-

Logs viewer is not available

-

- Set LOKI_URL for - this environment to enable log viewing. -

+
+
); } if (lokiAvailability.state === 'unreachable') { return ( -
-

Cannot reach Loki

-

- The UI could not connect to Loki at the configured URL. Check that - Loki is running and that{' '} - LOKI_URL is - correct. -

+
+
); } @@ -55,24 +52,13 @@ export default async function LogsViewerPage() { console.error('Failed to fetch logs levels: ', e); } - const refreshLogs = async (data: { - modules: string[]; - levels: string[]; - startDate: number | undefined; - endDate: number | undefined; - limit: string | undefined; - }) => { + const refreshLogs = async (data: LogsQueryParams) => { 'use server'; return await getLogsQueryRange({ ...data, - modules: data.modules ? data.modules : modules, + modules: data.modules.length > 0 ? data.modules : modules, limit: data.limit ? data.limit : '100', - }) - .then(res => res) - .catch(e => { - console.error('Failed to fetch logs levels: ', e); - return []; - }); + }); }; return ( diff --git a/src/app/(dashboard)/template.tsx b/src/app/(dashboard)/template.tsx index 206a304d3..c6de5f7c2 100644 --- a/src/app/(dashboard)/template.tsx +++ b/src/app/(dashboard)/template.tsx @@ -102,10 +102,12 @@ export default function ModuleHeader({ if (!moduleName) return ( - <> - -
{children}
- +
+ +
+ {children} +
+
); const RESTDocs: { @@ -170,7 +172,7 @@ export default function ModuleHeader({ isCommunicationsSubRoute && pathSegments[1] === 'templates'; return ( -
+
@@ -366,7 +368,7 @@ export default function ModuleHeader({ {children}
- +
); } diff --git a/src/components/logs-viewer/JsonViewer.tsx b/src/components/logs-viewer/JsonViewer.tsx index d4738e03d..a1213bbc1 100644 --- a/src/components/logs-viewer/JsonViewer.tsx +++ b/src/components/logs-viewer/JsonViewer.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useTheme } from 'next-themes'; import { JsonView, @@ -8,12 +10,12 @@ import { import 'react-json-view-lite/dist/index.css'; type JsonViewerProps = { - json: Record; + json: Record; }; const customLightStyles = { ...defaultStyles, - container: `${defaultStyles.container} semantic-json-view m-3 rounded-lg p-3`, + container: `${defaultStyles.container} semantic-json-view m-3 min-w-0 max-w-full rounded-lg p-3`, label: `${defaultStyles.label} semantic-json-view__property`, clickableLabel: `${defaultStyles.clickableLabel} semantic-json-view__property`, nullValue: `${defaultStyles.nullValue} semantic-json-view__null`, @@ -30,7 +32,7 @@ const customLightStyles = { const customDarkStyles = { ...darkStyles, - container: `${darkStyles.container} semantic-json-view m-3 rounded-lg p-3`, + container: `${darkStyles.container} semantic-json-view m-3 min-w-0 max-w-full rounded-lg p-3`, label: `${darkStyles.label} semantic-json-view__property`, clickableLabel: `${darkStyles.clickableLabel} semantic-json-view__property`, nullValue: `${darkStyles.nullValue} semantic-json-view__null`, @@ -49,5 +51,11 @@ export default function JsonViewer({ json }: JsonViewerProps) { const { resolvedTheme } = useTheme(); const styles = resolvedTheme === 'dark' ? customDarkStyles : customLightStyles; - return ; + return ( + + ); } diff --git a/src/components/logs-viewer/LogsAccordionList.tsx b/src/components/logs-viewer/LogsAccordionList.tsx index f1d3aa0fa..fd6ada7d4 100644 --- a/src/components/logs-viewer/LogsAccordionList.tsx +++ b/src/components/logs-viewer/LogsAccordionList.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Accordion, AccordionContent, @@ -9,21 +9,37 @@ import { } from '@/components/ui/accordion'; import { cn } from '@/lib/utils'; import JsonViewer from './JsonViewer'; -import { Files, CheckCheck } from 'lucide-react'; +import { + AlertCircle, + ArrowDown, + CheckCheck, + Files, + ScrollText, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { EmptyState } from '@/components/ui/empty-state'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { LogsData } from '@/lib/models/logs-viewer'; import { + formatLogModule, getFormattedDate, getFormattedMessage, getFormattedMetadata, + getLogDate, } from '@/lib/models/logs-viewer/utils'; +import { useToast } from '@/lib/hooks/use-toast'; + +const NEAR_BOTTOM_PX = 80; -const badgeBackgroundColorVariants = { +const badgeBackgroundColorVariants: Record = { critical: 'border-log-critical/40 bg-log-critical/10 text-log-critical hover:bg-log-critical/15', warning: 'border-log-warning/40 bg-log-warning/10 text-log-warning hover:bg-log-warning/15', + error: + 'border-log-critical/40 bg-log-critical/10 text-log-critical hover:bg-log-critical/15', info: 'border-log-info/40 bg-log-info/10 text-log-info hover:bg-log-info/15', debug: 'border-log-debug/40 bg-log-debug/10 text-log-debug hover:bg-log-debug/15', @@ -34,111 +50,246 @@ const badgeBackgroundColorVariants = { type LogsAccordionListProps = { className?: string; logs: LogsData[]; + isLoading?: boolean; + error?: string | null; + onRetry?: () => void; }; +function LogsListSkeleton() { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ + + + +
+ ))} +
+ ); +} + export function LogsAccordionList({ className, logs, + isLoading = false, + error = null, + onRetry, }: Readonly) { const [value, setValue] = useState(''); - const [copied, setCopied] = useState(false); + const [copiedId, setCopiedId] = useState(null); + const [followLatest, setFollowLatest] = useState(true); const logsContainerRef = useRef(null); - const iconClass = 'w-4 h-4 shrink-0 text-current'; + const { toast } = useToast(); + + const updateFollowLatest = useCallback(() => { + const container = logsContainerRef.current; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + setFollowLatest(distanceFromBottom <= NEAR_BOTTOM_PX); + }, []); + + const scrollToLatest = useCallback(() => { + const container = logsContainerRef.current; + if (!container) return; + container.scrollTop = container.scrollHeight; + setFollowLatest(true); + }, []); - const handleCopyToClipboard = async (json: object) => { + const handleCopyToClipboard = async (id: string, json: object) => { try { await navigator.clipboard.writeText(JSON.stringify(json, null, 2)); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (error) { - console.error('Failed to copy: ', error); + setCopiedId(id); + toast({ + title: 'Copied', + description: 'Log metadata copied to clipboard.', + }); + window.setTimeout(() => { + setCopiedId(current => (current === id ? null : current)); + }, 2000); + } catch { + toast({ + variant: 'destructive', + title: 'Copy failed', + description: 'Could not copy log metadata.', + }); } }; useEffect(() => { - if (logsContainerRef.current && !value) { - logsContainerRef.current.scrollTop = - logsContainerRef.current.scrollHeight; + if (followLatest && !value) { + scrollToLatest(); } - }, [logs]); - - if (logs.length === 0) { - return ( -

- {' '} - There no available logs -

- ); - } + }, [followLatest, logs, scrollToLatest, value]); return ( - - {logs.map(({ level, message, timestamp, module }, index) => { - return ( - - -
- {getFormattedDate(timestamp)} - - {level} - - - {module} - {' '} - {getFormattedMessage(message)} +
+
+ {isLoading ? ( + + ) : ( + <> + {error ? ( +
+ + + Could not load logs + +

{error}

+ {onRetry ? ( + + ) : null} +
+
- - - - {copied ? ( - - - - ) : ( - - )} - - - ); - })} - + ) : null} + {!error && logs.length === 0 ? ( + + ) : null} + {logs.length > 0 ? ( + + {logs.map((log, index) => { + const { level, message, timestamp, module } = log; + const itemId = `item-${timestamp}-${index}`; + const metadata = getFormattedMetadata(message); + const levelKey = (level || 'unknown').toLowerCase(); + const levelClass = + badgeBackgroundColorVariants[levelKey] ?? + badgeBackgroundColorVariants.unknown; + const logDate = getLogDate(timestamp); + const formattedDate = getFormattedDate(timestamp); + + return ( + + +
+ + + {level || 'unknown'} + + + {formatLogModule(module)} + + + {getFormattedMessage(message) || message} + +
+
+ +
+ +
+ {typeof metadata === 'string' ? ( +

+ {metadata} +

+ ) : ( +
+ +
+ )} + {copiedId === itemId ? ( + + + Copied + + ) : ( + + )} +
+
+ ); + })} +
+ ) : null} + + )} +
+ {!followLatest && logs.length > 0 && !isLoading ? ( + + ) : null} +
); } diff --git a/src/components/logs-viewer/LogsDrawer.tsx b/src/components/logs-viewer/LogsDrawer.tsx index 96ed542d5..801eef625 100644 --- a/src/components/logs-viewer/LogsDrawer.tsx +++ b/src/components/logs-viewer/LogsDrawer.tsx @@ -1,7 +1,9 @@ 'use client'; + import { Button } from '@/components/ui/button'; import { Drawer, + DrawerClose, DrawerContent, DrawerTitle, DrawerTrigger, @@ -10,9 +12,9 @@ import { Logs, X } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { usePathname } from 'next/navigation'; import { cn } from '@/lib/utils'; -import { LogsAccordionList } from './LogsAccordionList'; -import LogsFiltersPanel from './LogsFiltersPanel'; -import { LogsData } from '@/lib/models/logs-viewer'; +import { LogsWorkspace } from './LogsWorkspace'; +import { EmptyState } from '@/components/ui/empty-state'; +import { LogsData, LogsQueryParams } from '@/lib/models/logs-viewer'; import { getLogsLevels, getLogsQueryRange, @@ -21,22 +23,21 @@ import { import { getLokiAvailability } from '@/lib/observability/lokiAvailability.actions'; import type { LokiAvailability } from '@/lib/observability/types'; import { knownModuleNames } from '@/lib/models/logs-viewer/constants'; -import { getLokiModuleFilterForPath } from '@/lib/utils/module-utils'; +import { + getLokiModuleFilterForPath, + getModuleDisplayName, +} from '@/lib/utils/module-utils'; const snapPoints = [0.5, 0.75, 1]; -type LogsDrawerProps = { - isSidebarOpen?: boolean; -}; - -export function LogsDrawer({ isSidebarOpen = true }: LogsDrawerProps) { +export function LogsDrawer() { const [lokiAvailability, setLokiAvailability] = useState(null); const [snap, setSnap] = useState(snapPoints[0]); const [levels, setLevels] = useState([]); const [modules, setModules] = useState([]); const [logs, setLogs] = useState([]); - const [drawerHeight, setDrawerHeight] = useState(0); + const [isLoading, setIsLoading] = useState(true); const pathname = usePathname(); const drawerModuleLabel = useMemo( () => @@ -59,19 +60,17 @@ export function LogsDrawer({ isSidebarOpen = true }: LogsDrawerProps) { () => drawerModuleLabel === 'core', [drawerModuleLabel] ); + const moduleDisplayName = + drawerModuleLabel === 'core' + ? 'Core' + : getModuleDisplayName(drawerModuleLabel); useEffect(() => { getLokiAvailability().then(setLokiAvailability); }, [drawerModuleLabel]); const refreshDrawerLogs = useCallback( - async (data: { - modules: string[] | string; - levels?: string[]; - startDate?: number; - endDate?: number; - limit?: string; - }) => { + async (data: LogsQueryParams) => { return await getLogsQueryRange({ ...data, modules: lokiModuleFilter, @@ -80,6 +79,7 @@ export function LogsDrawer({ isSidebarOpen = true }: LogsDrawerProps) { }, [lokiModuleFilter] ); + const lokiReady = lokiAvailability?.state === 'ready'; const showLogsUi = !isLogsViewerPage && @@ -87,16 +87,20 @@ export function LogsDrawer({ isSidebarOpen = true }: LogsDrawerProps) { lokiAvailability.state !== 'not_configured'; useEffect(() => { - if (!lokiReady) return; + if (!lokiReady) { + setIsLoading(false); + return; + } + setIsLoading(true); getLogsLevels() .then(res => { - 'use client'; setLevels(res); }) - .catch(); + .catch(() => { + setLevels([]); + }); getModules() .then(res => { - 'use client'; let validModules = knownModuleNames; if (res.length > 0) { validModules = knownModuleNames.concat( @@ -105,24 +109,10 @@ export function LogsDrawer({ isSidebarOpen = true }: LogsDrawerProps) { } setModules(validModules); }) - .catch(); - refreshDrawerLogs({ modules: lokiModuleFilter }) - .then(res => { - 'use client'; - setLogs(res); - }) - .catch(); - }, [lokiReady, lokiModuleFilter, pathname, refreshDrawerLogs]); - - useEffect(() => { - const height = calculateDrawerHeight() - 124; // subtract height for drawer header & drawer vertical padding - setDrawerHeight(height); - }, [snap]); - - const calculateDrawerHeight = () => { - const windowHeight = window.innerHeight; - return (snap as number) ? windowHeight * (snap as number) : windowHeight; - }; + .catch(() => { + setModules([...knownModuleNames]); + }); + }, [lokiReady, lokiModuleFilter, pathname]); return showLogsUi ? ( - List of logs with filters - - - - {lokiReady ? ( - <> - +
+
+ + Logs + +

+ {moduleDisplayName} +

+
+ + + +
+ {lokiReady ? ( + { + setLogs(value); + setIsLoading(false); + }} modules={modules} - drawerModule={drawerModuleLabel} refreshLogs={refreshDrawerLogs} + drawerModule={drawerModuleLabel} + type="drawer" + isLoading={isLoading} /> -
- -
- - ) : ( -
- Cannot reach Loki at the configured URL. Check that Loki is running - and that LOKI_URL is correct. -
- )} + ) : ( + + )} +
) : null; diff --git a/src/components/logs-viewer/LogsFiltersOptions.tsx b/src/components/logs-viewer/LogsFiltersOptions.tsx index ecd0d7778..47c6d88bb 100644 --- a/src/components/logs-viewer/LogsFiltersOptions.tsx +++ b/src/components/logs-viewer/LogsFiltersOptions.tsx @@ -1,3 +1,5 @@ +'use client'; + import { generateMultiSelectOptions } from '@/lib/models/logs-viewer/utils'; import MultiSelectField from '@/components/ui/form-inputs/MultiSelectField'; import SelectField from '@/components/ui/form-inputs/SelectField'; @@ -6,30 +8,15 @@ import { limitOptions, timeOptions } from '@/lib/models/logs-viewer/constants'; import { cn } from '@/lib/utils'; import { DatePickerField } from '@/components/ui/form-inputs/DatePickerField'; import { Dispatch, SetStateAction, useEffect } from 'react'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; +import { LogsFiltersState } from '@/lib/models/logs-viewer'; -interface LogsFiltersOptionsProps extends React.FormHTMLAttributes { +interface LogsFiltersOptionsProps { levels: string[]; modules: string[]; type?: 'drawer' | 'viewer'; - logsFilters: { - selectedLevels: string[]; - selectedLimit: string | undefined; - selectedModules: string[] | undefined; - selectedTime: string | undefined; - selectedStartDate: Date | undefined; - selectedEndDate: Date | undefined; - }; - setLogsFilters: Dispatch< - SetStateAction<{ - selectedLevels: string[]; - selectedLimit: string | undefined; - selectedModules: string[]; - selectedTime: string | undefined; - selectedStartDate: Date | undefined; - selectedEndDate: Date | undefined; - }> - >; + logsFilters: LogsFiltersState; + setLogsFilters: Dispatch>; className?: string; disabledPopover?: boolean; } @@ -42,11 +29,9 @@ export default function LogsFiltersOptions({ setLogsFilters, disabledPopover = false, className, - ...restProps }: LogsFiltersOptionsProps) { const router = useRouter(); const pathname = usePathname(); - const searchParams = useSearchParams(); const { selectedLevels, selectedLimit, @@ -54,44 +39,59 @@ export default function LogsFiltersOptions({ selectedTime, selectedStartDate, selectedEndDate, + searchTerm, } = logsFilters; const wrapperClass = - 'mx-5 mb-5 flex flex-col rounded-md border border-border bg-surface-2 px-3 pt-3 pb-4'; + 'mx-4 mb-3 flex flex-col rounded-md border border-border bg-surface-2 px-3 pt-3 pb-4'; const selectTriggerClass = 'mt-0 bg-surface-1'; - const formItemClass = 'space-y-1'; + const formItemClass = 'flex flex-col gap-1'; const levelOptions = generateMultiSelectOptions(levels); const moduleOptions = generateMultiSelectOptions(modules); - const updateURLParams = () => { + const moreFiltersParams = new URLSearchParams(); + if (selectedTime) moreFiltersParams.set('time', selectedTime); + if (selectedLimit) moreFiltersParams.set('limit', selectedLimit); + if (selectedLevels.length > 0) + moreFiltersParams.set('levels', selectedLevels.join(',')); + if (searchTerm) moreFiltersParams.set('search', searchTerm); + const moreFiltersQuery = moreFiltersParams.toString(); + const moreFiltersHref = moreFiltersQuery + ? `/logs-viewer?${moreFiltersQuery}` + : '/logs-viewer'; + + useEffect(() => { + if (type !== 'viewer') return; const params = new URLSearchParams(); if (selectedTime) params.set('time', selectedTime); if (selectedLimit) params.set('limit', selectedLimit); - if (selectedLevels && selectedLevels.length > 0) + if (selectedLevels.length > 0) params.set('levels', selectedLevels.join(',')); - if (selectedModules && selectedModules.length > 0) + if (selectedModules.length > 0) params.set('modules', selectedModules.join(',')); if (selectedStartDate) params.set('startDate', selectedStartDate.toISOString()); if (selectedEndDate) params.set('endDate', selectedEndDate.toISOString()); - router.replace(`${pathname}?${params.toString()}`); - }; - - useEffect(() => { - updateURLParams(); + if (searchTerm) params.set('search', searchTerm); + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname); }, [ - selectedTime, - selectedLimit, + pathname, + router, + searchTerm, + selectedEndDate, selectedLevels, + selectedLimit, selectedModules, selectedStartDate, - selectedEndDate, + selectedTime, + type, ]); const renderDrawerFilters = ( -
-
+
+
More filters @@ -144,8 +144,8 @@ export default function LogsFiltersOptions({ ); const renderViewerFilters = ( -
-
+
+
-
-
+
+
setLogsFilters(prevState => ({ @@ -193,7 +192,7 @@ export default function LogsFiltersOptions({ /> diff --git a/src/components/logs-viewer/LogsFiltersPanel.tsx b/src/components/logs-viewer/LogsFiltersPanel.tsx index ed1f91b50..f46bce266 100644 --- a/src/components/logs-viewer/LogsFiltersPanel.tsx +++ b/src/components/logs-viewer/LogsFiltersPanel.tsx @@ -6,21 +6,31 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from 'react'; -import { Filter, RefreshCw } from 'lucide-react'; +import { Filter, RefreshCw, Search } from 'lucide-react'; import { Toggle } from '@/components/ui/toggle'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; -import { SearchInput } from '@/components/ui/form-inputs/SearchInput'; +import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; -import { usePathname } from 'next/navigation'; import LogsFiltersOptions from './LogsFiltersOptions'; -import { LogsData } from '@/lib/models/logs-viewer'; +import { + LogsData, + LogsFiltersState, + RefreshLogsFn, +} from '@/lib/models/logs-viewer'; import { getTimestamp } from '@/lib/models/logs-viewer/utils'; import { debounce, throttle } from 'lodash'; -import { getLogsLevels } from '@/lib/loki/requests'; +import { useSearchParams } from 'next/navigation'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; type LogsFiltersPanelProps = { className?: string; @@ -30,13 +40,11 @@ type LogsFiltersPanelProps = { open?: boolean; type?: 'drawer' | 'viewer'; drawerModule?: string; - refreshLogs: (data: { - modules: string[]; - levels: string[]; - startDate: number | undefined; - endDate: number | undefined; - limit: string | undefined; - }) => Promise; + refreshLogs: RefreshLogsFn; + isRefreshing?: boolean; + onRefreshingChange?: (isRefreshing: boolean) => void; + onError?: (message: string | null) => void; + retryTick?: number; }; export default function LogsFiltersPanel({ @@ -48,20 +56,39 @@ export default function LogsFiltersPanel({ open = false, drawerModule, refreshLogs, + isRefreshing = false, + onRefreshingChange, + onError, + retryTick = 0, }: Readonly) { + const searchParams = useSearchParams(); const [openFilters, setOpenFilters] = useState(open); - const iconClass = 'w-4 h-4 shrink-0'; - const pathname = usePathname(); - const isLogsViewerPage = pathname === '/logs-viewer'; - const [logsFilters, setLogsFilters] = useState({ - selectedLevels: [] as string[], - selectedLimit: undefined as string | undefined, - selectedModules: drawerModule ? [drawerModule] : ([] as string[]), - selectedTime: undefined as string | undefined, // filter for drawer - selectedStartDate: undefined as Date | undefined, - selectedEndDate: undefined as Date | undefined, + const [logsFilters, setLogsFilters] = useState(() => { + if (type !== 'viewer') { + return { + selectedLevels: [], + selectedLimit: undefined, + selectedModules: drawerModule ? [drawerModule] : [], + selectedTime: undefined, + selectedStartDate: undefined, + selectedEndDate: undefined, + searchTerm: '', + }; + } + const start = searchParams.get('startDate'); + const end = searchParams.get('endDate'); + return { + selectedLevels: + searchParams.get('levels')?.split(',').filter(Boolean) ?? [], + selectedLimit: searchParams.get('limit') ?? undefined, + selectedModules: + searchParams.get('modules')?.split(',').filter(Boolean) ?? [], + selectedTime: searchParams.get('time') ?? undefined, + selectedStartDate: start ? new Date(start) : undefined, + selectedEndDate: end ? new Date(end) : undefined, + searchTerm: searchParams.get('search') ?? '', + }; }); - const [liveReloadChecked, setLiveReloadChecked] = useState(false); let startDate = undefined as number | undefined; @@ -72,136 +99,236 @@ export default function LogsFiltersPanel({ startDate = startTime; endDate = endTime; } else if (logsFilters.selectedStartDate && !logsFilters.selectedEndDate) { - startDate = logsFilters.selectedStartDate?.getTime(); + startDate = logsFilters.selectedStartDate.getTime(); endDate = Date.now(); } else if (logsFilters.selectedStartDate && logsFilters.selectedEndDate) { startDate = logsFilters.selectedStartDate.getTime(); endDate = logsFilters.selectedEndDate.getTime(); - } else { - startDate = undefined; - endDate = undefined; } - const refresh = useCallback(() => { - refreshLogs({ - modules: - logsFilters.selectedModules.length === 0 - ? modules - : logsFilters.selectedModules, - levels: logsFilters.selectedLevels, - startDate: liveReloadChecked ? undefined : startDate, - endDate: liveReloadChecked ? undefined : endDate, - limit: logsFilters.selectedLimit, - }).then(logsData => { + const refresh = useCallback(async () => { + onRefreshingChange?.(true); + try { + const logsData = await refreshLogs({ + modules: + logsFilters.selectedModules.length === 0 + ? modules + : logsFilters.selectedModules, + levels: logsFilters.selectedLevels, + startDate: liveReloadChecked ? undefined : startDate, + endDate: liveReloadChecked ? undefined : endDate, + limit: logsFilters.selectedLimit, + searchTerm: logsFilters.searchTerm.trim() || undefined, + }); setLogs(logsData); - }); + onError?.(null); + } catch { + onError?.( + 'Could not load logs. Check that Loki is running and try again.' + ); + } finally { + onRefreshingChange?.(false); + } }, [ + endDate, + liveReloadChecked, + logsFilters.searchTerm, logsFilters.selectedLevels, - logsFilters.selectedModules, logsFilters.selectedLimit, - logsFilters.selectedTime, + logsFilters.selectedModules, + modules, + onError, + onRefreshingChange, + refreshLogs, + setLogs, startDate, - endDate, ]); + const refreshRef = useRef(refresh); + refreshRef.current = refresh; + + const hasMounted = useRef(false); + const requestDebounce = useMemo( () => debounce(() => { - refresh(); - getLogsLevels(startDate, endDate).then(levels => {}); + void refreshRef.current(); }, 1000), - [ - logsFilters.selectedLevels, - logsFilters.selectedModules, - logsFilters.selectedLimit, - logsFilters.selectedTime, - startDate, - endDate, - ] + [] ); const refreshRequest = useMemo( () => throttle(() => { - refresh(); + void refreshRef.current(); }, 1000), - [refreshLogs] + [] ); - const handleManualRefresh = useCallback(() => { - refreshRequest(); - }, [refreshRequest]); + useEffect(() => { + if (!drawerModule) return; + setLogsFilters(prev => + prev.selectedModules.length === 1 && + prev.selectedModules[0] === drawerModule + ? prev + : { ...prev, selectedModules: [drawerModule] } + ); + }, [drawerModule]); useEffect(() => { + if (!hasMounted.current) { + hasMounted.current = true; + const hasUrlFilters = + type === 'viewer' && + Boolean( + logsFilters.searchTerm || + logsFilters.selectedLevels.length || + logsFilters.selectedLimit || + logsFilters.selectedModules.length || + logsFilters.selectedTime || + logsFilters.selectedStartDate || + logsFilters.selectedEndDate + ); + if (type === 'viewer' && !hasUrlFilters) return; + void refreshRef.current(); + return; + } requestDebounce(); return () => requestDebounce.cancel(); }, [ + logsFilters.searchTerm, logsFilters.selectedLevels, - logsFilters.selectedModules, logsFilters.selectedLimit, + logsFilters.selectedModules, logsFilters.selectedTime, + logsFilters.selectedStartDate, + logsFilters.selectedEndDate, startDate, endDate, + requestDebounce, + refreshLogs, + type, ]); useEffect(() => { - let timer: NodeJS.Timeout | null = null; + if (retryTick > 0) { + void refreshRef.current(); + } + }, [retryTick]); + + useEffect(() => { + let timer: ReturnType | null = null; if (liveReloadChecked) { - if (endDate) - setLogsFilters({ ...logsFilters, selectedEndDate: undefined }); - if (startDate) - setLogsFilters({ ...logsFilters, selectedStartDate: undefined }); - timer = setInterval(() => requestDebounce(), 3000); + setLogsFilters(prev => + prev.selectedEndDate || prev.selectedStartDate || prev.selectedTime + ? { + ...prev, + selectedEndDate: undefined, + selectedStartDate: undefined, + selectedTime: undefined, + } + : prev + ); + timer = setInterval(() => { + void refreshRef.current(); + }, 3000); } return () => { - if (timer) { - clearInterval(timer); - } + if (timer) clearInterval(timer); }; - }, [endDate, liveReloadChecked, requestDebounce, startDate]); + }, [liveReloadChecked]); + + useEffect(() => { + return () => { + requestDebounce.cancel(); + refreshRequest.cancel(); + }; + }, [requestDebounce, refreshRequest]); return (
-
-
-
- +
+
+
+ +
- setOpenFilters(open)} - variant="outline" - > - - - + + + + + + + + Show log filters + + + + + + + {liveReloadChecked ? 'Live refresh is on' : 'Refresh logs'} + + + +
+
+ + + + setLogsFilters(prev => ({ + ...prev, + searchTerm: event.target.value, + })) + } + className="h-8 pl-8" + />
-
- {openFilters && ( + {openFilters ? ( - )} + ) : null}
); } diff --git a/src/components/logs-viewer/LogsViewer.tsx b/src/components/logs-viewer/LogsViewer.tsx index 5dfda1ca3..6c2edd487 100644 --- a/src/components/logs-viewer/LogsViewer.tsx +++ b/src/components/logs-viewer/LogsViewer.tsx @@ -1,20 +1,14 @@ 'use client'; -import { LogsData } from '@/lib/models/logs-viewer'; -import { LogsAccordionList } from './LogsAccordionList'; -import LogsFiltersPanel from './LogsFiltersPanel'; + +import { LogsData, RefreshLogsFn } from '@/lib/models/logs-viewer'; +import { LogsWorkspace } from './LogsWorkspace'; import { useState } from 'react'; type LogsViewerProps = { levelsData: string[]; logsData: LogsData[]; modules: string[]; - refreshLogs: (data: { - modules: string[]; - levels: string[]; - startDate: number | undefined; - endDate: number | undefined; - limit: string | undefined; - }) => Promise; + refreshLogs: RefreshLogsFn; }; export default function LogsViewer({ @@ -26,19 +20,19 @@ export default function LogsViewer({ const [logs, setLogs] = useState(logsData); return ( -
-
-

Logs Viewer

+
+
+

Logs Viewer

- -
); } diff --git a/src/components/logs-viewer/LogsWorkspace.tsx b/src/components/logs-viewer/LogsWorkspace.tsx new file mode 100644 index 000000000..c8be93976 --- /dev/null +++ b/src/components/logs-viewer/LogsWorkspace.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { Dispatch, SetStateAction, useCallback, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { LogsAccordionList } from './LogsAccordionList'; +import LogsFiltersPanel from './LogsFiltersPanel'; +import { LogsData, RefreshLogsFn } from '@/lib/models/logs-viewer'; + +type LogsWorkspaceProps = { + className?: string; + logs: LogsData[]; + setLogs: Dispatch>; + levels: string[]; + modules: string[]; + type?: 'drawer' | 'viewer'; + drawerModule?: string; + openFilters?: boolean; + refreshLogs: RefreshLogsFn; + isLoading?: boolean; +}; + +export function LogsWorkspace({ + className, + logs, + setLogs, + levels, + modules, + type = 'drawer', + drawerModule, + openFilters = false, + refreshLogs, + isLoading = false, +}: Readonly) { + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const [retryTick, setRetryTick] = useState(0); + const [showInitialLoading, setShowInitialLoading] = useState(isLoading); + + const handleRefreshingChange = useCallback((refreshing: boolean) => { + setIsRefreshing(refreshing); + if (!refreshing) setShowInitialLoading(false); + }, []); + + return ( +
+ + setRetryTick(tick => tick + 1)} + /> +
+ ); +} diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index 37757cfb9..a690f73cb 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -36,19 +36,21 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; const DrawerContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + showOverlay?: boolean; + } +>(({ className, children, showOverlay = true, ...props }, ref) => ( - + {showOverlay ? : null} -
+
{children} diff --git a/src/lib/loki/requests.ts b/src/lib/loki/requests.ts index d5926f43a..1c94152d2 100644 --- a/src/lib/loki/requests.ts +++ b/src/lib/loki/requests.ts @@ -1,5 +1,6 @@ 'use server'; import { LogsData, LokiLogsData } from '@/lib/models/logs-viewer'; +import { escapeLogqlString } from '@/lib/models/logs-viewer/utils'; import { getLokiClient } from '@/lib/loki/index'; import { _getEnv } from '@/lib/logic/EnvManager'; import { getLokiAvailabilityCore } from '@/lib/observability/lokiAvailabilityCore'; @@ -90,8 +91,9 @@ export const getLogsQueryRange = async (data: { query += queryParts.join(',') + '}'; - if (searchTerm) { - query += ` |~ "${searchTerm}"`; + const trimmedSearch = searchTerm?.trim(); + if (trimmedSearch) { + query += ` |= "${escapeLogqlString(trimmedSearch)}"`; } const res = await ( await getLokiClient() diff --git a/src/lib/models/logs-viewer/index.ts b/src/lib/models/logs-viewer/index.ts index bb3438967..e51e99288 100644 --- a/src/lib/models/logs-viewer/index.ts +++ b/src/lib/models/logs-viewer/index.ts @@ -7,9 +7,30 @@ export interface LogsData { message: string; level: string; instance?: string; - module?: string[]; + module?: string[] | string; } +export type LogsQueryParams = { + modules: string[]; + levels: string[]; + startDate: number | undefined; + endDate: number | undefined; + limit: string | undefined; + searchTerm?: string; +}; + +export type RefreshLogsFn = (data: LogsQueryParams) => Promise; + +export type LogsFiltersState = { + selectedLevels: string[]; + selectedLimit: string | undefined; + selectedModules: string[]; + selectedTime: string | undefined; + selectedStartDate: Date | undefined; + selectedEndDate: Date | undefined; + searchTerm: string; +}; + export interface LokiLogsData { stream: { instance: string; diff --git a/src/lib/models/logs-viewer/utils.ts b/src/lib/models/logs-viewer/utils.ts index f2bb5c31c..aadecbdaa 100644 --- a/src/lib/models/logs-viewer/utils.ts +++ b/src/lib/models/logs-viewer/utils.ts @@ -1,22 +1,50 @@ import { LogsData, ModuleNames } from '@/lib/models/logs-viewer'; import { format, isValid } from 'date-fns'; -export const getFormattedMessage = (message: LogsData['message']) => - message.slice(0, message?.indexOf('{"')); +export const getFormattedMessage = (message: LogsData['message']) => { + const metadataStartIndex = message?.indexOf('{"'); + if (metadataStartIndex === undefined || metadataStartIndex === -1) { + return message ?? ''; + } + return message.slice(0, metadataStartIndex).trim(); +}; -export const getFormattedMetadata = (message: LogsData['message']) => { +export const getFormattedMetadata = ( + message: LogsData['message'] +): Record | string => { const metadataStartIndex = message?.indexOf('{"'); if (metadataStartIndex === -1 || metadataStartIndex === undefined) { return 'No metadata'; } const metadata = message.slice(metadataStartIndex); - return JSON.parse(metadata); + try { + return JSON.parse(metadata) as Record; + } catch { + return { raw: metadata }; + } }; -export const getFormattedDate = (timestamp: LogsData['timestamp']) => { +export const formatLogModule = (module: LogsData['module']): string => { + if (!module) return 'unknown'; + if (Array.isArray(module)) { + const label = module.filter(Boolean).join(', '); + return label || 'unknown'; + } + return module; +}; + +export const escapeLogqlString = (value: string): string => + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + +export const getLogDate = (timestamp: LogsData['timestamp']): Date | null => { const date = new Date(Number(timestamp) / 1_000_000); - if (!isValid(date)) { + return isValid(date) ? date : null; +}; + +export const getFormattedDate = (timestamp: LogsData['timestamp']) => { + const date = getLogDate(timestamp); + if (!date) { return 'Invalid date'; } return format(date, 'MMM dd, yyyy, hh:mm:ss a'); @@ -53,20 +81,19 @@ export const generateMultiSelectOptions = (options?: string[]) => { export const getTimestamp = (value: string) => { const now = new Date(); if (value === '0') { - // Special case for 'Today' const startTime = new Date(now.setHours(0, 0, 0, 0)); const endTime = new Date(); return { - startDate: startTime.valueOf() * 1000000, - endDate: endTime.valueOf() * 1000000, - }; - } else { - const minutes = parseInt(value, 10); - const endDate = now; - const startDate = new Date(now.getTime() - minutes * 60000); - return { - startTime: startDate.valueOf() * 1000000, - endTime: endDate.valueOf() * 1000000, + startTime: startTime.valueOf() * 1_000_000, + endTime: endTime.valueOf() * 1_000_000, }; } + + const minutes = parseInt(value, 10); + const endDate = now; + const startDate = new Date(now.getTime() - minutes * 60000); + return { + startTime: startDate.valueOf() * 1_000_000, + endTime: endDate.valueOf() * 1_000_000, + }; }; From e70f4c16d1aaefac7c604c4b0d5cc6f710b5e8e7 Mon Sep 17 00:00:00 2001 From: John Chantzigoulas Date: Mon, 14 Sep 2026 18:57:33 +0300 Subject: [PATCH 2/5] fix(logs): pin the log list so Vaul cannot swallow scroll Give the snap drawer a real height, drag only from the handle, and absolutely position the list viewport so it no longer grows with rows. --- src/components/logs-viewer/LogsAccordionList.tsx | 4 +++- src/components/logs-viewer/LogsDrawer.tsx | 5 +++-- src/components/ui/drawer.tsx | 7 +++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/logs-viewer/LogsAccordionList.tsx b/src/components/logs-viewer/LogsAccordionList.tsx index fd6ada7d4..65b87f663 100644 --- a/src/components/logs-viewer/LogsAccordionList.tsx +++ b/src/components/logs-viewer/LogsAccordionList.tsx @@ -131,8 +131,10 @@ export function LogsAccordionList({
event.stopPropagation()} + className="absolute inset-0 overflow-x-hidden overflow-y-scroll overscroll-contain touch-pan-y main-scrollbar" > {isLoading ? ( diff --git a/src/components/logs-viewer/LogsDrawer.tsx b/src/components/logs-viewer/LogsDrawer.tsx index 801eef625..f119f1b8b 100644 --- a/src/components/logs-viewer/LogsDrawer.tsx +++ b/src/components/logs-viewer/LogsDrawer.tsx @@ -117,6 +117,7 @@ export function LogsDrawer() { return showLogsUi ? ( -
+
diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index a690f73cb..a758dd0c8 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -22,6 +22,8 @@ const DrawerPortal = DrawerPrimitive.Portal; const DrawerClose = DrawerPrimitive.Close; +const DrawerHandle = DrawerPrimitive.Handle; + const DrawerOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -45,12 +47,12 @@ const DrawerContent = React.forwardRef< -
+ {children} @@ -112,6 +114,7 @@ export { DrawerOverlay, DrawerTrigger, DrawerClose, + DrawerHandle, DrawerContent, DrawerHeader, DrawerFooter, From 6293ffaf8c8e0920c6093e5a8a012cea57e92147 Mon Sep 17 00:00:00 2001 From: John Chantzigoulas Date: Mon, 14 Sep 2026 19:12:21 +0300 Subject: [PATCH 3/5] fix(logs): align scrolling with drawer snap height --- src/components/logs-viewer/LogsAccordionList.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/logs-viewer/LogsAccordionList.tsx b/src/components/logs-viewer/LogsAccordionList.tsx index 65b87f663..06b15901f 100644 --- a/src/components/logs-viewer/LogsAccordionList.tsx +++ b/src/components/logs-viewer/LogsAccordionList.tsx @@ -133,8 +133,8 @@ export function LogsAccordionList({ ref={logsContainerRef} data-vaul-no-drag="" onScroll={updateFollowLatest} - onWheel={event => event.stopPropagation()} - className="absolute inset-0 overflow-x-hidden overflow-y-scroll overscroll-contain touch-pan-y main-scrollbar" + className="absolute inset-x-0 top-0 overflow-x-hidden overflow-y-auto overscroll-contain touch-pan-y main-scrollbar" + style={{ bottom: 'var(--snap-point-height, 0px)' }} > {isLoading ? ( @@ -286,6 +286,9 @@ export function LogsAccordionList({ size="sm" variant="secondary" className="absolute right-4 bottom-3 shadow-2" + style={{ + transform: 'translateY(calc(var(--snap-point-height, 0px) * -1))', + }} onClick={scrollToLatest} > From 3da8546f36e299479ddd2ad002676ca15726f5eb Mon Sep 17 00:00:00 2001 From: John Chantzigoulas Date: Mon, 14 Sep 2026 19:18:33 +0300 Subject: [PATCH 4/5] fix(logs): show latest entries first --- src/components/logs-viewer/LogsAccordionList.tsx | 12 +++++------- src/lib/loki/requests.ts | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/components/logs-viewer/LogsAccordionList.tsx b/src/components/logs-viewer/LogsAccordionList.tsx index 06b15901f..5606a86d1 100644 --- a/src/components/logs-viewer/LogsAccordionList.tsx +++ b/src/components/logs-viewer/LogsAccordionList.tsx @@ -11,7 +11,7 @@ import { cn } from '@/lib/utils'; import JsonViewer from './JsonViewer'; import { AlertCircle, - ArrowDown, + ArrowUp, CheckCheck, Files, ScrollText, @@ -31,7 +31,7 @@ import { } from '@/lib/models/logs-viewer/utils'; import { useToast } from '@/lib/hooks/use-toast'; -const NEAR_BOTTOM_PX = 80; +const NEAR_TOP_PX = 80; const badgeBackgroundColorVariants: Record = { critical: @@ -89,15 +89,13 @@ export function LogsAccordionList({ const updateFollowLatest = useCallback(() => { const container = logsContainerRef.current; if (!container) return; - const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - setFollowLatest(distanceFromBottom <= NEAR_BOTTOM_PX); + setFollowLatest(container.scrollTop <= NEAR_TOP_PX); }, []); const scrollToLatest = useCallback(() => { const container = logsContainerRef.current; if (!container) return; - container.scrollTop = container.scrollHeight; + container.scrollTop = 0; setFollowLatest(true); }, []); @@ -291,7 +289,7 @@ export function LogsAccordionList({ }} onClick={scrollToLatest} > - + Jump to latest ) : null} diff --git a/src/lib/loki/requests.ts b/src/lib/loki/requests.ts index 1c94152d2..ab2a03592 100644 --- a/src/lib/loki/requests.ts +++ b/src/lib/loki/requests.ts @@ -120,7 +120,7 @@ export const getLogsQueryRange = async (data: { }); }) ); - logs.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + logs.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); } return logs; }; From ce62b63e5559aee4a1d1a718af2070f10d5c4a47 Mon Sep 17 00:00:00 2001 From: John Chantzigoulas Date: Mon, 14 Sep 2026 19:59:21 +0300 Subject: [PATCH 5/5] feat(logs): refine collapsible log rows --- package.json | 1 + .../logs-viewer/LogAccordionItem.tsx | 254 ++++++++++++++++++ .../logs-viewer/LogsAccordionList.tsx | 194 +++---------- src/lib/models/logs-viewer/utils.test.ts | 40 +++ src/lib/models/logs-viewer/utils.ts | 54 +++- 5 files changed, 387 insertions(+), 156 deletions(-) create mode 100644 src/components/logs-viewer/LogAccordionItem.tsx create mode 100644 src/lib/models/logs-viewer/utils.test.ts diff --git a/package.json b/package.json index e5f90b63c..8aa7327b8 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint": "eslint .", "test:semantic-colors": "node --experimental-strip-types --test src/lib/semantic-colors.test.ts src/lib/reactflow-edge-colors.test.ts", "test:schema-fields": "node --experimental-strip-types --test src/lib/database/schema-field-definition.test.ts src/lib/database/system-schema-fields.test.ts", + "test:logs-viewer": "node --experimental-strip-types --test src/lib/models/logs-viewer/utils.test.ts", "build:docker": "docker build --platform linux/amd64 -t ghcr.io/conduitplatform/conduit-ui:latest .", "prepare": "husky", "release": "standard-version" diff --git a/src/components/logs-viewer/LogAccordionItem.tsx b/src/components/logs-viewer/LogAccordionItem.tsx new file mode 100644 index 000000000..7b366873e --- /dev/null +++ b/src/components/logs-viewer/LogAccordionItem.tsx @@ -0,0 +1,254 @@ +import { memo } from 'react'; +import { CheckCheck, ClipboardCopy, ScrollText } from 'lucide-react'; + +import { + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import type { LogsData } from '@/lib/models/logs-viewer'; +import { + formatLogDate, + formatLogModule, + getFormattedMessage, + getFormattedMetadata, + getLogDate, + parseLogSummary, +} from '@/lib/models/logs-viewer/utils'; +import type { HttpMethod, LogSummary } from '@/lib/models/logs-viewer/utils'; +import { cn } from '@/lib/utils'; + +import JsonViewer from './JsonViewer'; + +const levelStyles: Record = { + critical: { + badge: + 'border-log-critical/40 bg-log-critical/10 text-log-critical hover:bg-log-critical/15', + rail: 'before:bg-log-critical', + }, + warning: { + badge: + 'border-log-warning/40 bg-log-warning/10 text-log-warning hover:bg-log-warning/15', + rail: 'before:bg-log-warning', + }, + error: { + badge: + 'border-log-critical/40 bg-log-critical/10 text-log-critical hover:bg-log-critical/15', + rail: 'before:bg-log-critical', + }, + info: { + badge: + 'border-log-info/40 bg-log-info/10 text-log-info hover:bg-log-info/15', + rail: 'before:bg-log-info', + }, + debug: { + badge: + 'border-log-debug/40 bg-log-debug/10 text-log-debug hover:bg-log-debug/15', + rail: 'before:bg-log-debug', + }, + unknown: { + badge: + 'border-log-unknown/40 bg-log-unknown/10 text-log-unknown hover:bg-log-unknown/15', + rail: 'before:bg-log-unknown', + }, +}; + +const methodStyles: Record = { + GET: 'text-http-get', + POST: 'text-http-post', + PUT: 'text-http-put', + PATCH: 'text-http-patch', + DELETE: 'text-http-delete', + HEAD: 'text-http-head', + OPTIONS: 'text-http-options', +}; + +type LogAccordionItemProps = { + itemId: string; + log: LogsData; + isCopied: boolean; + onCopy: (id: string, metadata: Record) => void; +}; + +function getStatusStyle(statusCode: number) { + if (statusCode >= 500) return 'text-status-critical'; + if (statusCode >= 400) return 'text-status-warning'; + if (statusCode >= 300) return 'text-status-info'; + if (statusCode >= 200) return 'text-status-healthy'; + return 'text-status-unknown'; +} + +function LogMessage({ summary }: Readonly<{ summary: LogSummary }>) { + switch (summary.type) { + case 'http': + return ( + + + {summary.method} + + + {summary.path} + + + {summary.statusCode} + + + {summary.duration} + + + ); + case 'text': + return ( + + {summary.text} + + ); + default: { + const exhaustiveCheck: never = summary; + return exhaustiveCheck; + } + } +} + +function LogDetails({ + itemId, + message, + isCopied, + onCopy, + dateTime, + formattedDate, +}: Readonly<{ + itemId: string; + message: string; + isCopied: boolean; + onCopy: LogAccordionItemProps['onCopy']; + dateTime?: string; + formattedDate: string; +}>) { + const metadata = getFormattedMetadata(message); + const copyMetadata = + typeof metadata === 'string' ? { message: metadata } : metadata; + + return ( + <> +
+
+ + + Log details + + +
+ +
+ {typeof metadata === 'string' ? ( +

+ {metadata} +

+ ) : ( +
+ +
+ )} + + ); +} + +export const LogAccordionItem = memo(function LogAccordionItem({ + itemId, + log, + isCopied, + onCopy, +}: Readonly) { + const { level, message, timestamp, module } = log; + const formattedMessage = getFormattedMessage(message) || message; + const summary = parseLogSummary(formattedMessage); + const levelKey = (level || 'unknown').toLowerCase(); + const levelStyle = levelStyles[levelKey] ?? levelStyles.unknown; + const logDate = getLogDate(timestamp); + const formattedDate = formatLogDate(logDate); + + return ( + + +
+ + + {level || 'unknown'} + + + {formatLogModule(module)} + + +
+
+ + + +
+ ); +}); diff --git a/src/components/logs-viewer/LogsAccordionList.tsx b/src/components/logs-viewer/LogsAccordionList.tsx index 5606a86d1..6e10ee58f 100644 --- a/src/components/logs-viewer/LogsAccordionList.tsx +++ b/src/components/logs-viewer/LogsAccordionList.tsx @@ -1,51 +1,20 @@ 'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '@/components/ui/accordion'; -import { cn } from '@/lib/utils'; -import JsonViewer from './JsonViewer'; -import { - AlertCircle, - ArrowUp, - CheckCheck, - Files, - ScrollText, -} from 'lucide-react'; +import { AlertCircle, ArrowUp, ScrollText } from 'lucide-react'; + +import { Accordion } from '@/components/ui/accordion'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Skeleton } from '@/components/ui/skeleton'; import { EmptyState } from '@/components/ui/empty-state'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { LogsData } from '@/lib/models/logs-viewer'; -import { - formatLogModule, - getFormattedDate, - getFormattedMessage, - getFormattedMetadata, - getLogDate, -} from '@/lib/models/logs-viewer/utils'; +import { Skeleton } from '@/components/ui/skeleton'; import { useToast } from '@/lib/hooks/use-toast'; +import type { LogsData } from '@/lib/models/logs-viewer'; +import { cn } from '@/lib/utils'; -const NEAR_TOP_PX = 80; +import { LogAccordionItem } from './LogAccordionItem'; -const badgeBackgroundColorVariants: Record = { - critical: - 'border-log-critical/40 bg-log-critical/10 text-log-critical hover:bg-log-critical/15', - warning: - 'border-log-warning/40 bg-log-warning/10 text-log-warning hover:bg-log-warning/15', - error: - 'border-log-critical/40 bg-log-critical/10 text-log-critical hover:bg-log-critical/15', - info: 'border-log-info/40 bg-log-info/10 text-log-info hover:bg-log-info/15', - debug: - 'border-log-debug/40 bg-log-debug/10 text-log-debug hover:bg-log-debug/15', - unknown: - 'border-log-unknown/40 bg-log-unknown/10 text-log-unknown hover:bg-log-unknown/15', -}; +const NEAR_TOP_PX = 80; type LogsAccordionListProps = { className?: string; @@ -61,11 +30,11 @@ function LogsListSkeleton() { {Array.from({ length: 8 }).map((_, index) => (
- + - +
))} @@ -99,25 +68,28 @@ export function LogsAccordionList({ setFollowLatest(true); }, []); - const handleCopyToClipboard = async (id: string, json: object) => { - try { - await navigator.clipboard.writeText(JSON.stringify(json, null, 2)); - setCopiedId(id); - toast({ - title: 'Copied', - description: 'Log metadata copied to clipboard.', - }); - window.setTimeout(() => { - setCopiedId(current => (current === id ? null : current)); - }, 2000); - } catch { - toast({ - variant: 'destructive', - title: 'Copy failed', - description: 'Could not copy log metadata.', - }); - } - }; + const handleCopyToClipboard = useCallback( + async (id: string, json: Record) => { + try { + await navigator.clipboard.writeText(JSON.stringify(json, null, 2)); + setCopiedId(id); + toast({ + title: 'Copied', + description: 'Log metadata copied to clipboard.', + }); + window.setTimeout(() => { + setCopiedId(current => (current === id ? null : current)); + }, 2000); + } catch { + toast({ + variant: 'destructive', + title: 'Copy failed', + description: 'Could not copy log metadata.', + }); + } + }, + [toast] + ); useEffect(() => { if (followLatest && !value) { @@ -177,100 +149,16 @@ export function LogsAccordionList({ className="flex w-full min-w-0 flex-col gap-2 px-4 py-3" > {logs.map((log, index) => { - const { level, message, timestamp, module } = log; - const itemId = `item-${timestamp}-${index}`; - const metadata = getFormattedMetadata(message); - const levelKey = (level || 'unknown').toLowerCase(); - const levelClass = - badgeBackgroundColorVariants[levelKey] ?? - badgeBackgroundColorVariants.unknown; - const logDate = getLogDate(timestamp); - const formattedDate = getFormattedDate(timestamp); + const itemId = `item-${log.timestamp}-${index}`; return ( - - -
- - - {level || 'unknown'} - - - {formatLogModule(module)} - - - {getFormattedMessage(message) || message} - -
-
- -
- -
- {typeof metadata === 'string' ? ( -

- {metadata} -

- ) : ( -
- -
- )} - {copiedId === itemId ? ( - - - Copied - - ) : ( - - )} -
-
+ itemId={itemId} + log={log} + isCopied={copiedId === itemId} + onCopy={handleCopyToClipboard} + /> ); })} diff --git a/src/lib/models/logs-viewer/utils.test.ts b/src/lib/models/logs-viewer/utils.test.ts new file mode 100644 index 000000000..2e1d4bb56 --- /dev/null +++ b/src/lib/models/logs-viewer/utils.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { parseLogSummary } from './utils.ts'; + +describe('parseLogSummary', () => { + it('parses an HTTP request summary', () => { + assert.deepEqual(parseLogSummary('GET /config/router 200 13ms'), { + type: 'http', + method: 'GET', + path: '/config/router', + statusCode: 200, + duration: '13ms', + }); + }); + + it('preserves query strings and alternative duration units', () => { + assert.deepEqual( + parseLogSummary( + 'PATCH /database/records/abc-123?populate=owner%2Cteam 204 1.24s' + ), + { + type: 'http', + method: 'PATCH', + path: '/database/records/abc-123?populate=owner%2Cteam', + statusCode: 204, + duration: '1.24s', + } + ); + }); + + it('returns non-HTTP messages without altering them', () => { + const message = 'Worker completed scheduled cleanup in 8 batches'; + + assert.deepEqual(parseLogSummary(message), { + type: 'text', + text: message, + }); + }); +}); diff --git a/src/lib/models/logs-viewer/utils.ts b/src/lib/models/logs-viewer/utils.ts index aadecbdaa..21481ef4a 100644 --- a/src/lib/models/logs-viewer/utils.ts +++ b/src/lib/models/logs-viewer/utils.ts @@ -1,6 +1,52 @@ -import { LogsData, ModuleNames } from '@/lib/models/logs-viewer'; import { format, isValid } from 'date-fns'; +import type { LogsData, ModuleNames } from './index'; + +export const HTTP_METHODS = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', +] as const; + +const HTTP_SUMMARY_PATTERN = new RegExp( + `^(${HTTP_METHODS.join('|')})\\s+(\\S+)\\s+([1-5]\\d{2})\\s+(\\d+(?:\\.\\d+)?(?:ns|µs|us|ms|s))$` +); + +export type HttpMethod = (typeof HTTP_METHODS)[number]; + +export type LogSummary = + | { + type: 'http'; + method: HttpMethod; + path: string; + statusCode: number; + duration: string; + } + | { + type: 'text'; + text: string; + }; + +export const parseLogSummary = (message: string): LogSummary => { + const match = HTTP_SUMMARY_PATTERN.exec(message); + if (!match) { + return { type: 'text', text: message }; + } + + const [, method, path, statusCode, duration] = match; + return { + type: 'http', + method: method as HttpMethod, + path, + statusCode: Number(statusCode), + duration, + }; +}; + export const getFormattedMessage = (message: LogsData['message']) => { const metadataStartIndex = message?.indexOf('{"'); if (metadataStartIndex === undefined || metadataStartIndex === -1) { @@ -42,14 +88,16 @@ export const getLogDate = (timestamp: LogsData['timestamp']): Date | null => { return isValid(date) ? date : null; }; -export const getFormattedDate = (timestamp: LogsData['timestamp']) => { - const date = getLogDate(timestamp); +export const formatLogDate = (date: Date | null) => { if (!date) { return 'Invalid date'; } return format(date, 'MMM dd, yyyy, hh:mm:ss a'); }; +export const getFormattedDate = (timestamp: LogsData['timestamp']) => + formatLogDate(getLogDate(timestamp)); + export const checkUnknownModuleNames = ( knownModuleNames: ModuleNames[], unknownModuleNames: string[]