@skyhook-io/k8s-ui 1.17.1 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +13 -0
  2. package/package.json +4 -4
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
  7. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  8. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  9. package/src/components/gitops/detail-helpers.test.ts +58 -13
  10. package/src/components/gitops/detail-helpers.ts +37 -6
  11. package/src/components/gitops/index.ts +1 -0
  12. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
  14. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  15. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  16. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
  17. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
  18. package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
  19. package/src/components/gitops/tree/tree-helpers.ts +5 -1
  20. package/src/components/issues/IssuesView.tsx +18 -10
  21. package/src/components/issues/ResourceIssuesSection.tsx +5 -2
  22. package/src/components/issues/diagnostic.ts +2 -0
  23. package/src/components/issues/index.ts +1 -1
  24. package/src/components/issues/issues.test.ts +26 -0
  25. package/src/components/issues/severity.ts +9 -2
  26. package/src/components/issues/types.ts +32 -4
  27. package/src/components/logs/LogCore.theme.test.tsx +126 -1
  28. package/src/components/logs/LogCore.tsx +159 -40
  29. package/src/components/logs/LogsViewer.test.tsx +183 -60
  30. package/src/components/logs/LogsViewer.tsx +49 -12
  31. package/src/components/logs/StructuredLogLine.tsx +1 -1
  32. package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
  33. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  34. package/src/components/logs/useLogBuffer.ts +48 -18
  35. package/src/components/logs/useLogSearch.ts +3 -1
  36. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  37. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  38. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  39. package/src/components/resources/ColumnPickerList.tsx +174 -0
  40. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  41. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  42. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
  43. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  44. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  45. package/src/components/resources/ResourcesView.tsx +198 -93
  46. package/src/components/resources/column-order.test.ts +104 -0
  47. package/src/components/resources/column-order.ts +64 -0
  48. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  49. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  50. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  51. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  52. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  53. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  54. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  55. package/src/components/resources/renderers/PodRenderer.tsx +72 -20
  56. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  57. package/src/components/resources/resource-utils-trivy.ts +122 -0
  58. package/src/components/resources/resource-utils.ts +15 -2
  59. package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
  60. package/src/components/shared/DetailShell.tsx +9 -7
  61. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  62. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  63. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  64. package/src/components/shared/ManagedByChip.tsx +24 -4
  65. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  66. package/src/components/shared/ResourceActionsBar.tsx +12 -6
  67. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  68. package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
  69. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  70. package/src/components/timeline/DiffViewer.tsx +113 -4
  71. package/src/components/timeline/TimelineList.tsx +5 -1
  72. package/src/components/timeline/shared.test.tsx +48 -0
  73. package/src/components/timeline/shared.tsx +14 -19
  74. package/src/components/ui/FetchResult.test.tsx +19 -2
  75. package/src/components/ui/FetchResult.tsx +13 -1
  76. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  77. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  78. package/src/components/ui/PageHeader.tsx +6 -4
  79. package/src/components/ui/Toast.tsx +48 -30
  80. package/src/components/ui/drawer-components.test.tsx +253 -0
  81. package/src/components/ui/drawer-components.tsx +315 -11
  82. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  83. package/src/components/ui/index.ts +1 -1
  84. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  85. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  86. package/src/components/workload/WorkloadView.tsx +68 -14
  87. package/src/hooks/index.ts +2 -0
  88. package/src/hooks/useMediaQuery.ts +21 -0
  89. package/src/hooks/usePhoneLayout.ts +12 -0
  90. package/src/theme/tailwind-theme.css +5 -0
  91. package/src/types/core.ts +91 -3
  92. package/src/types/gitops-flux-inventory.test.ts +24 -0
  93. package/src/types/gitops-insights.ts +1 -0
  94. package/src/types/gitops-tree.ts +2 -0
  95. package/src/types/gitops.ts +5 -5
  96. package/src/types/index.ts +3 -0
  97. package/src/types/read-coverage.ts +65 -0
  98. package/src/types/resource-coverage.ts +40 -0
  99. package/src/utils/format.test.ts +7 -0
  100. package/src/utils/format.ts +6 -1
  101. package/src/utils/gitops-owner.test.ts +42 -1
  102. package/src/utils/gitops-owner.ts +22 -0
  103. package/src/utils/index.ts +2 -0
  104. package/src/utils/read-actions.test.ts +22 -0
  105. package/src/utils/read-actions.ts +56 -0
  106. package/src/utils/resource-coverage.test.ts +121 -0
  107. package/src/utils/resource-coverage.ts +117 -0
@@ -29,6 +29,23 @@ class ApiError extends Error {
29
29
 
30
30
  const NO_EARLIER_RUN = 'This container has not restarted, so there is no earlier run to show.'
31
31
 
32
+ it('keeps received logs visible and reports a node failure until streaming is restarted', async () => {
33
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
34
+ await renderViewer(fetchLogs, true)
35
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
36
+ await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-20T10:00:00Z', content: 'before failure', container: 'app' }))
37
+ // The buffer batches streamed records before rendering them.
38
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 100)) })
39
+ await act(async () => FakeEventSource.last?.emit('error', { error: 'Kubernetes did not return this container\'s logs', error_code: 'logs_unavailable' }))
40
+ expect(element.querySelector('[role="alert"]')?.textContent).toContain('Kubernetes did not return')
41
+ expect(element.textContent).toContain('Previously loaded logs remain below.')
42
+ expect(element.textContent).toContain('OTHER 1')
43
+ expect(FakeEventSource.last?.closed).toBe(true)
44
+ await act(async () => button('Stream').click())
45
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
46
+ expect(element.querySelector('[role="alert"]')).toBeNull()
47
+ })
48
+
32
49
  let root: Root
33
50
  let element: HTMLDivElement
34
51
  beforeEach(() => {
@@ -43,16 +60,16 @@ afterEach(async () => {
43
60
  vi.unstubAllGlobals()
44
61
  })
45
62
 
46
- async function renderViewer(fetchLogs: (params: LogsFetchParams) => Promise<{ [container: string]: string }>, autoStream = false) {
63
+ async function renderViewer(fetchLogs: (params: LogsFetchParams) => Promise<{ [container: string]: string }>, autoStream = false, containers = ['app'], podName = 'web-0') {
47
64
  await act(async () =>
48
65
  root.render(
49
66
  <ToastProvider>
50
67
  <LogsViewer
51
68
  namespace="default"
52
- podName="web-0"
53
- containers={['app']}
69
+ podName={podName}
70
+ containers={containers}
54
71
  fetchLogs={fetchLogs}
55
- createStream={() => new EventSource('/stream')}
72
+ createStream={() => new EventSource(`/pods/default/${podName}/logs/stream`)}
56
73
  autoStream={autoStream}
57
74
  />
58
75
  </ToastProvider>,
@@ -66,6 +83,67 @@ function previousCheckbox(): HTMLInputElement {
66
83
  return box
67
84
  }
68
85
 
86
+ function refreshButton(): HTMLButtonElement {
87
+ const found = element.querySelector<HTMLButtonElement>('button[aria-label="Refresh logs"]')
88
+ if (!found) throw new Error('Refresh button not rendered')
89
+ return found
90
+ }
91
+
92
+ it('keeps records after a failed Refresh of the same selection and replaces them on success', async () => {
93
+ const fetchLogs = vi.fn(async () => ({ app: '2026-10-20T10:00:00Z retained record' }))
94
+ await renderViewer(fetchLogs)
95
+ for (let attempt = 0; attempt < 2; attempt++) {
96
+ fetchLogs.mockRejectedValueOnce(new Error('snapshot failed'))
97
+ await act(async () => refreshButton().click())
98
+ expect(element.querySelector('[role="alert"]')?.textContent).toContain('snapshot failed')
99
+ expect(element.textContent).toContain('OTHER 1')
100
+ }
101
+
102
+ fetchLogs.mockResolvedValueOnce({ app: '' })
103
+ await act(async () => refreshButton().click())
104
+ expect(element.querySelector('[role="alert"]')).toBeNull()
105
+ expect(element.textContent).not.toContain('OTHER 1')
106
+ expect(element.textContent).not.toContain('snapshot failed')
107
+ })
108
+
109
+ it('keeps streamed records when Refresh fails after the stream stops', async () => {
110
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
111
+ await renderViewer(fetchLogs, true)
112
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
113
+ await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-20T10:00:00Z', content: 'retained record', container: 'app' }))
114
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 100)) })
115
+ await act(async () => button('Stop').click())
116
+ fetchLogs.mockRejectedValueOnce(new Error('snapshot failed'))
117
+
118
+ await act(async () => refreshButton().click())
119
+
120
+ expect(element.querySelector('[role="alert"]')?.textContent).toContain('snapshot failed')
121
+ expect(element.textContent).toContain('OTHER 1')
122
+ })
123
+
124
+ it.each(['container', 'previous', 'range'])('clears prior records when a new %s snapshot fails', async selection => {
125
+ const fetchLogs = vi.fn(async () => ({ app: '2026-10-20T10:00:00Z old selection' }))
126
+ await renderViewer(fetchLogs, false, ['app', 'sidecar'])
127
+ expect(element.textContent).toContain('OTHER 1')
128
+ fetchLogs.mockRejectedValueOnce(new Error('snapshot failed'))
129
+
130
+ await act(async () => {
131
+ if (selection === 'previous') {
132
+ previousCheckbox().click()
133
+ return
134
+ }
135
+ const select = element.querySelectorAll('select')[selection === 'container' ? 0 : 1]
136
+ if (!select) throw new Error('selection control not rendered')
137
+ select.value = selection === 'container' ? 'sidecar' : '1000'
138
+ select.dispatchEvent(new Event('change', { bubbles: true }))
139
+ })
140
+
141
+ expect(element.textContent).toContain('snapshot failed')
142
+ expect(element.textContent).not.toContain('OTHER 1')
143
+ expect(element.textContent).not.toContain('Previously loaded logs remain below.')
144
+ expect(messageClass('snapshot failed')).toMatch(/text-red/)
145
+ })
146
+
69
147
  function button(label: string): HTMLButtonElement {
70
148
  const found = Array.from(element.querySelectorAll('button')).find(b => b.textContent?.trim() === label)
71
149
  if (!found) throw new Error(`${label} button not rendered`)
@@ -156,62 +234,6 @@ it('with auto-stream on, a time range picked after the stream ended loads it ins
156
234
  expect(fetchLogs).toHaveBeenLastCalledWith(expect.objectContaining({ previous: true, sinceSeconds: expect.any(Number) }))
157
235
  })
158
236
 
159
- it('with auto-stream on, a range picked while the stream opens reopens it with that range', async () => {
160
- const fetchLogs = vi.fn(async () => ({ app: '' }))
161
- await renderViewer(fetchLogs, true)
162
- const opening = FakeEventSource.last
163
-
164
- const range = element.querySelector<HTMLSelectElement>('select')
165
- if (!range) throw new Error('range select not rendered')
166
- await act(async () => {
167
- range.value = range.options[range.options.length - 1].value
168
- range.dispatchEvent(new Event('change', { bubbles: true }))
169
- })
170
-
171
- expect(opening?.closed).toBe(true)
172
- expect(FakeEventSource.last).not.toBe(opening)
173
- expect(fetchLogs).not.toHaveBeenCalled()
174
- })
175
-
176
- it('with auto-stream on, after the stream ended and was started again, a range picked while it opens reopens it', async () => {
177
- const fetchLogs = vi.fn(async () => ({ app: '' }))
178
- await renderViewer(fetchLogs, true)
179
- await act(async () => FakeEventSource.last?.emit('connected', {}))
180
- await act(async () => FakeEventSource.last?.emit('end', {}))
181
- const range = element.querySelector<HTMLSelectElement>('select')
182
- if (!range) throw new Error('range select not rendered')
183
- const pick = (index: number) => act(async () => {
184
- range.value = range.options[index].value
185
- range.dispatchEvent(new Event('change', { bubbles: true }))
186
- })
187
- await pick(range.options.length - 1)
188
- await act(async () => button('Stream').click())
189
- const opening = FakeEventSource.last
190
-
191
- await pick(range.options.length - 2)
192
-
193
- expect(opening?.closed).toBe(true)
194
- expect(FakeEventSource.last).not.toBe(opening)
195
- })
196
-
197
- it('a range picked while a manually started stream opens does not cancel it', async () => {
198
- const fetchLogs = vi.fn(async () => ({ app: '' }))
199
- await renderViewer(fetchLogs, true)
200
- await act(async () => FakeEventSource.last?.emit('connected', {}))
201
- await act(async () => button('Stop').click())
202
- await act(async () => button('Stream').click())
203
- const opening = FakeEventSource.last
204
- const range = element.querySelector<HTMLSelectElement>('select')
205
- if (!range) throw new Error('range select not rendered')
206
-
207
- await act(async () => {
208
- range.value = range.options[range.options.length - 1].value
209
- range.dispatchEvent(new Event('change', { bubbles: true }))
210
- })
211
-
212
- expect(opening?.closed).toBe(false)
213
- })
214
-
215
237
  it('with auto-stream on, the open stream is not replaced by a snapshot', async () => {
216
238
  const fetchLogs = vi.fn(async () => ({ app: '' }))
217
239
  await renderViewer(fetchLogs, true)
@@ -289,3 +311,104 @@ it('still shows a Radar failure as an error', async () => {
289
311
  await renderViewer(async () => { throw new ApiError(failure, { error: failure }) })
290
312
  expect(messageClass(failure)).toMatch(/text-red/)
291
313
  })
314
+
315
+ function rangeSelect(): HTMLSelectElement {
316
+ const range = element.querySelector<HTMLSelectElement>('select')
317
+ if (!range) throw new Error('range select not rendered')
318
+ return range
319
+ }
320
+
321
+ const nextFrame = () => act(async () => { await new Promise(resolve => requestAnimationFrame(() => resolve(null))) })
322
+
323
+ it('opening another pod with the same container name streams it, even after the first pod\'s stream ended', async () => {
324
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
325
+ await renderViewer(fetchLogs, true, ['app'], 'web-0')
326
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
327
+ await act(async () => FakeEventSource.last?.emit('end', {}))
328
+ const ended = FakeEventSource.last
329
+
330
+ await renderViewer(fetchLogs, true, ['app'], 'web-1')
331
+
332
+ expect(FakeEventSource.last).not.toBe(ended)
333
+ expect(FakeEventSource.last?.url).toBe('/pods/default/web-1/logs/stream')
334
+ expect(fetchLogs).not.toHaveBeenCalled()
335
+ })
336
+
337
+ it('a manual Stop on one pod does not stop the next pod from streaming', async () => {
338
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
339
+ await renderViewer(fetchLogs, true, ['app'], 'web-0')
340
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
341
+ await act(async () => button('Stop').click())
342
+
343
+ await renderViewer(fetchLogs, true, ['app'], 'web-1')
344
+
345
+ expect(FakeEventSource.last?.url).toBe('/pods/default/web-1/logs/stream')
346
+ })
347
+
348
+ it('Previous and the range are locked while an auto-stream opens', async () => {
349
+ await renderViewer(async () => ({ app: '' }), true)
350
+ expect(previousCheckbox().disabled).toBe(true)
351
+ expect(rangeSelect().disabled).toBe(true)
352
+
353
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
354
+ await act(async () => FakeEventSource.last?.emit('end', {}))
355
+ expect(previousCheckbox().disabled).toBe(false)
356
+ expect(rangeSelect().disabled).toBe(false)
357
+ })
358
+
359
+ it('Previous and the range are locked while a manually started stream opens', async () => {
360
+ await renderViewer(async () => ({ app: '' }))
361
+ expect(previousCheckbox().disabled).toBe(false)
362
+
363
+ await act(async () => button('Stream').click())
364
+
365
+ expect(previousCheckbox().disabled).toBe(true)
366
+ expect(rangeSelect().disabled).toBe(true)
367
+ })
368
+
369
+ it('a stream that is still opening can be stopped, which unlocks Previous', async () => {
370
+ await renderViewer(async () => ({ app: '' }))
371
+ await act(async () => button('Stream').click())
372
+ const opening = FakeEventSource.last
373
+
374
+ await act(async () => button('Stop').click())
375
+
376
+ expect(opening?.closed).toBe(true)
377
+ expect(previousCheckbox().disabled).toBe(false)
378
+ expect(button('Stream')).toBeTruthy()
379
+ })
380
+
381
+ it('a stream failure after lines arrived is shown above them', async () => {
382
+ await renderViewer(async () => ({ app: '' }))
383
+ await act(async () => button('Stream').click())
384
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
385
+ await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-14T10:00:00Z', content: 'live line', container: 'app' }))
386
+ await nextFrame()
387
+ await act(async () => FakeEventSource.last?.emit('error', { error: 'Read error: unexpected EOF' }))
388
+
389
+ expect(element.querySelector('[role="alert"]')?.textContent).toBe('Read error: unexpected EOF · Previously loaded logs remain below.')
390
+ expect(element.textContent).toContain('OTHER 1')
391
+ })
392
+
393
+ const NOT_STARTED = 'This container has not started yet, so it has no logs. Kubernetes reports ContainerCreating.'
394
+
395
+ it('shows a container that has not started in the empty-pane style', async () => {
396
+ await renderViewer(async () => { throw new ApiError(NOT_STARTED, { error: NOT_STARTED, error_code: 'container_not_started' }) })
397
+ expect(messageClass(NOT_STARTED)).not.toMatch(/text-red/)
398
+ })
399
+
400
+ it('shows what the live stream is waiting for, and drops it once lines arrive', async () => {
401
+ const waiting = `${NOT_STARTED} Logs will show here once it starts.`
402
+ await renderViewer(async () => ({ app: '' }), true)
403
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
404
+ await act(async () => FakeEventSource.last?.emit('notice', { notice: waiting, notice_code: 'container_not_started' }))
405
+ expect(messageClass(waiting)).not.toMatch(/text-red/)
406
+ expect(button('Stop')).toBeTruthy()
407
+
408
+ await act(async () => FakeEventSource.last?.emit('notice', { notice: '' }))
409
+ await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-14T10:00:00Z', content: 'started', container: 'app' }))
410
+ await nextFrame()
411
+
412
+ expect(element.textContent).not.toContain(waiting)
413
+ expect(element.textContent).toContain('OTHER 1')
414
+ })
@@ -43,7 +43,7 @@ export interface LogsViewerProps {
43
43
  // Kubernetes' answers that there are no logs to show, such as "no earlier run".
44
44
  // They describe the pod, not a failure in Radar, so they are shown like an
45
45
  // empty pane rather than as an error.
46
- const CLUSTER_ANSWER_CODES = new Set(['no_previous_run', 'logs_unavailable'])
46
+ const CLUSTER_ANSWER_CODES = new Set(['no_previous_run', 'logs_unavailable', 'container_not_started'])
47
47
 
48
48
  function isClusterAnswerCode(code: unknown): boolean {
49
49
  return typeof code === 'string' && CLUSTER_ANSWER_CODES.has(code)
@@ -56,7 +56,14 @@ function clusterAnswerOf(err: unknown): string | null {
56
56
  return isClusterAnswerCode(data.error_code) ? err.message : null
57
57
  }
58
58
 
59
- export function LogsViewer({
59
+ // Keyed by pod: everything the viewer remembers (the container, Previous, the
60
+ // auto-start latch, a manual Stop) belongs to one pod, and a host that swaps
61
+ // the pod without remounting would carry it over to the next one.
62
+ export function LogsViewer(props: LogsViewerProps) {
63
+ return <PodLogsViewer key={`${props.namespace}/${props.podName}`} {...props} />
64
+ }
65
+
66
+ function PodLogsViewer({
60
67
  namespace: _namespace,
61
68
  podName,
62
69
  containers,
@@ -74,11 +81,16 @@ export function LogsViewer({
74
81
  const [emptyMessage, setEmptyMessage] = useState<string | null>(null)
75
82
  const [logRange, setLogRange] = useState('500')
76
83
  const [showPrevious, setShowPrevious] = useState(false)
84
+ // What an open stream says it is waiting for, e.g. a container to start.
85
+ const [streamNotice, setStreamNotice] = useState<string | null>(null)
77
86
  const { showError, showSuccess } = useToast()
78
87
 
79
88
  const { tailLines, sinceSeconds } = parseLogRange(logRange)
80
- const { entries, append, set, clear } = useLogBuffer()
89
+ const { entries, isFollowing, pendingLineCount, retainedLineCount, missingLineCount, getRetainedEntries, setFollowing, append, set, clear } = useLogBuffer()
81
90
  const { isStreaming, streamError, streamErrorCode, connecting, startStreaming, stopStreaming } = useLogStream()
91
+ const selectionKey = JSON.stringify([_namespace, podName, selectedContainer, logRange])
92
+ // Identify the selection that supplied the buffered snapshot or stream.
93
+ const bufferSelectionRef = useRef<{ key: string; previous: boolean } | null>(null)
82
94
 
83
95
  const willAutoStream = autoStream && !!createStream
84
96
  // Tracks the container we've already auto-started for, so re-renders don't
@@ -106,9 +118,11 @@ export function LogsViewer({
106
118
  setIsLoading(true)
107
119
  setFetchError(null)
108
120
  setEmptyMessage(null)
121
+ setStreamNotice(null)
109
122
  try {
110
123
  const data = await fetchLogs({ container: selectedContainer, tailLines, sinceSeconds, previous: showPrevious })
111
124
  if (seq !== loadSeqRef.current) return
125
+ bufferSelectionRef.current = { key: selectionKey, previous: showPrevious }
112
126
  const logText = data[selectedContainer] ?? Object.values(data)[0] ?? ''
113
127
  set(logText.split('\n').filter(Boolean).map(line => {
114
128
  const { timestamp, content } = parseLogLine(line)
@@ -123,11 +137,12 @@ export function LogsViewer({
123
137
  return
124
138
  }
125
139
  console.error('Failed to fetch logs:', err)
140
+ if (bufferSelectionRef.current?.key !== selectionKey || bufferSelectionRef.current.previous !== showPrevious) set([])
126
141
  setFetchError(err instanceof Error ? err.message : 'Failed to fetch logs')
127
142
  } finally {
128
143
  if (seq === loadSeqRef.current) setIsLoading(false)
129
144
  }
130
- }, [selectedContainer, tailLines, sinceSeconds, showPrevious, fetchLogs, set, stopStreaming])
145
+ }, [selectedContainer, tailLines, sinceSeconds, showPrevious, fetchLogs, set, stopStreaming, selectionKey])
131
146
 
132
147
  // When auto-streaming the stream supplies the initial tail, so the static
133
148
  // snapshot fetch is skipped to avoid a redundant request and a flash of
@@ -166,10 +181,12 @@ export function LogsViewer({
166
181
  setIsLoading(false)
167
182
  setFetchError(null)
168
183
  setEmptyMessage(null)
184
+ setStreamNotice(null)
169
185
  // The stream replays the last N lines (TailLines + Follow); clear first so
170
186
  // they don't duplicate lines already in the buffer (the snapshot on the
171
187
  // manual path, or an earlier stream on restart).
172
188
  clear()
189
+ bufferSelectionRef.current = { key: selectionKey, previous: false }
173
190
  startStreaming(
174
191
  () => createStream({ container: selectedContainer, tailLines: 100, sinceSeconds }),
175
192
  {
@@ -178,13 +195,18 @@ export function LogsViewer({
178
195
  content: data.content || '',
179
196
  container: data.container || selectedContainer,
180
197
  }),
198
+ onNotice: (data: unknown) => {
199
+ const notice = typeof data === 'object' && data !== null && 'notice' in data && typeof data.notice === 'string' ? data.notice : ''
200
+ setStreamNotice(notice || null)
201
+ },
181
202
  },
182
203
  'Log stream connection failed',
183
204
  )
184
- }, [createStream, startStreaming, selectedContainer, sinceSeconds, append, clear])
205
+ }, [createStream, startStreaming, selectedContainer, sinceSeconds, append, clear, selectionKey])
185
206
 
186
207
  const handleStopStreaming = useCallback(() => {
187
208
  userStoppedRef.current = true
209
+ setStreamNotice(null)
188
210
  stopStreaming()
189
211
  }, [stopStreaming])
190
212
 
@@ -214,24 +236,28 @@ export function LogsViewer({
214
236
  }
215
237
  }, [podName, selectedContainer, overrideDownload, showError, showSuccess])
216
238
 
239
+ // Previous and the range describe what a snapshot loads, so they stay
240
+ // locked from the moment a stream starts opening, not only once it is open.
241
+ const streamBusy = isStreaming || connecting
242
+
217
243
  const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
218
244
  <>
219
245
  <ContainerSelect containers={containers} value={selectedContainer} onChange={setSelectedContainer} isDark={isDark} />
220
246
 
221
- <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">
222
- <label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary} ${isStreaming ? 'opacity-50 cursor-not-allowed' : ''}`}>
247
+ <Tooltip content={streamBusy ? '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">
248
+ <label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary} ${streamBusy ? 'opacity-50 cursor-not-allowed' : ''}`}>
223
249
  <input
224
250
  type="checkbox"
225
251
  checked={showPrevious}
226
252
  onChange={(e) => setShowPrevious(e.target.checked)}
227
- disabled={isStreaming}
253
+ disabled={streamBusy}
228
254
  className={`w-3 h-3 rounded ${palette.borderLight} ${palette.elevatedBg} text-blue-500 focus:ring-blue-500 focus:ring-offset-0`}
229
255
  />
230
256
  <span className={`border-b border-dotted ${isDark ? 'border-slate-500' : 'border-slate-400'}`}>Previous</span>
231
257
  </label>
232
258
  </Tooltip>
233
259
 
234
- <LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} disabled={isStreaming} />
260
+ <LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} disabled={streamBusy} />
235
261
  </>
236
262
  )
237
263
 
@@ -241,20 +267,31 @@ export function LogsViewer({
241
267
  const streamAnswer = isClusterAnswerCode(streamErrorCode) ? streamError : null
242
268
 
243
269
  return (
270
+ <div className="flex h-full min-h-0 flex-col">
271
+ {(fetchError || streamError) && entries.length > 0 && <div role="alert" className="shrink-0 border-b border-theme-border bg-theme-surface px-3 py-2 text-xs text-theme-text-secondary">{fetchError || streamError} · Previously loaded logs remain below.</div>}
272
+ <div className="min-h-0 flex-1">
244
273
  <LogCore
245
274
  entries={entries}
275
+ isFollowing={isFollowing}
276
+ onFollowingChange={setFollowing}
277
+ pendingLineCount={pendingLineCount}
278
+ retainedLineCount={retainedLineCount}
279
+ missingLineCount={missingLineCount}
280
+ getRetainedEntries={getRetainedEntries}
246
281
  isLoading={isLoading || isConnecting}
247
- errorMessage={fetchError || (entries.length === 0 && !emptyMessage && !streamAnswer ? streamError : null)}
248
- isStreaming={isStreaming}
282
+ errorMessage={entries.length === 0 ? fetchError || (!emptyMessage && !streamAnswer ? streamError : null) : null}
283
+ isStreaming={streamBusy}
249
284
  onStartStream={createStream ? handleStartStreaming : undefined}
250
285
  onStopStream={handleStopStreaming}
251
286
  onRefresh={loadLogs}
252
287
  onDownload={downloadLogs}
253
288
  onClear={clear}
254
- emptyMessage={emptyMessage ?? streamAnswer ?? undefined}
289
+ emptyMessage={emptyMessage ?? streamAnswer ?? streamNotice ?? undefined}
255
290
  toolbarExtra={renderToolbarExtra}
256
291
  forceDark={forceDark}
257
292
  defaultDark={defaultDark}
258
293
  />
294
+ </div>
295
+ </div>
259
296
  )
260
297
  }
@@ -113,7 +113,7 @@ function FilterableValue({
113
113
  <button
114
114
  type="button"
115
115
  onClick={(e) => { e.stopPropagation(); onFilter(value) }}
116
- className={`opacity-0 group-hover/flt:opacity-100 transition-opacity ${palette.textTertiary} ${palette.hoverText} px-0.5`}
116
+ className={`opacity-0 group-hover/flt:opacity-100 focus-visible:opacity-100 transition-opacity ${palette.textTertiary} ${palette.hoverText} px-0.5`}
117
117
  title={`Filter to lines containing "${value}"`}
118
118
  aria-label={`Filter to lines containing ${value}`}
119
119
  >
@@ -97,7 +97,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
97
97
  const { showError, showSuccess } = useToast()
98
98
 
99
99
  const { tailLines, sinceSeconds } = parseLogRange(logRange)
100
- const { entries, append, set, clear } = useLogBuffer()
100
+ const { entries, isFollowing, pendingLineCount, retainedLineCount, missingLineCount, getRetainedEntries, setFollowing, append, set, clear } = useLogBuffer()
101
101
  const { isStreaming, streamError, connecting, startStreaming, stopStreaming } = useLogStream()
102
102
 
103
103
  const willAutoStream = autoStream && !!createStream
@@ -430,6 +430,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
430
430
  {(fetchError || streamError) && entries.length > 0 && <div role="alert" className="shrink-0 border-b border-theme-border bg-theme-surface px-3 py-2 text-xs text-theme-text-secondary">{fetchError || streamError} · Previously loaded logs remain below.</div>}
431
431
  <div className="min-h-0 flex-1"><LogCore
432
432
  entries={filteredEntries}
433
+ isFollowing={isFollowing}
434
+ onFollowingChange={setFollowing}
435
+ pendingLineCount={pendingLineCount}
436
+ retainedLineCount={retainedLineCount}
437
+ missingLineCount={missingLineCount}
438
+ getRetainedEntries={getRetainedEntries}
433
439
  allEntries={entries}
434
440
  isLoading={isLoading || isConnecting}
435
441
  isStreaming={isStreaming}
@@ -0,0 +1,149 @@
1
+ // @vitest-environment jsdom
2
+ import { act, StrictMode } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { useLogBuffer } from './useLogBuffer'
6
+
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+
9
+ let root: Root
10
+ let element: HTMLDivElement
11
+ let current: ReturnType<typeof useLogBuffer> | undefined
12
+ let flush: FrameRequestCallback | undefined
13
+ const buffer = () => {
14
+ if (!current) throw new Error('Buffer not mounted')
15
+ return current
16
+ }
17
+ function Harness() {
18
+ current = useLogBuffer()
19
+ return null
20
+ }
21
+ const lines = (count: number) => Array.from({ length: count }, (_, i) => ({ content: `INFO line ${i}`, container: 'app', timestamp: '' }))
22
+ async function append(count: number) {
23
+ await act(async () => {
24
+ buffer().appendBatch(lines(count))
25
+ flush?.(0)
26
+ })
27
+ }
28
+
29
+ beforeEach(async () => {
30
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { flush = callback; return 1 })
31
+ vi.stubGlobal('cancelAnimationFrame', () => { flush = undefined })
32
+ element = document.createElement('div')
33
+ root = createRoot(element)
34
+ await act(async () => root.render(<StrictMode><Harness /></StrictMode>))
35
+ })
36
+ afterEach(async () => {
37
+ await act(async () => root.unmount())
38
+ current = undefined
39
+ flush = undefined
40
+ vi.unstubAllGlobals()
41
+ })
42
+
43
+ describe('reading a live log buffer', () => {
44
+ it('appends below the reader without replacing existing records before rollover', async () => {
45
+ await append(100)
46
+ const anchor = buffer().entries[50]
47
+ await act(async () => buffer().setFollowing(false))
48
+ await append(100)
49
+ expect(buffer().entries).toHaveLength(200)
50
+ expect(buffer().entries[50]).toBe(anchor)
51
+ expect(buffer().isFollowing).toBe(false)
52
+ expect(buffer().pendingLineCount).toBe(0)
53
+ })
54
+
55
+ it('keeps the reading window through repeated eviction and resumes at the newest records', async () => {
56
+ await append(10_000)
57
+ await act(async () => buffer().setFollowing(false))
58
+ const reading = buffer().entries
59
+ await append(500)
60
+ await append(20_000)
61
+ expect(buffer().entries).toBe(reading)
62
+ expect(buffer().entries[50]).toBe(reading[50])
63
+ expect(buffer().pendingLineCount).toBe(20_500)
64
+ expect(buffer().isFollowing).toBe(false)
65
+ await act(async () => buffer().setFollowing(true))
66
+ expect(buffer().entries).toHaveLength(10_000)
67
+ expect(buffer().entries.at(-1)?.id).toBe(30_499)
68
+ expect(buffer().pendingLineCount).toBe(0)
69
+ expect(buffer().isFollowing).toBe(true)
70
+ })
71
+
72
+ it('still bounds the buffer while following', async () => {
73
+ await append(10_500)
74
+ expect(buffer().entries).toHaveLength(10_000)
75
+ expect(buffer().entries[0].id).toBe(500)
76
+ expect(buffer().isFollowing).toBe(true)
77
+ })
78
+
79
+ it('releases the saved history on refresh and clear', async () => {
80
+ await append(10_000)
81
+ await act(async () => buffer().setFollowing(false))
82
+ await append(100)
83
+ await act(async () => buffer().set(lines(5)))
84
+ expect(buffer().entries).toHaveLength(5)
85
+ expect(buffer().isFollowing).toBe(true)
86
+ await act(async () => buffer().setFollowing(false))
87
+ await act(async () => buffer().clear())
88
+ expect(buffer().entries).toHaveLength(0)
89
+ expect(buffer().pendingLineCount).toBe(0)
90
+ expect(buffer().isFollowing).toBe(true)
91
+ })
92
+ })
93
+
94
+ it('exports overlapping windows once and reports expired history after they separate', async () => {
95
+ await append(10_000)
96
+ await act(async () => buffer().setFollowing(false))
97
+ await append(500)
98
+ expect(buffer().retainedLineCount).toBe(10_500)
99
+ expect(buffer().missingLineCount).toBe(0)
100
+ expect(buffer().getRetainedEntries().map(entry => entry.id)).toEqual(Array.from({ length: 10_500 }, (_, i) => i))
101
+ await append(10_000)
102
+ const retained = buffer().getRetainedEntries()
103
+ expect(buffer().retainedLineCount).toBe(20_000)
104
+ expect(buffer().missingLineCount).toBe(500)
105
+ expect(retained).toHaveLength(20_000)
106
+ expect(retained[9_999].id).toBe(9_999)
107
+ expect(retained[10_000].id).toBe(10_500)
108
+ expect(retained.at(-1)?.id).toBe(20_499)
109
+ await act(async () => buffer().setFollowing(true))
110
+ expect(buffer().retainedLineCount).toBe(10_000)
111
+ expect(buffer().missingLineCount).toBe(0)
112
+ await act(async () => buffer().clear())
113
+ expect(buffer().retainedLineCount).toBe(0)
114
+ expect(buffer().getRetainedEntries()).toEqual([])
115
+ })
116
+
117
+ it('keeps collecting the latest logs when animation frames stop running', async () => {
118
+ await act(async () => {
119
+ buffer().appendBatch(lines(10_000))
120
+ })
121
+ // No animation frame was delivered, as happens in a hidden tab.
122
+ expect(buffer().entries).toHaveLength(10_000)
123
+ await act(async () => buffer().setFollowing(false))
124
+ const saved = buffer().entries
125
+ await act(async () => buffer().appendBatch(lines(30_000)))
126
+ expect(buffer().entries).toBe(saved)
127
+ expect(buffer().pendingLineCount).toBe(30_000)
128
+ expect(buffer().getRetainedEntries().at(-1)?.id).toBe(39_999)
129
+ expect(buffer().retainedLineCount).toBe(20_000)
130
+ expect(buffer().missingLineCount).toBe(20_000)
131
+ await act(async () => buffer().appendBatch(lines(5)))
132
+ expect(buffer().pendingLineCount).toBe(30_000)
133
+ await act(async () => flush?.(0))
134
+ expect(buffer().pendingLineCount).toBe(30_005)
135
+ expect(buffer().getRetainedEntries().at(-1)?.id).toBe(40_004)
136
+ expect(buffer().missingLineCount).toBe(20_005)
137
+ })
138
+
139
+ it('does not count records from before an empty reset when pausing and receiving a large batch', async () => {
140
+ await append(25_000)
141
+ for (const reset of [() => buffer().clear(), () => buffer().set([])]) {
142
+ await act(async () => { reset(); buffer().setFollowing(false) })
143
+ await append(20_001)
144
+ expect(buffer().entries).toHaveLength(10_000)
145
+ expect(buffer().pendingLineCount).toBe(10_001)
146
+ expect(buffer().missingLineCount).toBe(1)
147
+ expect(buffer().retainedLineCount).toBe(20_000)
148
+ }
149
+ })