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..600b0f968 --- /dev/null +++ b/webapp/src/lib/stores/log-stream.ts @@ -0,0 +1,332 @@ +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`; + } + + async function connect() { + if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) { + return; + } + + manuallyDisconnected = false; + 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(() => { + 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..950a4e3d3 --- /dev/null +++ b/webapp/src/routes/logs/+page.svelte @@ -0,0 +1,496 @@ + + + + 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} +
+
+
+