Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,36 @@ 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
- Log message keyword and level filtering via Search and Level controls in stream panels
- Automatic WebSocket reconnection when pod/container selector changes

### 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
- 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

### Added
Expand Down
20 changes: 12 additions & 8 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -14,22 +14,26 @@ 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;
return [{ id: '1', config: {} }];
});

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([]));
const stored = globalThis.localStorage.getItem(CLUSTER_KEY);
if (stored && list.includes(stored)) {
setContext(stored);
} else if (list.length > 0) {
setContext(list[0]);
}
// ponytail: on empty list (fetch failed), keep stored context — no setContext here
});
}, []);

useEffect(() => {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/logs/LogFilters.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ const LogFiltersComponent = ({
<InputField
label="Search"
value={searchFilter}
onChange={(e) => onSearchChange(e.target.value)}
onChange={onSearchChange}
placeholder="Filter logs..."
className="flex-1"
/>

<SelectField
label="Level"
value={levelFilter}
onChange={(e) => onLevelChange(e.target.value)}
onChange={onLevelChange}
options={LOG_LEVELS.map(level => ({
value: level,
label: level === 'all'
Expand Down
127 changes: 97 additions & 30 deletions frontend/src/components/logs/LogViewer.jsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,73 @@
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 });
let observer;
if (typeof ResizeObserver !== 'undefined') {
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',
Expand All @@ -36,6 +78,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 (
<div className="relative flex-1 flex flex-col min-h-0">
{/* Jump to Top/Bottom Buttons */}
Expand Down Expand Up @@ -69,29 +119,46 @@ export function LogViewer({
<p className="text-sm text-gray-600 mt-1">Update filters if needed, then click Connect to start streaming.</p>
</div>
) : (
logs.map((log, idx) => (
<div
key={`${log.pod}-${idx}-${log.message || log.text}`}
className="flex gap-2 hover:bg-gray-900/50 whitespace-nowrap"
>
<span
className="font-semibold select-none shrink-0"
style={{ color: podColorMap[log.pod] || '#888' }}
<>
<div style={{ height: totalHeight, position: 'relative' }}>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${start * ROW_HEIGHT}px)`
}}
>
[{log.pod}]
</span>
{log.level && (
<span className={`font-semibold shrink-0 ${getLevelColor(log.level)}`}>
{log.level.toUpperCase()}
</span>
)}
<span className="flex-1 text-gray-300">
{log.message || log.text || ''}
</span>
{visibleLogs.map((log, idx) => {
const absoluteIdx = start + idx;
return (
<div
key={log.id ?? `${log.pod}-${absoluteIdx}-${log.message || log.text}`}
className="flex gap-2 hover:bg-gray-900/50 whitespace-nowrap"
style={{ height: ROW_HEIGHT, lineHeight: `${ROW_HEIGHT}px` }}
>
<span
className="font-semibold shrink-0"
style={{ color: podColorMap[log.pod] || '#888' }}
>
[{log.pod}]
</span>
{log.level && (
<span className={`font-semibold shrink-0 ${getLevelColor(log.level)}`}>
{log.level.toUpperCase()}
</span>
)}
<span className="flex-1 text-gray-300">
{log.message || log.text || ''}
</span>
</div>
);
})}
</div>
</div>
))
</>
)}
<div ref={logEndRef} />
</div>
</div>
);
Expand Down
42 changes: 35 additions & 7 deletions frontend/src/components/stream/StreamPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ 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';
import { saveConfig } from '../../utils/storage';
import {
buildPodColorMap,
countLogLevels,
compileLogFilters,
filterLogs
} from '../../utils/logUtils';
import { DEFAULT_CONFIG } from '../../constants';
Expand All @@ -22,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 {
Expand Down Expand Up @@ -90,13 +94,23 @@ 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]);
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
Expand All @@ -123,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 ||
Expand Down Expand Up @@ -254,6 +269,19 @@ export function StreamPanel({ streamId, initialConfig, onStreamStateChange, isAc
</div>
)}

{isConnected && (
<div className="mb-4">
<LogFilters
searchFilter={searchFilter}
onSearchChange={setSearchFilter}
levelFilter={levelFilter}
onLevelChange={setLevelFilter}
onResetFilters={() => { setSearchFilter(''); setLevelFilter('all'); }}
levelCounts={levelCounts}
/>
</div>
)}

<div className={displaySettings.showSettings ? 'bg-black border border-gray-800 rounded-lg flex flex-col h-[calc(100vh-650px)] min-h-[400px]' : 'bg-black border border-gray-800 rounded-lg flex flex-col h-[calc(100vh-350px)] min-h-[600px]'}>
<LogStatusBar
isConnected={isConnected}
Expand Down
Loading
Loading