From 69a1ce850ce62d8f4914e307cf024fac99c664cf Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Thu, 2 Jul 2026 20:56:00 +0300 Subject: [PATCH 1/2] Add real-time log streaming page Connects to the existing /api/v1/ws/logs WebSocket endpoint and streams slog JSON entries with client-side filtering, search highlighting, and virtualized rendering. Messages are batched (150ms flush interval) and only visible rows are rendered to keep the browser responsive under high log volume. The buffer size is configurable via a slider (1000-5000 entries). The WebSocket disconnects and the buffer is freed on navigation away. --- webapp/src/lib/components/Navigation.svelte | 5 + webapp/src/lib/stores/log-stream.ts | 310 ++++++++++++ webapp/src/routes/+layout.svelte | 2 +- webapp/src/routes/logs/+page.svelte | 495 ++++++++++++++++++++ 4 files changed, 811 insertions(+), 1 deletion(-) create mode 100644 webapp/src/lib/stores/log-stream.ts create mode 100644 webapp/src/routes/logs/+page.svelte diff --git a/webapp/src/lib/components/Navigation.svelte b/webapp/src/lib/components/Navigation.svelte index 4f4180f0d..b0e539683 100644 --- a/webapp/src/lib/components/Navigation.svelte +++ b/webapp/src/lib/components/Navigation.svelte @@ -102,6 +102,11 @@ href: resolve('/objects'), label: 'Object Storage', icon: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4' + }, + { + href: resolve('/logs'), + label: 'Logs', + icon: 'M4 6h16M4 10h16M4 14h16M4 18h16' } ]; diff --git a/webapp/src/lib/stores/log-stream.ts b/webapp/src/lib/stores/log-stream.ts new file mode 100644 index 000000000..19788413e --- /dev/null +++ b/webapp/src/lib/stores/log-stream.ts @@ -0,0 +1,310 @@ +import { writable, derived, get } from 'svelte/store'; + +export interface LogRecord { + id: number; + time: string; + level: string; + msg: string; + attrs: Record; + raw: string; +} + +export interface LogFilter { + key: string; + value: string; +} + +export interface LogStreamState { + connected: boolean; + connecting: boolean; + paused: boolean; + error: string | null; + entries: LogRecord[]; + bufferSize: number; + minLevel: string; + filters: LogFilter[]; + filterMode: 'any' | 'all'; + search: string; +} + +const LEVEL_ORDER: Record = { + DEBUG: 0, + INFO: 1, + WARN: 2, + WARNING: 2, + ERROR: 3, +}; + +let nextId = 0; + +function parseLogRecord(raw: string): LogRecord | null { + try { + const obj = JSON.parse(raw); + const { time, level, msg, ...attrs } = obj; + return { + id: nextId++, + time: time || '', + level: (level || '').toUpperCase(), + msg: msg || '', + attrs, + raw, + }; + } catch { + return null; + } +} + +const FLUSH_INTERVAL = 150; + +function createLogStreamStore() { + const { subscribe, set, update } = writable({ + connected: false, + connecting: false, + paused: false, + error: null, + entries: [], + bufferSize: 1000, + minLevel: 'DEBUG', + filters: [], + filterMode: 'any', + search: '', + }); + + let ws: WebSocket | null = null; + let reconnectTimeout: number | null = null; + let reconnectAttempts = 0; + let baseReconnectInterval = 1000; + let reconnectInterval = 1000; + let maxReconnectInterval = 30000; + let manuallyDisconnected = false; + + let pendingRecords: LogRecord[] = []; + let flushTimer: number | null = null; + + function flushPending() { + flushTimer = null; + if (pendingRecords.length === 0) return; + + const batch = pendingRecords; + pendingRecords = []; + + update(s => { + const entries = s.entries.concat(batch); + const excess = entries.length - s.bufferSize; + if (excess > 0) entries.splice(0, excess); + return { ...s, entries }; + }); + } + + function enqueueRecord(record: LogRecord) { + pendingRecords.push(record); + if (flushTimer === null) { + flushTimer = window.setTimeout(flushPending, FLUSH_INTERVAL); + } + } + + function getWebSocketUrl(): string { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + return `${protocol}//${host}/api/v1/ws/logs`; + } + + function connect() { + if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) { + return; + } + + manuallyDisconnected = false; + update(s => ({ ...s, connecting: true, error: null })); + + try { + ws = new WebSocket(getWebSocketUrl()); + + const connectionTimeout = setTimeout(() => { + if (ws && ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + }, 10000); + + ws.onopen = () => { + clearTimeout(connectionTimeout); + reconnectAttempts = 0; + reconnectInterval = baseReconnectInterval; + update(s => ({ ...s, connected: true, connecting: false, error: null })); + }; + + ws.onmessage = (event) => { + const state = get({ subscribe }); + if (state.paused) return; + + const record = parseLogRecord(event.data); + if (!record) return; + + enqueueRecord(record); + }; + + ws.onclose = (event) => { + clearTimeout(connectionTimeout); + const wasManual = event.code === 1000 && manuallyDisconnected; + update(s => ({ + ...s, + connected: false, + connecting: false, + error: event.code !== 1000 ? `Connection closed: ${event.reason || 'Unknown reason'}` : null, + })); + if (!wasManual) { + scheduleReconnect(); + } + }; + + ws.onerror = () => { + clearTimeout(connectionTimeout); + update(s => ({ + ...s, + connected: false, + connecting: false, + error: 'WebSocket connection error', + })); + }; + } catch (err) { + update(s => ({ + ...s, + connected: false, + connecting: false, + error: err instanceof Error ? err.message : 'Failed to connect', + })); + } + } + + function scheduleReconnect() { + if (manuallyDisconnected) return; + if (reconnectTimeout) clearTimeout(reconnectTimeout); + + reconnectAttempts++; + if (reconnectAttempts > 50) { + reconnectAttempts = 1; + reconnectInterval = baseReconnectInterval; + } + + const actualInterval = Math.min(reconnectInterval, maxReconnectInterval); + reconnectTimeout = window.setTimeout(() => { + if (!manuallyDisconnected) { + connect(); + reconnectInterval = Math.min(reconnectInterval * 1.5, maxReconnectInterval); + } + }, actualInterval + Math.random() * 500); + } + + function disconnect() { + manuallyDisconnected = true; + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + pendingRecords = []; + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + if (ws) { + ws.close(1000, 'User disconnected'); + ws = null; + } + update(s => ({ ...s, connected: false, connecting: false, entries: [] })); + } + + function pause() { + update(s => ({ ...s, paused: true })); + } + + function resume() { + update(s => ({ ...s, paused: false })); + } + + function clear() { + pendingRecords = []; + update(s => ({ ...s, entries: [] })); + } + + function setBufferSize(size: number) { + const clamped = Math.max(1000, Math.min(5000, size)); + update(s => { + const entries = s.entries.length > clamped + ? s.entries.slice(s.entries.length - clamped) + : s.entries; + return { ...s, bufferSize: clamped, entries }; + }); + } + + function setMinLevel(level: string) { + update(s => ({ ...s, minLevel: level.toUpperCase() })); + } + + function setFilters(filters: LogFilter[]) { + update(s => ({ ...s, filters })); + } + + function setFilterMode(mode: 'any' | 'all') { + update(s => ({ ...s, filterMode: mode })); + } + + function setSearch(search: string) { + update(s => ({ ...s, search })); + } + + return { + subscribe, + connect, + disconnect, + pause, + resume, + clear, + setBufferSize, + setMinLevel, + setFilters, + setFilterMode, + setSearch, + }; +} + +export const logStreamStore = createLogStreamStore(); + +function matchesFilter(filter: LogFilter, attrs: Record, msg: string): boolean { + if (filter.key === 'msg') { + return msg.toLowerCase().includes(filter.value.toLowerCase()); + } + const val = attrs[filter.key]; + if (val === undefined) return false; + return String(val).toLowerCase().includes(filter.value.toLowerCase()); +} + +export const filteredLogEntries = derived( + logStreamStore, + ($state) => { + const minLevelNum = LEVEL_ORDER[$state.minLevel] ?? 0; + + return $state.entries.filter(entry => { + const entryLevelNum = LEVEL_ORDER[entry.level] ?? 0; + if (entryLevelNum < minLevelNum) return false; + + if ($state.filters.length > 0) { + if ($state.filterMode === 'all') { + if (!$state.filters.every(f => matchesFilter(f, entry.attrs, entry.msg))) return false; + } else { + if (!$state.filters.some(f => matchesFilter(f, entry.attrs, entry.msg))) return false; + } + } + + if ($state.search) { + const term = $state.search.toLowerCase(); + const inMsg = entry.msg.toLowerCase().includes(term); + const inAttrs = Object.entries(entry.attrs).some( + ([k, v]) => k.toLowerCase().includes(term) || String(v).toLowerCase().includes(term) + ); + if (!inMsg && !inAttrs) return false; + } + + return true; + }); + } +); diff --git a/webapp/src/routes/+layout.svelte b/webapp/src/routes/+layout.svelte index 073baed0a..92e7569bb 100644 --- a/webapp/src/routes/+layout.svelte +++ b/webapp/src/routes/+layout.svelte @@ -59,7 +59,7 @@
-
+
diff --git a/webapp/src/routes/logs/+page.svelte b/webapp/src/routes/logs/+page.svelte new file mode 100644 index 000000000..f04dd937f --- /dev/null +++ b/webapp/src/routes/logs/+page.svelte @@ -0,0 +1,495 @@ + + + + Logs - GARM + + +
+ +
+
+
+

Logs

+

+ Real-time GARM log stream +

+
+
+ {#if state.connected} + +
+ Connected +
+ {:else if state.connecting} + +
+ Connecting +
+ {:else if state.error} + +
+ Disconnected +
+ {:else} + +
+ Idle +
+ {/if} +
+
+
+ + +
+
+ +
+ Level: + {#each levels as level} + + {/each} +
+ + +
+
+ + + + logStreamStore.setSearch(e.currentTarget.value)} + class="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+
+ + +
+ + + +
+
+ + + {#if showFilters} +
+
+
+ { if (e.key === 'Enter') addFilter(); }} + class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ Match: + + +
+
+ {#if state.filters.length > 0} +
+ {#each state.filters as filter, idx} + + {filter.key}={filter.value} + + + {/each} +
+ {/if} +
+ {/if} +
+ + +
+ {#if entries.length === 0} +
+ {#if state.connected} +
+ + + +

Waiting for log entries...

+

Log entries will appear here as they arrive

+
+ {:else if state.error} +
+ + + +

{state.error}

+

Log streaming may be disabled in the GARM configuration

+
+ {:else} +
+ + + +

Connecting to log stream...

+
+ {/if} +
+ {:else} + +
+ + {#each visibleEntries as entry, i (entry.id)} + {@const hasAttrs = Object.keys(entry.attrs).length > 0} + {@const isExpanded = expandedEntries.has(entry.id)} +
+ + +
hasAttrs && toggleEntry(entry.id)} + > + + {#if hasAttrs} + {isExpanded ? '▼' : '▶'} + {/if} + + {formatTime(entry.time)} + {entry.level.padEnd(5)} + + {@html highlightSearch(entry.msg, state.search)} + + {#if hasAttrs && !isExpanded} + + {/if} +
+ {#if isExpanded && hasAttrs} +
+ {#each Object.entries(entry.attrs).sort(([a], [b]) => a.localeCompare(b)) as [key, value]} +
+ + {@html highlightSearch(key, state.search)} + = + + {@html highlightSearch(typeof value === 'object' ? JSON.stringify(value) : String(value), state.search)} + +
+ {/each} +
+ {/if} +
+ {/each} + + +
+ {/if} +
+ + +
+
+
+ + {filteredCount} + {#if filteredCount !== entryCount} + / {entryCount} + {/if} + entries + + {#if state.paused} + Paused + {/if} +
+
+
+ + logStreamStore.setBufferSize(parseInt(e.currentTarget.value))} + class="w-24 h-1 bg-gray-300 dark:bg-gray-600 rounded-lg appearance-none cursor-pointer" + /> + {state.bufferSize} +
+ + {#if !autoScroll} + + {/if} +
+
+
+
From 0917d4be12bb7ea0b7e47441e7d5c4b2ee1e4ab3 Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Thu, 2 Jul 2026 21:25:15 +0300 Subject: [PATCH 2/2] Handle log stream not enabled and fix autoscroll --- webapp/src/lib/stores/log-stream.ts | 24 +++++++++++++++++++++++- webapp/src/routes/logs/+page.svelte | 7 ++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/webapp/src/lib/stores/log-stream.ts b/webapp/src/lib/stores/log-stream.ts index 19788413e..600b0f968 100644 --- a/webapp/src/lib/stores/log-stream.ts +++ b/webapp/src/lib/stores/log-stream.ts @@ -109,7 +109,7 @@ function createLogStreamStore() { return `${protocol}//${host}/api/v1/ws/logs`; } - function connect() { + async function connect() { if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) { return; } @@ -118,6 +118,28 @@ function createLogStreamStore() { update(s => ({ ...s, connecting: true, error: null })); try { + // Probe the endpoint with a regular HTTP request first. + // If log streaming is disabled, the server returns 400 before + // the WebSocket upgrade — the browser WS API swallows that, + // so we'd otherwise retry forever with no useful error message. + try { + const probeRes = await fetch(`/api/v1/ws/logs`, { method: 'GET' }); + if (probeRes.status === 400) { + const body = await probeRes.text(); + const msg = body.includes('disabled') + ? 'Log streaming is disabled in the GARM configuration' + : body || 'Log streaming is not available'; + update(s => ({ ...s, connecting: false, error: msg })); + return; + } + if (probeRes.status === 403) { + update(s => ({ ...s, connecting: false, error: 'Admin access required to view logs' })); + return; + } + } catch { + // Probe failed (network error) — fall through and try WS anyway + } + ws = new WebSocket(getWebSocketUrl()); const connectionTimeout = setTimeout(() => { diff --git a/webapp/src/routes/logs/+page.svelte b/webapp/src/routes/logs/+page.svelte index f04dd937f..950a4e3d3 100644 --- a/webapp/src/routes/logs/+page.svelte +++ b/webapp/src/routes/logs/+page.svelte @@ -78,9 +78,10 @@ logStreamStore.disconnect(); }); - let prevEntryCount = 0; - $: if (entries.length !== prevEntryCount) { - prevEntryCount = entries.length; + $: lastEntryId = entries.length > 0 ? entries[entries.length - 1].id : -1; + let prevLastEntryId = -1; + $: if (lastEntryId !== prevLastEntryId) { + prevLastEntryId = lastEntryId; if (autoScroll && logContainer) { tick().then(() => { if (logContainer && autoScroll) {