@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.
- package/README.md +13 -0
- package/package.json +4 -4
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +33 -4
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
- package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
- package/src/components/gitops/insights/insights-helpers.ts +7 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
- package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
- package/src/components/gitops/tree/tree-helpers.ts +5 -1
- package/src/components/issues/IssuesView.tsx +18 -10
- package/src/components/issues/ResourceIssuesSection.tsx +5 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +26 -0
- package/src/components/issues/severity.ts +9 -2
- package/src/components/issues/types.ts +32 -4
- package/src/components/logs/LogCore.theme.test.tsx +126 -1
- package/src/components/logs/LogCore.tsx +159 -40
- package/src/components/logs/LogsViewer.test.tsx +183 -60
- package/src/components/logs/LogsViewer.tsx +49 -12
- package/src/components/logs/StructuredLogLine.tsx +1 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
- package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
- package/src/components/logs/useLogBuffer.ts +48 -18
- package/src/components/logs/useLogSearch.ts +3 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +198 -93
- package/src/components/resources/column-order.test.ts +104 -0
- package/src/components/resources/column-order.ts +64 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
- package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
- package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +72 -20
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +15 -2
- package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +24 -4
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +12 -6
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +6 -4
- package/src/components/ui/Toast.tsx +48 -30
- package/src/components/ui/drawer-components.test.tsx +253 -0
- package/src/components/ui/drawer-components.tsx +315 -11
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/ui/index.ts +1 -1
- package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +68 -14
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +91 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops-insights.ts +1 -0
- package/src/types/gitops-tree.ts +2 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +22 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +56 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- 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=
|
|
53
|
-
containers={
|
|
69
|
+
podName={podName}
|
|
70
|
+
containers={containers}
|
|
54
71
|
fetchLogs={fetchLogs}
|
|
55
|
-
createStream={() => new EventSource(
|
|
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
|
-
|
|
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={
|
|
222
|
-
<label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary} ${
|
|
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={
|
|
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={
|
|
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={
|
|
248
|
-
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
|
+
})
|