Skip to content

Commit 884f6c6

Browse files
authored
Stream logs by default (#857) (#870)
* Stream logs by default (#857) Log viewers now start streaming on mount instead of requiring a click on the Stream button — matching k9s, Lens, ArgoCD, and Rancher, which all follow logs by default. Terminated pods (Succeeded/Failed) still load a static snapshot rather than auto-streaming, since there's nothing to follow. - New optional `autoStream` prop on LogsViewer / WorkloadLogsViewer (default false, so library consumers are unaffected). Radar's wrappers opt in: workload aggregate and dock pod logs default on; single-pod logs gate on phase. - Auto-start is keyed per-container and respects a manual Stop (won't re-arm), and the redundant snapshot fetch is skipped while auto-streaming. - Clear the buffer when a stream starts so the replayed tail (TailLines + Follow) no longer duplicates the snapshot — a pre-existing bug on the manual Stream button too. - Add `s` to toggle streaming (k9s parity), ignored while typing in a field, with a hint in the shortcut bar. * Address review: multi-pod phase gating, stream-error settle, select key guard - MultiPodLogsTab (Job/CronJob children) now gates autoStream on the selected pod's phase, so completed pods show a static snapshot instead of opening a follow stream. Waits for the pod to load before deciding. - useLogStream exposes streamError; the viewers settle the "connecting" state on a failed SSE connection (and surface the error when no logs arrived) instead of spinning forever. - The 's' stream shortcut now also ignores SELECT elements, so type-to- select on the container/range dropdowns isn't hijacked. * Don't report a clean stream end as a connection error EventSource fires a generic 'error' on the normal close that follows the server's 'end', so a live stream ending while watched could surface a false "connection failed" and the connecting spinner could re-appear after a clean end with no logs. Track end-vs-error with endedRef, and replace the ad-hoc connecting predicate with a `connecting` signal from useLogStream that is bounded to the actual connection attempt (set on start, cleared on the first connected/log/end/error/stop) so it can't re-spin. * Fix auto-stream lifecycle: container switch after stop, terminal transition - When the user has Stopped, a container switch now reloads the snapshot instead of leaving the previous container's lines under the new selection (the snapshot fetch was skipped whenever willAutoStream was true). - When autoStream turns off while a stream is open (pod went terminal), stop following so live appends don't race the snapshot fetch. - Don't log/surface the EventSource 'error' that fires on the normal close after a clean 'end'. * Ignore stale EventSource events; disable snapshot controls while streaming - useLogStream guards every listener with an isCurrent() check, so a late async event from a superseded source (after Stop, container switch, or restart) can't corrupt the new stream's state or surface a false "connection failed". - Previous and the log-range select are disabled while streaming — they're snapshot-only controls that otherwise appeared to do nothing under the auto-stream default. Stop to adjust them. * Gate single-pod auto-stream on known phase; survive Strict Mode - PodLogsTab now waits for the pod phase to be known (!!phase) before auto-streaming, matching MultiPodLogsTab — a completed pod is no longer briefly followed while its resource is still loading. - The auto-start effect resets its per-container arm latch on teardown, so React Strict Mode's mount→unmount→mount (dev) re-opens the stream instead of leaving the viewer static after the cleanup closes the EventSource.
1 parent ac30ef4 commit 884f6c6

8 files changed

Lines changed: 209 additions & 27 deletions

File tree

packages/k8s-ui/src/components/logs/LogCore.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,17 +274,27 @@ export function LogCore({
274274
})
275275
}, [])
276276

277-
// Keyboard shortcut: Ctrl+F to open search
277+
// Keyboard shortcuts: Ctrl+F opens search; bare 's' toggles streaming
278+
// (matches k9s). 's' is ignored while typing in a field so it doesn't
279+
// hijack search input or other text entry.
278280
useEffect(() => {
279281
const handleKeyDown = (e: KeyboardEvent) => {
280282
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
281283
e.preventDefault()
282284
search.open()
285+
return
286+
}
287+
if (e.key === 's' && !e.ctrlKey && !e.metaKey && !e.altKey && onStartStream) {
288+
const target = e.target as HTMLElement | null
289+
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) return
290+
e.preventDefault()
291+
if (isStreaming) onStopStream()
292+
else onStartStream()
283293
}
284294
}
285295
window.addEventListener('keydown', handleKeyDown)
286296
return () => window.removeEventListener('keydown', handleKeyDown)
287-
}, [search.open])
297+
}, [search.open, onStartStream, onStopStream, isStreaming])
288298

289299
const handleFollowOutput = useCallback((isAtBottom: boolean) => {
290300
if (isAtBottom) return 'smooth' as const
@@ -846,6 +856,7 @@ export function LogCore({
846856

847857
{/* Keyboard shortcut hints */}
848858
<div className={`flex items-center gap-4 px-3 py-1 border-t ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
859+
{onStartStream && <Shortcut keys="S" label={isStreaming ? 'Stop stream' : 'Stream'} palette={palette} />}
849860
<Shortcut keys="Ctrl+F" label="Search" palette={palette} />
850861
<Shortcut keys="Enter" label="Next match" palette={palette} />
851862
<Shortcut keys="Shift+Enter" label="Prev match" palette={palette} />

packages/k8s-ui/src/components/logs/LogToolbarSelects.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ interface LogRangeSelectProps {
4848
tooltip?: string
4949
/** Dark/light palette selector. Defaults to `true` (dark viewer). */
5050
isDark?: boolean
51+
/** When true, the control is greyed out and non-interactive. */
52+
disabled?: boolean
5153
}
5254

5355
export function LogRangeSelect({
@@ -56,14 +58,16 @@ export function LogRangeSelect({
5658
lineOptions = [100, 500, 1000, 5000],
5759
tooltip = 'How many logs to load — by line count or time range',
5860
isDark = true,
61+
disabled = false,
5962
}: LogRangeSelectProps) {
6063
const palette = getLogPalette(isDark)
6164
return (
62-
<Tooltip content={tooltip} position="bottom">
65+
<Tooltip content={disabled ? 'Stop streaming to change the log range' : tooltip} position="bottom">
6366
<select
6467
value={value}
6568
onChange={(e) => onChange(e.target.value)}
66-
className={`${selectClass(palette)} pr-5`}
69+
disabled={disabled}
70+
className={`${selectClass(palette)} pr-5 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
6771
>
6872
<optgroup label="Lines">
6973
{lineOptions.map(n => (

packages/k8s-ui/src/components/logs/LogsViewer.tsx

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useCallback } from 'react'
1+
import { useState, useEffect, useCallback, useRef } from 'react'
22
import { parseLogLine, parseLogRange } from '../../utils/log-format'
33
import { triggerDownload } from '../../utils/download'
44
import { useLogBuffer } from './useLogBuffer'
@@ -30,6 +30,12 @@ export interface LogsViewerProps {
3030
overrideDownload?: (content: string, mime: string, filename: string) => void
3131
/** Force dark mode on the logs container (default: true) */
3232
forceDark?: boolean
33+
/**
34+
* Open the stream automatically on mount (and on container switch) instead of
35+
* loading a static snapshot. The user can still Stop, and a manual Stop is not
36+
* re-armed. Requires `createStream`. Default: false.
37+
*/
38+
autoStream?: boolean
3339
}
3440

3541
export function LogsViewer({
@@ -41,6 +47,7 @@ export function LogsViewer({
4147
createStream,
4248
overrideDownload,
4349
forceDark,
50+
autoStream = false,
4451
}: LogsViewerProps) {
4552
const [selectedContainer, setSelectedContainer] = useState(initialContainer || containers[0] || '')
4653
const [isLoading, setIsLoading] = useState(false)
@@ -51,7 +58,14 @@ export function LogsViewer({
5158

5259
const { tailLines, sinceSeconds } = parseLogRange(logRange)
5360
const { entries, append, set, clear } = useLogBuffer()
54-
const { isStreaming, startStreaming, stopStreaming } = useLogStream()
61+
const { isStreaming, streamError, connecting, startStreaming, stopStreaming } = useLogStream()
62+
63+
const willAutoStream = autoStream && !!createStream
64+
// Tracks the container we've already auto-started for, so re-renders don't
65+
// re-open the stream, and a container switch arms a fresh auto-start.
66+
const autoStartedForRef = useRef<string | null>(null)
67+
// Once the user explicitly Stops, don't auto-resume for this viewer's lifetime.
68+
const userStoppedRef = useRef(false)
5569

5670
const loadLogs = useCallback(async () => {
5771
if (!selectedContainer) return
@@ -72,11 +86,30 @@ export function LogsViewer({
7286
}
7387
}, [selectedContainer, tailLines, sinceSeconds, showPrevious, fetchLogs, set])
7488

75-
useEffect(() => { loadLogs() }, [loadLogs])
89+
// When auto-streaming the stream supplies the initial tail, so the static
90+
// snapshot fetch is skipped to avoid a redundant request and a flash of
91+
// snapshot content before the stream takes over. If the user has Stopped we
92+
// won't auto-start, so fall back to the snapshot — otherwise a container
93+
// switch would keep showing the previous container's lines.
94+
useEffect(() => {
95+
if (!willAutoStream || userStoppedRef.current) loadLogs()
96+
}, [loadLogs, willAutoStream])
7697
useEffect(() => { stopStreaming() }, [selectedContainer, stopStreaming])
7798

99+
// If auto-stream turns off while a stream is open (e.g. the pod went
100+
// terminal), stop following so live appends don't race the snapshot.
101+
const prevWillAutoStreamRef = useRef(willAutoStream)
102+
useEffect(() => {
103+
if (prevWillAutoStreamRef.current && !willAutoStream && isStreaming) stopStreaming()
104+
prevWillAutoStreamRef.current = willAutoStream
105+
}, [willAutoStream, isStreaming, stopStreaming])
106+
78107
const handleStartStreaming = useCallback(() => {
79108
if (!createStream) return
109+
// The stream replays the last N lines (TailLines + Follow); clear first so
110+
// they don't duplicate lines already in the buffer (the snapshot on the
111+
// manual path, or an earlier stream on restart).
112+
clear()
80113
startStreaming(
81114
() => createStream({ container: selectedContainer, tailLines: 100, sinceSeconds }),
82115
{
@@ -86,8 +119,26 @@ export function LogsViewer({
86119
container: data.container || selectedContainer,
87120
}),
88121
},
122+
'Log stream connection failed',
89123
)
90-
}, [createStream, startStreaming, selectedContainer, sinceSeconds, append])
124+
}, [createStream, startStreaming, selectedContainer, sinceSeconds, append, clear])
125+
126+
const handleStopStreaming = useCallback(() => {
127+
userStoppedRef.current = true
128+
stopStreaming()
129+
}, [stopStreaming])
130+
131+
useEffect(() => {
132+
if (!willAutoStream || !selectedContainer) return
133+
if (userStoppedRef.current) return
134+
if (autoStartedForRef.current === selectedContainer) return
135+
autoStartedForRef.current = selectedContainer
136+
handleStartStreaming()
137+
// Reset the arm latch on teardown so a re-run re-streams — without this,
138+
// React Strict Mode's mount→unmount→mount closes the stream but the latch
139+
// stays set, leaving the viewer static.
140+
return () => { autoStartedForRef.current = null }
141+
}, [willAutoStream, selectedContainer, handleStartStreaming])
91142

92143
const downloadLogs = useCallback((format: DownloadFormat) => {
93144
let content: string
@@ -122,30 +173,35 @@ export function LogsViewer({
122173
<>
123174
<ContainerSelect containers={containers} value={selectedContainer} onChange={setSelectedContainer} isDark={isDark} />
124175

125-
<Tooltip content="Show logs from the pod's previous instance (if it was restarted). Useful for troubleshooting crashed containers." position="bottom">
126-
<label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary}`}>
176+
<Tooltip content={isStreaming ? 'Stop streaming to view the previous instance' : "Show logs from the pod's previous instance (if it was restarted). Useful for troubleshooting crashed containers."} position="bottom">
177+
<label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary} ${isStreaming ? 'opacity-50 cursor-not-allowed' : ''}`}>
127178
<input
128179
type="checkbox"
129180
checked={showPrevious}
130181
onChange={(e) => setShowPrevious(e.target.checked)}
182+
disabled={isStreaming}
131183
className={`w-3 h-3 rounded ${palette.borderLight} ${palette.elevatedBg} text-blue-500 focus:ring-blue-500 focus:ring-offset-0`}
132184
/>
133185
<span className={`border-b border-dotted ${isDark ? 'border-slate-500' : 'border-slate-400'}`}>Previous</span>
134186
</label>
135187
</Tooltip>
136188

137-
<LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} />
189+
<LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} disabled={isStreaming} />
138190
</>
139191
)
140192

193+
// While the auto-stream is opening (before it first settles), show the
194+
// loading state rather than the empty-logs placeholder.
195+
const isConnecting = willAutoStream && connecting && entries.length === 0
196+
141197
return (
142198
<LogCore
143199
entries={entries}
144-
isLoading={isLoading}
145-
errorMessage={fetchError}
200+
isLoading={isLoading || isConnecting}
201+
errorMessage={fetchError || (entries.length === 0 ? streamError : null)}
146202
isStreaming={isStreaming}
147203
onStartStream={createStream ? handleStartStreaming : undefined}
148-
onStopStream={stopStreaming}
204+
onStopStream={handleStopStreaming}
149205
onRefresh={loadLogs}
150206
onDownload={downloadLogs}
151207
onClear={clear}

packages/k8s-ui/src/components/logs/WorkloadLogsViewer.tsx

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,15 @@ export interface WorkloadLogsViewerProps {
4747
overrideDownload?: (content: string, mime: string, filename: string) => void
4848
/** Force dark mode on the logs container (default: true) */
4949
forceDark?: boolean
50+
/**
51+
* Open the stream automatically on mount (and on container switch) instead of
52+
* loading a static snapshot. The user can still Stop, and a manual Stop is not
53+
* re-armed. Requires `createStream`. Default: false.
54+
*/
55+
autoStream?: boolean
5056
}
5157

52-
export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark }: WorkloadLogsViewerProps) {
58+
export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, autoStream = false }: WorkloadLogsViewerProps) {
5359
const [selectedContainer, setSelectedContainer] = useState<string>('')
5460
const [pods, setPods] = useState<WorkloadPodInfo[]>([])
5561
const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
@@ -61,7 +67,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
6167

6268
const { tailLines, sinceSeconds } = parseLogRange(logRange)
6369
const { entries, append, set, clear } = useLogBuffer()
64-
const { isStreaming, startStreaming, stopStreaming } = useLogStream()
70+
const { isStreaming, streamError, connecting, startStreaming, stopStreaming } = useLogStream()
71+
72+
const willAutoStream = autoStream && !!createStream
73+
// null sentinel so the initial selectedContainer ('' = all) still arms once.
74+
const autoStartedForRef = useRef<string | null>(null)
75+
const userStoppedRef = useRef(false)
6576

6677
// Map pod.name → index. Color classes are resolved at render time from the
6778
// current palette (see LogCore / pod-filter dropdown below) so toggling
@@ -105,11 +116,30 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
105116
}
106117
}, [fetchAll, selectedContainer, tailLines, sinceSeconds, set])
107118

108-
useEffect(() => { loadLogs() }, [loadLogs])
119+
// When auto-streaming the stream supplies the initial tail, so the static
120+
// snapshot fetch is skipped to avoid a redundant request and a flash of
121+
// snapshot content before the stream takes over. If the user has Stopped we
122+
// won't auto-start, so fall back to the snapshot — otherwise a container
123+
// switch would keep showing the previous selection's lines.
124+
useEffect(() => {
125+
if (!willAutoStream || userStoppedRef.current) loadLogs()
126+
}, [loadLogs, willAutoStream])
109127
useEffect(() => { stopStreaming() }, [selectedContainer, stopStreaming])
110128

129+
// If auto-stream turns off while a stream is open, stop following so live
130+
// appends don't race the snapshot.
131+
const prevWillAutoStreamRef = useRef(willAutoStream)
132+
useEffect(() => {
133+
if (prevWillAutoStreamRef.current && !willAutoStream && isStreaming) stopStreaming()
134+
prevWillAutoStreamRef.current = willAutoStream
135+
}, [willAutoStream, isStreaming, stopStreaming])
136+
111137
const handleStartStreaming = useCallback(() => {
112138
if (!createStream) return
139+
// The stream replays the last N lines per pod (TailLines + Follow); clear
140+
// first so they don't duplicate lines already in the buffer (the snapshot on
141+
// the manual path, or an earlier stream on restart).
142+
clear()
113143
startStreaming(
114144
() => createStream({ container: selectedContainer || undefined, tailLines: 50, sinceSeconds }),
115145
{
@@ -156,9 +186,26 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
156186
}
157187
},
158188
},
159-
'Workload log stream error',
189+
'Workload log stream connection failed',
160190
)
161-
}, [createStream, startStreaming, selectedContainer, sinceSeconds, append, podColorIndex, selectedPods.size])
191+
}, [createStream, startStreaming, selectedContainer, sinceSeconds, append, podColorIndex, selectedPods.size, clear])
192+
193+
const handleStopStreaming = useCallback(() => {
194+
userStoppedRef.current = true
195+
stopStreaming()
196+
}, [stopStreaming])
197+
198+
useEffect(() => {
199+
if (!willAutoStream) return
200+
if (userStoppedRef.current) return
201+
if (autoStartedForRef.current === selectedContainer) return
202+
autoStartedForRef.current = selectedContainer
203+
handleStartStreaming()
204+
// Reset the arm latch on teardown so a re-run re-streams — without this,
205+
// React Strict Mode's mount→unmount→mount closes the stream but the latch
206+
// stays set, leaving the viewer static.
207+
return () => { autoStartedForRef.current = null }
208+
}, [willAutoStream, selectedContainer, handleStartStreaming])
162209

163210
const allContainers = useMemo(() => {
164211
const s = new Set<string>()
@@ -280,24 +327,29 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
280327
lineOptions={[50, 100, 500, 1000]}
281328
tooltip="How many logs to load per pod — by line count or time range"
282329
isDark={isDark}
330+
disabled={isStreaming}
283331
/>
284332
</>
285333
)
286334

335+
// While the auto-stream is opening (before it first settles), show the
336+
// loading state rather than the empty-logs placeholder.
337+
const isConnecting = willAutoStream && connecting && entries.length === 0
338+
287339
return (
288340
<LogCore
289341
entries={filteredEntries}
290-
isLoading={isLoading}
342+
isLoading={isLoading || isConnecting}
291343
isStreaming={isStreaming}
292344
onStartStream={createStream ? handleStartStreaming : undefined}
293-
onStopStream={stopStreaming}
345+
onStopStream={handleStopStreaming}
294346
onRefresh={loadLogs}
295347
onDownload={downloadLogs}
296348
onClear={clear}
297349
toolbarExtra={renderToolbarExtra}
298350
showPodName
299351
emptyMessage={pods.length === 0 ? 'No pods found' : 'No logs available'}
300-
errorMessage={fetchError}
352+
errorMessage={fetchError || (entries.length === 0 ? streamError : null)}
301353
forceDark={forceDark}
302354
/>
303355
)

0 commit comments

Comments
 (0)