From 78367e91a7665ae5003c18eabca969986a788b04 Mon Sep 17 00:00:00 2001 From: Andrea Cristalli Date: Sat, 8 Aug 2026 07:48:55 +0200 Subject: [PATCH 1/7] perf: virtualize log window, cache fetch, harden backend - StreamLog LogViewer rows so DOM work stays constant regardless of buffer size - Add cached/deduplicated API fetch for autocomplete and autocomplete data - Add WebSocket cleanup on stream unmount to prevent connection leaks - Buffer parse-failure entries while paused - Add stable log ids to avoid row remounts on ring-buffer rollover - Precompile client-side log filters once per log change - Enforce WebSocket origin allowlist (same-origin or ALLOWED_ORIGINS) - Reuse a single Kubernetes client per context across streams - Use --namespace= value form so kubectl args cannot be flag-injected --- CHANGELOG.md | 14 ++ frontend/src/App.jsx | 10 +- frontend/src/components/logs/LogViewer.jsx | 123 ++++++++--- .../src/components/stream/StreamPanel.jsx | 13 +- frontend/src/hooks/useAutoComplete.js | 33 ++- frontend/src/hooks/useWebSocket.js | 59 ++++-- frontend/src/test/setup.js | 8 + frontend/src/utils/__tests__/logUtils.test.js | 26 ++- frontend/src/utils/cache.js | 37 ++++ frontend/src/utils/logUtils.js | 56 +++-- main.go | 200 ++++++++++++++---- main_test.go | 35 ++- 12 files changed, 462 insertions(+), 152 deletions(-) create mode 100644 frontend/src/utils/cache.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d83f61e..27e7f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Virtualized rendering of log rows so DOM work stays constant regardless of how many logs are buffered +- Client-side response caching with in-flight deduplication for autocomplete and cluster UI data +- WebSocket origin allowlist (same-origin or `ALLOWED_ORIGINS`) to prevent arbitrary web pages from proxying this backend +- Precompiled client-side log filters reused across log lines + ### Changed +- Reuse a single Kubernetes client per context across WebSocket streams and cluster API endpoints +- Autocomplete data cached with TTLs (60s for namespaces/contexts/nodes, 8s for pods/containers) +- Log entries carry a stable monotonic id used as the render key, avoiding row remounts when the buffer rolls over +- Log append path avoids a double array copy for every incoming message + ### Fixed +- WebSocket connections no longer leak when a stream panel unmounts (e.g. when switching views) +- Parse-failure log entries are now buffered while paused instead of being dropped +- `kubectl` namespace filters use `--namespace=` form so a value starting with `-` cannot be parsed as a flag + ## [0.5.1] - 2026-08-07 ### Added diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4105864..67b7c0c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react'; import { Header, StreamPanel } from './components'; import { EventsPanel, HealthPanel, ApplyPanel, ResourcesPanel } from './components/management'; import { clearAllSettings, deleteConfig, loadAllConfigs } from './utils/storage'; -import { getApiBase } from './utils/helpers'; +import { cachedFetch } from './utils/cache'; const CLUSTER_KEY = 'stern-ui-cluster'; @@ -22,14 +22,12 @@ function App() { }); useEffect(() => { - const base = getApiBase(); - fetch(`${base}/api/contexts`) - .then((r) => (r.ok ? r.json() : [])) + cachedFetch('/api/contexts', { ttl: 60_000 }) + .catch(() => []) .then((list) => { setContexts(list); setContext((prev) => prev || (list[0] || '')); - }) - .catch(() => setContexts([])); + }); }, []); useEffect(() => { diff --git a/frontend/src/components/logs/LogViewer.jsx b/frontend/src/components/logs/LogViewer.jsx index 1a42356..4fa89cc 100644 --- a/frontend/src/components/logs/LogViewer.jsx +++ b/frontend/src/components/logs/LogViewer.jsx @@ -1,31 +1,70 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; +const ROW_HEIGHT = 20; +const OVERSCAN = 12; +const FOLLOW_THRESHOLD = 40; + /** - * Main log display area with auto-scroll capability + * Main log display area with virtualized rendering and auto-scroll capability. + * Only renders the rows visible in the viewport plus an overscan buffer, + * so DOM work stays constant regardless of how many logs are buffered. */ export function LogViewer({ logs = [], podColorMap = {}, autoScroll = true }) { - const logEndRef = useRef(null); const containerRef = useRef(null); + const followedRef = useRef(true); + const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 }); + + // Track scroll position and viewport size. Mark whether user is pinned to + // the bottom so incoming logs only scroll if they're still at the tail. + useEffect(() => { + const el = containerRef.current; + if (!el) return undefined; + + const update = () => { + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD; + followedRef.current = autoScroll && atBottom; + setViewport({ scrollTop: el.scrollTop, height: el.clientHeight }); + }; + + update(); + el.addEventListener('scroll', update, { passive: true }); + const observer = new ResizeObserver(update); + observer.observe(el); + + return () => { + el.removeEventListener('scroll', update); + observer.disconnect(); + }; + }, [autoScroll]); + + // Jump to the tail whenever new logs arrive while pinned to the bottom. + const prevLengthRef = useRef(0); + useEffect(() => { + const el = containerRef.current; + const prevLength = prevLengthRef.current; + prevLengthRef.current = logs.length; + if (prevLength >= logs.length) return; + if (!el || logs.length === 0 || !followedRef.current) return; + el.scrollTop = el.scrollHeight; + followedRef.current = true; + }, [logs]); const scrollToTop = () => { containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); }; const scrollToBottom = () => { - logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + const el = containerRef.current; + if (!el) return; + followedRef.current = true; + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); }; - useEffect(() => { - if (autoScroll && logEndRef.current) { - logEndRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }, [logs, autoScroll]); - const getLevelColor = (level) => { const colors = { error: 'text-red-500', @@ -36,6 +75,14 @@ export function LogViewer({ return colors[level?.toLowerCase()] || 'text-gray-300'; }; + // Compute the visible slice from the current scroll position. + const totalHeight = logs.length * ROW_HEIGHT; + const firstVisible = Math.floor(viewport.scrollTop / ROW_HEIGHT); + const visibleCount = Math.ceil(viewport.height / ROW_HEIGHT) + OVERSCAN * 2; + const start = Math.max(0, firstVisible - OVERSCAN); + const end = Math.min(logs.length, start + visibleCount); + const visibleLogs = logs.slice(start, end); + return (
{/* Jump to Top/Bottom Buttons */} @@ -69,29 +116,45 @@ export function LogViewer({

Update filters if needed, then click Connect to start streaming.

) : ( - logs.map((log, idx) => ( -
- +
+
- [{log.pod}] - - {log.level && ( - - {log.level.toUpperCase()} - - )} - - {log.message || log.text || ''} - + {visibleLogs.map((log, idx) => { + const absoluteIdx = start + idx; + return ( +
+ + [{log.pod}] + + {log.level && ( + + {log.level.toUpperCase()} + + )} + + {log.message || log.text || ''} + +
+ ); + })} +
- )) + )} -
); diff --git a/frontend/src/components/stream/StreamPanel.jsx b/frontend/src/components/stream/StreamPanel.jsx index f695dcc..5d0764e 100644 --- a/frontend/src/components/stream/StreamPanel.jsx +++ b/frontend/src/components/stream/StreamPanel.jsx @@ -10,6 +10,7 @@ import { saveConfig } from '../../utils/storage'; import { buildPodColorMap, countLogLevels, + compileLogFilters, filterLogs } from '../../utils/logUtils'; import { DEFAULT_CONFIG } from '../../constants'; @@ -90,13 +91,15 @@ export function StreamPanel({ streamId, initialConfig, onStreamStateChange, isAc return countLogLevels(logs); }, [logs, isActive]); + // Precompile client-side filters once per config change (not per log line) + const logFilters = useMemo(() => { + return compileLogFilters({ query: config.query, container: config.container }); + }, [config.query, config.container]); + const filteredLogs = useMemo(() => { if (!isActive) return logs; - return filterLogs(logs, { - query: config.query, - container: config.container - }); - }, [logs, config.query, config.container, isActive]); + return filterLogs(logs, logFilters); + }, [logs, logFilters, isActive]); const handleConnect = useCallback(() => { // eslint-disable-next-line no-unused-vars diff --git a/frontend/src/hooks/useAutoComplete.js b/frontend/src/hooks/useAutoComplete.js index bfcc9dd..cd9fdea 100644 --- a/frontend/src/hooks/useAutoComplete.js +++ b/frontend/src/hooks/useAutoComplete.js @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import { getApiBase } from '../utils/helpers'; +import { cachedFetch } from '../utils/cache'; /** * Custom hook for fetching autocomplete suggestions from the K8s API @@ -16,13 +16,14 @@ export function useAutoComplete(context, namespace) { useEffect(() => { const fetchData = async () => { setLoading(true); - const base = getApiBase(); try { const [nsRes, ctxRes, nodeRes] = await Promise.all([ - fetch(`${base}/api/namespaces?context=${context || ''}`).then(r => r.ok ? r.json() : []), - fetch(`${base}/api/contexts`).then(r => r.ok ? r.json() : []), - fetch(`${base}/api/nodes?context=${context || ''}`).then(r => r.ok ? r.json() : []) + cachedFetch(`/api/namespaces?context=${context || ''}`, { ttl: 60_000 }) + .catch(() => []), + cachedFetch('/api/contexts', { ttl: 60_000 }).catch(() => []), + cachedFetch(`/api/nodes?context=${context || ''}`, { ttl: 60_000 }) + .catch(() => []) ]); setNamespaces(nsRes || []); setContexts(ctxRes || []); @@ -54,8 +55,6 @@ export function useAutoComplete(context, namespace) { // Fetch pods when namespace or context changes useEffect(() => { const fetchPods = async () => { - const base = getApiBase(); - // Only fetch if namespace is in the valid list if (!namespace || !namespaces.includes(namespace)) { setPods([]); @@ -67,12 +66,9 @@ export function useAutoComplete(context, namespace) { if (context) params.set('context', context); params.set('namespace', namespace); - const res = await fetch(`${base}/api/pods?${params}`); - if (res.ok) { - setPods(await res.json() || []); - } else { - setPods([]); - } + const res = await cachedFetch(`/api/pods?${params}`, { ttl: 8_000 }) + .catch(() => []); + setPods(res || []); } catch (e) { console.error('Failed to fetch pods:', e); setPods([]); @@ -85,8 +81,6 @@ export function useAutoComplete(context, namespace) { // Fetch containers when namespace or context changes useEffect(() => { const fetchContainers = async () => { - const base = getApiBase(); - // Only fetch if namespace is in the valid list if (!namespace || !namespaces.includes(namespace)) { setContainers([]); @@ -98,12 +92,9 @@ export function useAutoComplete(context, namespace) { if (context) params.set('context', context); params.set('namespace', namespace); - const res = await fetch(`${base}/api/containers?${params}`); - if (res.ok) { - setContainers(await res.json() || []); - } else { - setContainers([]); - } + const res = await cachedFetch(`/api/containers?${params}`, { ttl: 8_000 }) + .catch(() => []); + setContainers(res || []); } catch (e) { console.error('Failed to fetch containers:', e); setContainers([]); diff --git a/frontend/src/hooks/useWebSocket.js b/frontend/src/hooks/useWebSocket.js index 586fb34..f13c9df 100644 --- a/frontend/src/hooks/useWebSocket.js +++ b/frontend/src/hooks/useWebSocket.js @@ -79,8 +79,9 @@ function buildWsParams(config) { /** * Parse log line into log entry */ -function parseLogLine(line) { +function parseLogLine(line, id) { return { + id, timestamp: formatTimestamp(line.timestamp), pod: line.podName, container: line.containerName, @@ -95,8 +96,9 @@ function parseLogLine(line) { /** * Create fallback log entry for parse errors */ -function createFallbackLogEntry(data) { +function createFallbackLogEntry(data, id) { return { + id, timestamp: formatTimestamp(), pod: 'system', container: 'parser', @@ -120,6 +122,7 @@ export function useWebSocket() { const wsRef = useRef(null); const configRef = useRef(null); const untilTimeRef = useRef(null); + const logIdRef = useRef(0); // Keep refs in sync useEffect(() => { @@ -131,10 +134,44 @@ export function useWebSocket() { isPausedRef.current = isPaused; }, [isPaused]); + // Close the socket when the component unmounts so streams don't leak. + useEffect(() => { + return () => { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, []); + + const appendLog = useCallback((logEntry) => { + setLogs(prev => { + if (prev.length < MAX_LOGS) { + return prev.concat([logEntry]); + } + return prev.slice(-(MAX_LOGS - 1)).concat([logEntry]); + }); + }, []); + + const pushToBuffer = useCallback((logEntry) => { + pauseBufferRef.current.push(logEntry); + if (pauseBufferRef.current.length > MAX_BUFFER) { + pauseBufferRef.current = pauseBufferRef.current.slice(-MAX_BUFFER); + } + }, []); + + const routeLog = useCallback((logEntry) => { + if (isPausedRef.current) { + pushToBuffer(logEntry); + } else { + appendLog(logEntry); + } + }, [appendLog, pushToBuffer]); + const handleLogMessage = useCallback((event) => { try { const line = JSON.parse(event.data); - const logEntry = parseLogLine(line); + const logEntry = parseLogLine(line, logIdRef.current++); // Filter by untilTime if in absolute mode if (untilTimeRef.current) { @@ -146,21 +183,11 @@ export function useWebSocket() { } } - if (isPausedRef.current) { - pauseBufferRef.current.push(logEntry); - if (pauseBufferRef.current.length > MAX_BUFFER) { - pauseBufferRef.current = pauseBufferRef.current.slice(-MAX_BUFFER); - } - } else { - setLogs(prev => [...prev, logEntry].slice(-MAX_LOGS)); - } + routeLog(logEntry); } catch { - const logEntry = createFallbackLogEntry(event.data); - if (!isPausedRef.current) { - setLogs(prev => [...prev.slice(-(MAX_LOGS - 1)), logEntry]); - } + routeLog(createFallbackLogEntry(event.data, logIdRef.current++)); } - }, []); + }, [routeLog]); const connect = useCallback((config) => { // Close existing connection using ref diff --git a/frontend/src/test/setup.js b/frontend/src/test/setup.js index 03acc13..9cfa201 100644 --- a/frontend/src/test/setup.js +++ b/frontend/src/test/setup.js @@ -3,6 +3,14 @@ import '@testing-library/jest-dom'; // Mock scrollIntoView globalThis.HTMLElement.prototype.scrollIntoView = function() {}; +// Mock ResizeObserver (jsdom doesn't implement it) +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} +globalThis.ResizeObserver = globalThis.ResizeObserver || ResizeObserverMock; + // Mock localStorage with a fresh store for each test const createLocalStorageMock = () => { let store = {}; diff --git a/frontend/src/utils/__tests__/logUtils.test.js b/frontend/src/utils/__tests__/logUtils.test.js index d463fdb..5c7dba6 100644 --- a/frontend/src/utils/__tests__/logUtils.test.js +++ b/frontend/src/utils/__tests__/logUtils.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { detectLogLevel, filterLogs, buildPodColorMap } from '../logUtils'; +import { detectLogLevel, filterLogs, buildPodColorMap, compileLogFilters } from '../logUtils'; describe('detectLogLevel', () => { it('detects error level', () => { @@ -73,6 +73,30 @@ describe('filterLogs', () => { }); }); +describe('compileLogFilters', () => { + const sampleLogs = [ + { message: 'Error occurred', pod: 'api-pod', container: 'api', level: 'error' }, + { message: 'Warning issued', pod: 'web-pod', container: 'nginx', level: 'warn' }, + { message: 'Info message', pod: 'api-pod', container: 'api', level: 'info' }, + ]; + + it('precompiles filters and reuses them across calls', () => { + const filters = compileLogFilters({ query: 'api-pod', container: 'api' }); + expect(filterLogs(sampleLogs, filters)).toHaveLength(2); + expect(filterLogs(sampleLogs, filters)).toHaveLength(2); + }); + + it('treats the default query of dot as no filter', () => { + const filters = compileLogFilters({ query: '.', container: '' }); + expect(filterLogs(sampleLogs, filters)).toHaveLength(3); + }); + + it('strips pod/container format from container filters', () => { + const filters = compileLogFilters({ query: '.', container: 'api-pod/api' }); + expect(filterLogs(sampleLogs, filters)).toHaveLength(2); + }); +}); + describe('buildPodColorMap', () => { it('assigns colors to unique pods', () => { const logs = [ diff --git a/frontend/src/utils/cache.js b/frontend/src/utils/cache.js new file mode 100644 index 0000000..7e1e3a0 --- /dev/null +++ b/frontend/src/utils/cache.js @@ -0,0 +1,37 @@ +import { apiFetch } from './api'; + +const cache = new Map(); + +/** + * Fetch with in-memory caching and in-flight deduplication. + * Concurrent calls for the same URL share a single request. + */ +export function cachedFetch(url, { ttl = 30_000 } = {}) { + const now = Date.now(); + const entry = cache.get(url); + + if (entry && entry.expiresAt > now) { + return Promise.resolve(entry.value); + } + + if (entry && entry.promise) { + return entry.promise; + } + + const promise = apiFetch(url) + .then((value) => { + cache.set(url, { value, expiresAt: Date.now() + ttl }); + return value; + }) + .catch((err) => { + cache.delete(url); + throw err; + }); + + cache.set(url, { promise }); + return promise; +} + +export function clearFetchCache() { + cache.clear(); +} diff --git a/frontend/src/utils/logUtils.js b/frontend/src/utils/logUtils.js index e439024..ac78cfe 100644 --- a/frontend/src/utils/logUtils.js +++ b/frontend/src/utils/logUtils.js @@ -45,45 +45,55 @@ export function countLogLevels(logs) { } /** - * Helper: Match text against pattern (regex or string) + * Helper: Compile a pattern into a case-insensitive regex. + * Returns null for empty patterns and the raw string when the regex + * fails to compile (falling back to a substring match). */ -function matchesPattern(text, pattern) { +function compilePattern(pattern) { + if (pattern instanceof RegExp) return pattern; + if (!pattern) return null; try { - const regex = new RegExp(pattern, 'i'); - return regex.test(text); + return new RegExp(pattern, 'i'); } catch { - return text.toLowerCase().includes(pattern.toLowerCase()); + return pattern; } } /** - * Helper: Filter by pod query + * Precompile log filters so they aren't rebuilt for every log line. */ -function filterByQuery(logs, query) { - if (!query || query === '.') return logs; - return logs.filter(log => matchesPattern(log.pod || '', query)); +export function compileLogFilters({ query, container } = {}) { + return { + query: compilePattern(query && query !== '.' ? query : null), + container: compilePattern(container + ? (typeof container === 'string' && container.includes('/') + ? container.split('/').pop() + : container) + : null) + }; } /** - * Helper: Filter by container + * Helper: Match text against a precompiled pattern (regex or fallback string) */ -function filterByContainer(logs, container) { - if (!container) return logs; - const containerName = container.includes('/') ? container.split('/').pop() : container; - return logs.filter(log => matchesPattern(log.container || '', containerName)); +function matchesCompiled(text, pattern) { + if (pattern instanceof RegExp) { + return pattern.test(text); + } + return text.toLowerCase().includes(pattern.toLowerCase()); } /** - * Filter logs by query and container + * Filter logs by query and container using precompiled filters. * Applies CLIENT-SIDE filters that work on already-fetched logs without reconnection. * Server-side filters (namespace) require reconnection. + * Accepts either plain strings (compiled on each call) or the output of + * compileLogFilters() to avoid rebuilding regexes per log line. */ -export function filterLogs(logs, { query, container }) { - let result = logs; - - // Client-side filters (apply to already-fetched logs) - result = filterByQuery(result, query); - result = filterByContainer(result, container); - - return result; +export function filterLogs(logs, filters = {}) { + const compiled = compileLogFilters(filters); + return logs.filter(log => + (!compiled.query || matchesCompiled(log.pod || '', compiled.query)) && + (!compiled.container || matchesCompiled(log.container || '', compiled.container)) + ); } diff --git a/main.go b/main.go index e936eb4..6d99ba1 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "io/fs" "log" "net/http" + "net/url" "os" "os/exec" "regexp" @@ -43,12 +44,48 @@ func debugLog(format string, args ...interface{}) { var frontendFS embed.FS var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: checkWebSocketOrigin, HandshakeTimeout: 10 * time.Second, ReadBufferSize: 4096, WriteBufferSize: 4096, } +// allowedOrigins contains host[:port] values (besides the request's own host) +// that may open WebSocket connections. Defaults to the Vite dev server so the +// frontend can reach the backend during development. Extend via ALLOWED_ORIGINS. +var allowedOrigins = func() map[string]struct{} { + set := map[string]struct{}{ + "localhost:5173": {}, + "127.0.0.1:5173": {}, + } + for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") { + if o = strings.ToLower(strings.TrimSpace(o)); o != "" { + set[o] = struct{}{} + } + } + return set +}() + +// checkWebSocketOrigin restricts WebSocket upgrades to the app's own origin +// (or explicitly allowed origins), so third-party pages can't proxy the +// kubectl/stern operations exposed by the backend to localhost. +func checkWebSocketOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + // Non-browser clients (curl, tests, desktop apps) don't send Origin. + return true + } + u, err := url.Parse(origin) + if err != nil { + return false + } + if strings.EqualFold(u.Host, r.Host) { + return true + } + _, ok := allowedOrigins[strings.ToLower(u.Host)] + return ok +} + // WebSocketWriter writes stern output to a WebSocket connection type WebSocketWriter struct { conn *websocket.Conn @@ -73,36 +110,8 @@ func (w *WebSocketWriter) Write(p []byte) (n int, err error) { continue } - // Filter by untilTime if set - if !w.untilTime.IsZero() { - var logEntry map[string]interface{} - if err := json.Unmarshal(line, &logEntry); err == nil { - // Extract timestamp from the message field - // Log format: [2026-01-15T14:44:37.663Z] "GET /adm/v1/administrator/ping..." - if message, ok := logEntry["message"].(string); ok && len(message) > 0 { - // Check if message starts with [ - if message[0] == '[' { - // Find the closing bracket - endIdx := -1 - for i := 1; i < len(message) && i < 30; i++ { - if message[i] == ']' { - endIdx = i - break - } - } - - if endIdx > 0 { - timestampStr := message[1:endIdx] - if logTime, err := time.Parse(time.RFC3339Nano, timestampStr); err == nil { - // If log is after untilTime, skip it - if logTime.After(w.untilTime) { - continue - } - } - } - } - } - } + if w.shouldSkipLine(line) { + continue } if err := w.conn.WriteMessage(websocket.TextMessage, line); err != nil { @@ -112,6 +121,62 @@ func (w *WebSocketWriter) Write(p []byte) (n int, err error) { return len(p), nil } +func (w *WebSocketWriter) shouldSkipLine(line []byte) bool { + if w.untilTime.IsZero() { + return false + } + + logTime, ok := parseLogTime(line) + if !ok { + return false + } + + return logTime.After(w.untilTime) +} + +func parseLogTime(line []byte) (time.Time, bool) { + var logEntry map[string]interface{} + if err := json.Unmarshal(line, &logEntry); err != nil { + return time.Time{}, false + } + + message, ok := logEntry["message"].(string) + if !ok { + return time.Time{}, false + } + + timestampStr, ok := extractTimestamp(message) + if !ok { + return time.Time{}, false + } + + logTime, err := time.Parse(time.RFC3339Nano, timestampStr) + if err != nil { + return time.Time{}, false + } + + return logTime, true +} + +func extractTimestamp(message string) (string, bool) { + if len(message) == 0 || message[0] != '[' { + return "", false + } + + maxEnd := len(message) + if maxEnd > 30 { + maxEnd = 30 + } + + for i := 1; i < maxEnd; i++ { + if message[i] == ']' { + return message[1:i], true + } + } + + return "", false +} + // WriteMessage writes a message with the given type, protected by mutex func (w *WebSocketWriter) WriteMessage(messageType int, data []byte) error { w.mu.Lock() @@ -203,6 +268,56 @@ func createKubeClient(contextName string) (kubernetes.Interface, clientcmd.Clien return clientset, kubeConfig, nil } +// kubeClientEntry caches a Kubernetes client per context along with the config +// used to build it, so multiple streams/requests reuse the same connection +// pool instead of creating a new client per WebSocket connection. +type kubeClientEntry struct { + mu sync.Mutex + clientset *kubernetes.Interface + kubeConfig clientcmd.ClientConfig +} + +var ( + kubeClientCache = map[string]*kubeClientEntry{} + kubeClientCacheMu sync.Mutex +) + +// getKubeClient returns a cached Kubernetes client for a context, creating and +// wiring up the credential refresher on first use. Safe for concurrent use. +func getKubeClient(contextName string) (*kubeClientEntry, error) { + kubeClientCacheMu.Lock() + if entry, ok := kubeClientCache[contextName]; ok { + kubeClientCacheMu.Unlock() + return entry, nil + } + kubeClientCacheMu.Unlock() + + clientset, kubeConfig, err := createKubeClient(contextName) + if err != nil { + return nil, err + } + + entry := &kubeClientEntry{clientset: &clientset, kubeConfig: kubeConfig} + startCredentialRefresher(context.Background(), entry.clientset, contextName, &entry.mu) + + kubeClientCacheMu.Lock() + if existing, ok := kubeClientCache[contextName]; ok { + entry = existing + } else { + kubeClientCache[contextName] = entry + } + kubeClientCacheMu.Unlock() + return entry, nil +} + +// snapshot returns a copy of the current client, safe in relation to the +// background credential refresh which swaps the underlying client. +func (e *kubeClientEntry) snapshot() kubernetes.Interface { + e.mu.Lock() + defer e.mu.Unlock() + return *e.clientset +} + func parseNumericParams(params streamParams) (*int64, time.Duration, int) { var tailLines *int64 if params.tail != "" && params.tail != "-1" { @@ -575,11 +690,13 @@ func streamLogs(c *gin.Context) { writer := &WebSocketWriter{conn: conn, buf: &bytes.Buffer{}} - clientset, kubeConfig, err := createKubeClient(params.contextName) + clientsetEntry, err := getKubeClient(params.contextName) if err != nil { _ = writer.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"error":"%s"}`, err))) return } + kubeConfig := clientsetEntry.kubeConfig + clientset := clientsetEntry.snapshot() tailLines, sinceDuration, maxReq := parseNumericParams(params) namespaces := buildNamespaceList(params, kubeConfig) @@ -636,9 +753,6 @@ func streamLogs(c *gin.Context) { setupWebSocketHandlers(conn, ctx, cancel, writer) - var clientMutex sync.Mutex - startCredentialRefresher(ctx, &clientset, params.contextName, &clientMutex) - if err := stern.Run(ctx, clientset, config); err != nil { _ = writer.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"error":"Stern error: %s"}`, err))) } @@ -748,7 +862,7 @@ func getPods(c *gin.Context) { if allNamespaces == "true" { args = append(args, "--all-namespaces") } else if namespace != "" { - args = append(args, "-n", namespace) + args = append(args, "--namespace="+namespace) } cmd := exec.Command("kubectl", args...) @@ -777,7 +891,7 @@ func getContainers(c *gin.Context) { if allNamespaces == "true" { args = append(args, "--all-namespaces") } else if namespace != "" { - args = append(args, "-n", namespace) + args = append(args, "--namespace="+namespace) } cmd := exec.Command("kubectl", args...) @@ -862,7 +976,7 @@ func getPodMetadata(c *gin.Context) { if ctx != "" { args = append([]string{"--context", ctx}, args...) } - args = append(args, "-n", namespace) + args = append(args, "--namespace="+namespace) cmd := exec.Command("kubectl", args...) output, err := cmd.CombinedOutput() @@ -891,11 +1005,12 @@ func getClusterEvents(c *gin.Context) { ctxName := c.Query("context") namespace := c.Query("namespace") - clientset, _, err := createKubeClient(ctxName) + entry, err := getKubeClient(ctxName) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + clientset := entry.snapshot() events, err := clientset.CoreV1().Events(namespace).List(c.Request.Context(), metav1.ListOptions{}) if err != nil { @@ -968,11 +1083,12 @@ func getClusterHealth(c *gin.Context) { ctxName := c.Query("context") namespace := c.Query("namespace") - clientset, _, err := createKubeClient(ctxName) + entry, err := getKubeClient(ctxName) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + clientset := entry.snapshot() ctx := c.Request.Context() nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) @@ -1142,7 +1258,7 @@ func getClusterResources(c *gin.Context) { args := []string{"--context", ctxName, "get", resource, "-o", "json"} if namespace != "" && !clusterScopedResources[kind] { - args = append(args, "-n", namespace) + args = append(args, "--namespace="+namespace) } cmd := exec.Command("kubectl", args...) @@ -1196,7 +1312,7 @@ func getResourceDetail(c *gin.Context) { args := []string{"--context", ctxName, "get", resource, name, "-o", "yaml"} if namespace != "" && !clusterScopedResources[kind] { - args = append(args, "-n", namespace) + args = append(args, "--namespace="+namespace) } cmd := exec.Command("kubectl", args...) diff --git a/main_test.go b/main_test.go index 7b994e8..9f9490e 100644 --- a/main_test.go +++ b/main_test.go @@ -251,12 +251,31 @@ func TestResourceDetailRejectsBadName(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } -// TestCORSUpgrader tests that the WebSocket upgrader allows all origins -func TestCORSUpgrader(t *testing.T) { - // Verify the upgrader is configured to allow all origins - dummyReq, _ := http.NewRequest("GET", "/", nil) - dummyReq.Header.Set("Origin", "http://localhost:5173") - - result := upgrader.CheckOrigin(dummyReq) - assert.True(t, result, "Upgrader should allow all origins") +// TestWSOriginPolicy verifies the WebSocket upgrader origin restrictions +func TestWSOriginPolicy(t *testing.T) { + t.Run("allows same origin", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/", nil) + req.Host = "localhost:8080" + req.Header.Set("Origin", "http://localhost:8080") + assert.True(t, upgrader.CheckOrigin(req), "Same-origin requests should be allowed") + }) + + t.Run("allows Vite dev origin", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/", nil) + req.Host = "localhost:8080" + req.Header.Set("Origin", "http://localhost:5173") + assert.True(t, upgrader.CheckOrigin(req), "Vite dev origin should be allowed") + }) + + t.Run("rejects unknown origin", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/", nil) + req.Host = "localhost:8080" + req.Header.Set("Origin", "http://evil.example.com") + assert.False(t, upgrader.CheckOrigin(req), "Unknown origins must be rejected") + }) + + t.Run("allows non-browser clients without Origin", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/", nil) + assert.True(t, upgrader.CheckOrigin(req), "Non-browser clients without Origin should be allowed") + }) } From 2eb0fc841e2a479fe219cf39cf7e349ba24f1b7b Mon Sep 17 00:00:00 2001 From: Esysc <> Date: Mon, 10 Aug 2026 07:38:11 +0200 Subject: [PATCH 2/7] fix: stale context validation, log level defaults, filtering UX, auto-reconnect - App.jsx: validate stored context against fetched list, not just falsy check - useAutoComplete: cleanup targets correct localStorage key (stern-ui-cluster) - LogViewer: remove select-none so log lines are copyable - LogFilters: fix double-unwrapped onChange breaking search/level inputs - LogFilters: wire into StreamPanel (was exported but never rendered) - StreamPanel: use ?? instead of || for query default so cleared pods stay cleared - StreamPanel: add query to auto-reconnect check on pod change - logUtils: default unrecognized logs to info instead of unknown --- frontend/src/App.jsx | 9 +++-- frontend/src/components/logs/LogFilters.jsx | 4 +-- frontend/src/components/logs/LogViewer.jsx | 2 +- .../src/components/stream/StreamPanel.jsx | 33 ++++++++++++++++--- frontend/src/hooks/useAutoComplete.js | 5 +-- frontend/src/utils/logUtils.js | 4 +-- 6 files changed, 42 insertions(+), 15 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 67b7c0c..da88486 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,7 +14,7 @@ function nextStreamId(streams) { function App() { const [view, setView] = useState('logs'); const [contexts, setContexts] = useState([]); - const [context, setContext] = useState(() => globalThis.localStorage.getItem(CLUSTER_KEY) || ''); + const [context, setContext] = useState(''); const [streams, setStreams] = useState(() => { const saved = loadAllConfigs(); if (saved.length > 0) return saved; @@ -26,7 +26,12 @@ function App() { .catch(() => []) .then((list) => { setContexts(list); - setContext((prev) => prev || (list[0] || '')); + const stored = globalThis.localStorage.getItem(CLUSTER_KEY); + if (stored && list.includes(stored)) { + setContext(stored); + } else { + setContext(list[0] || ''); + } }); }, []); diff --git a/frontend/src/components/logs/LogFilters.jsx b/frontend/src/components/logs/LogFilters.jsx index 1a4578a..07d437c 100644 --- a/frontend/src/components/logs/LogFilters.jsx +++ b/frontend/src/components/logs/LogFilters.jsx @@ -22,7 +22,7 @@ const LogFiltersComponent = ({ onSearchChange(e.target.value)} + onChange={onSearchChange} placeholder="Filter logs..." className="flex-1" /> @@ -30,7 +30,7 @@ const LogFiltersComponent = ({ onLevelChange(e.target.value)} + onChange={onLevelChange} options={LOG_LEVELS.map(level => ({ value: level, label: level === 'all' diff --git a/frontend/src/components/logs/LogViewer.jsx b/frontend/src/components/logs/LogViewer.jsx index 4fa89cc..82ad929 100644 --- a/frontend/src/components/logs/LogViewer.jsx +++ b/frontend/src/components/logs/LogViewer.jsx @@ -135,7 +135,7 @@ export function LogViewer({ className="flex gap-2 hover:bg-gray-900/50 whitespace-nowrap" > [{log.pod}] diff --git a/frontend/src/components/stream/StreamPanel.jsx b/frontend/src/components/stream/StreamPanel.jsx index 5d0764e..2a58493 100644 --- a/frontend/src/components/stream/StreamPanel.jsx +++ b/frontend/src/components/stream/StreamPanel.jsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import PropTypes from 'prop-types'; import { StreamConfig } from './StreamConfig'; import { StreamActions } from './StreamActions'; -import { LogViewer, LogStatusBar } from '../logs'; +import { LogViewer, LogStatusBar, LogFilters } from '../logs'; import { useWebSocket } from '../../hooks/useWebSocket'; import { useAutoComplete } from '../../hooks/useAutoComplete'; import { useDisplaySettings } from '../../hooks/useDisplaySettings'; @@ -23,10 +23,13 @@ export function StreamPanel({ streamId, initialConfig, onStreamStateChange, isAc return { ...DEFAULT_CONFIG, ...initialConfig, - query: initialConfig?.query || '.' + query: initialConfig?.query ?? '.' }; }); + const [searchFilter, setSearchFilter] = useState(''); + const [levelFilter, setLevelFilter] = useState('all'); + const displaySettings = useDisplaySettings(); const { @@ -98,8 +101,16 @@ export function StreamPanel({ streamId, initialConfig, onStreamStateChange, isAc const filteredLogs = useMemo(() => { if (!isActive) return logs; - return filterLogs(logs, logFilters); - }, [logs, logFilters, isActive]); + let result = filterLogs(logs, logFilters); + if (searchFilter.trim()) { + const q = searchFilter.toLowerCase(); + result = result.filter(log => (log.message || '').toLowerCase().includes(q)); + } + if (levelFilter !== 'all') { + result = result.filter(log => log.level === levelFilter); + } + return result; + }, [logs, logFilters, isActive, searchFilter, levelFilter]); const handleConnect = useCallback(() => { // eslint-disable-next-line no-unused-vars @@ -126,6 +137,7 @@ export function StreamPanel({ streamId, initialConfig, onStreamStateChange, isAc prev.allNamespaces !== curr.allNamespaces || prev.node !== curr.node || prev.context !== curr.context || + prev.query !== curr.query || prev.selector !== curr.selector || prev.tail !== curr.tail || prev.initContainers !== curr.initContainers || @@ -257,6 +269,19 @@ export function StreamPanel({ streamId, initialConfig, onStreamStateChange, isAc )} + {isConnected && ( +
+ { setSearchFilter(''); setLevelFilter('all'); }} + levelCounts={levelCounts} + /> +
+ )} +
key.startsWith('stern-ui-config-')) - .forEach(key => localStorage.removeItem(key)); + localStorage.removeItem('stern-ui-cluster'); } setNamespaces([]); diff --git a/frontend/src/utils/logUtils.js b/frontend/src/utils/logUtils.js index ac78cfe..f1b256f 100644 --- a/frontend/src/utils/logUtils.js +++ b/frontend/src/utils/logUtils.js @@ -5,11 +5,11 @@ import { hashString } from './helpers'; * Detect log level from message content */ export function detectLogLevel(message) { - if (!message) return 'unknown'; + if (!message) return 'info'; for (const [level, { pattern }] of Object.entries(LOG_LEVEL_PATTERNS)) { if (pattern.test(message)) return level; } - return 'unknown'; + return 'info'; } /** From d6d58d2aafcc2bd48a489c8c991aea8333877717 Mon Sep 17 00:00:00 2001 From: Esysc <> Date: Mon, 10 Aug 2026 07:46:40 +0200 Subject: [PATCH 3/7] fix: update test to expect info instead of unknown for unrecognized log patterns --- frontend/src/utils/__tests__/logUtils.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/utils/__tests__/logUtils.test.js b/frontend/src/utils/__tests__/logUtils.test.js index 5c7dba6..9dbd2b2 100644 --- a/frontend/src/utils/__tests__/logUtils.test.js +++ b/frontend/src/utils/__tests__/logUtils.test.js @@ -19,10 +19,10 @@ describe('detectLogLevel', () => { expect(detectLogLevel('TRACE: entering function')).toBe('debug'); }); - it('returns unknown for unrecognized patterns', () => { - expect(detectLogLevel('Just a regular message')).toBe('unknown'); - expect(detectLogLevel('')).toBe('unknown'); - expect(detectLogLevel(null)).toBe('unknown'); + it('returns info for unrecognized patterns', () => { + expect(detectLogLevel('Just a regular message')).toBe('info'); + expect(detectLogLevel('')).toBe('info'); + expect(detectLogLevel(null)).toBe('info'); }); }); From 2b7d6b3facb78a7fad33bae2916b59932e63d36f Mon Sep 17 00:00:00 2001 From: Esysc <> Date: Mon, 10 Aug 2026 07:47:39 +0200 Subject: [PATCH 4/7] fix: guard ResizeObserver for environments where it is unavailable --- frontend/src/components/logs/LogViewer.jsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/logs/LogViewer.jsx b/frontend/src/components/logs/LogViewer.jsx index 82ad929..428af04 100644 --- a/frontend/src/components/logs/LogViewer.jsx +++ b/frontend/src/components/logs/LogViewer.jsx @@ -33,12 +33,15 @@ export function LogViewer({ update(); el.addEventListener('scroll', update, { passive: true }); - const observer = new ResizeObserver(update); - observer.observe(el); + let observer; + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(update); + observer.observe(el); + } return () => { el.removeEventListener('scroll', update); - observer.disconnect(); + observer?.disconnect(); }; }, [autoScroll]); From dd851a290049bf8182f2b63935a775c32f7fbffd Mon Sep 17 00:00:00 2001 From: Esysc <> Date: Mon, 10 Aug 2026 07:48:42 +0200 Subject: [PATCH 5/7] fix: start credential refresher only for winning cache entry to prevent goroutine leak --- main.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 6d99ba1..b6c93c7 100644 --- a/main.go +++ b/main.go @@ -298,15 +298,16 @@ func getKubeClient(contextName string) (*kubeClientEntry, error) { } entry := &kubeClientEntry{clientset: &clientset, kubeConfig: kubeConfig} - startCredentialRefresher(context.Background(), entry.clientset, contextName, &entry.mu) kubeClientCacheMu.Lock() if existing, ok := kubeClientCache[contextName]; ok { - entry = existing - } else { - kubeClientCache[contextName] = entry + kubeClientCacheMu.Unlock() + return existing, nil } + kubeClientCache[contextName] = entry kubeClientCacheMu.Unlock() + + startCredentialRefresher(context.Background(), entry.clientset, contextName, &entry.mu) return entry, nil } From d04f3f87500de51b9309edf20724ae3e2883a5d2 Mon Sep 17 00:00:00 2001 From: Esysc <> Date: Mon, 10 Aug 2026 08:17:21 +0200 Subject: [PATCH 6/7] fix: enforce virtual row height; preserve stored context on transient fetch failure --- frontend/src/App.jsx | 5 +++-- frontend/src/components/logs/LogViewer.jsx | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index da88486..02be73f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -29,9 +29,10 @@ function App() { const stored = globalThis.localStorage.getItem(CLUSTER_KEY); if (stored && list.includes(stored)) { setContext(stored); - } else { - setContext(list[0] || ''); + } else if (list.length > 0) { + setContext(list[0]); } + // ponytail: on empty list (fetch failed), keep stored context — no setContext here }); }, []); diff --git a/frontend/src/components/logs/LogViewer.jsx b/frontend/src/components/logs/LogViewer.jsx index 428af04..d4a0664 100644 --- a/frontend/src/components/logs/LogViewer.jsx +++ b/frontend/src/components/logs/LogViewer.jsx @@ -136,6 +136,7 @@ export function LogViewer({
Date: Mon, 10 Aug 2026 08:18:34 +0200 Subject: [PATCH 7/7] docs: update changelog with recent fixes and additions --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e7f17..222a7b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Client-side response caching with in-flight deduplication for autocomplete and cluster UI data - WebSocket origin allowlist (same-origin or `ALLOWED_ORIGINS`) to prevent arbitrary web pages from proxying this backend - Precompiled client-side log filters reused across log lines +- Log message keyword and level filtering via Search and Level controls in stream panels +- Automatic WebSocket reconnection when pod/container selector changes ### Changed @@ -26,6 +28,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - WebSocket connections no longer leak when a stream panel unmounts (e.g. when switching views) - Parse-failure log entries are now buffered while paused instead of being dropped - `kubectl` namespace filters use `--namespace=` form so a value starting with `-` cannot be parsed as a flag +- Stale Kubernetes context from localStorage validated against available contexts on app load +- Context cleanup on error targets the correct localStorage key (`stern-ui-cluster`) +- Default query `.` no longer reappears after clearing the pod selector +- Unrecognized log lines default to `info` level instead of `unknown` +- Search and Level filter inputs are now editable (removed double-unwrapping of onChange values) +- Log lines are copyable (removed `select-none` CSS on pod name) +- Credential refresher goroutine only starts for the cache entry that wins insertion (prevents leak on race) +- Virtual row height enforced via inline style to match virtualization scroll math +- Stored context preserved on transient `/api/contexts` fetch failure +- ResizeObserver guarded for environments where it is unavailable ## [0.5.1] - 2026-08-07