@skyhook-io/k8s-ui 1.17.1 → 1.18.0
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/package.json +4 -4
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
- 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 +4 -1
- 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 +80 -2
- package/src/components/logs/LogsViewer.tsx +21 -4
- 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/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
- package/src/components/resources/ResourcesView.tsx +93 -75
- 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.tsx +40 -11
- package/src/components/resources/resource-utils.ts +15 -2
- package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- package/src/components/shared/ResourceActionsBar.tsx +3 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +3 -1
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- package/src/components/ui/Toast.tsx +15 -4
- package/src/components/ui/index.ts +1 -1
- package/src/components/workload/WorkloadView.tsx +14 -8
- package/src/types/core.ts +25 -1
- package/src/types/gitops-insights.ts +1 -0
- package/src/types/gitops-tree.ts +2 -0
|
@@ -118,6 +118,23 @@ export interface IssueRecentChangeField {
|
|
|
118
118
|
newValue?: unknown;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/** Names the caller's access kept out of a list, counted but never named. */
|
|
122
|
+
export interface IssueWithheld {
|
|
123
|
+
count: number;
|
|
124
|
+
/** A readreason code; always "user_forbidden" today. */
|
|
125
|
+
reason: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface IssueRunningPreviousValue {
|
|
129
|
+
/** Owners the caller may read, capped at 5. */
|
|
130
|
+
workloads: string[] | null;
|
|
131
|
+
/** Owners left out because the caller may not read them. */
|
|
132
|
+
withheld?: IssueWithheld;
|
|
133
|
+
pods: number;
|
|
134
|
+
keys: string[];
|
|
135
|
+
note: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
121
138
|
export interface IssueRecentChange {
|
|
122
139
|
source?: string;
|
|
123
140
|
kind: string;
|
|
@@ -130,15 +147,19 @@ export interface IssueRecentChange {
|
|
|
130
147
|
timestamp: string;
|
|
131
148
|
change_category?: 'spec_config' | 'lifecycle' | 'runtime_status' | string;
|
|
132
149
|
rank_reason?: string;
|
|
133
|
-
/** Ranking hint for workload runtime configuration (including the image)
|
|
150
|
+
/** Ranking hint for workload runtime configuration (including the image), directly referenced ConfigMap data, or Secret writes with likely-stale env readers; not a causal claim. */
|
|
134
151
|
application_configuration_change?: boolean;
|
|
135
152
|
/** Set only on top-level recent_changes in an eligible, unfiltered issues response with complete linkage evidence. */
|
|
136
153
|
not_linked_to_returned_issues?: boolean;
|
|
137
154
|
fields?: IssueRecentChangeField[];
|
|
138
|
-
/** Workloads
|
|
139
|
-
* Direct spec references only
|
|
140
|
-
* service are not captured. */
|
|
155
|
+
/** Workloads or Pods this ConfigMap or Secret is referenced by ("Deployment/flagd"), capped at 5.
|
|
156
|
+
* Direct spec references only; references do not establish runtime use. */
|
|
141
157
|
consumed_by?: string[];
|
|
158
|
+
/** Referencing workloads or Pods left out of consumed_by because the caller may not read them. */
|
|
159
|
+
consumed_by_withheld?: IssueWithheld;
|
|
160
|
+
running_previous_value?: IssueRunningPreviousValue;
|
|
161
|
+
updates_in_window?: number;
|
|
162
|
+
first_update_at?: string;
|
|
142
163
|
}
|
|
143
164
|
|
|
144
165
|
/** One finalizer removal: a JSON Patch that tests the object UID and the
|
|
@@ -201,6 +222,10 @@ export interface Issue {
|
|
|
201
222
|
reason: string;
|
|
202
223
|
message?: string;
|
|
203
224
|
raw_message?: string;
|
|
225
|
+
/** 'radar' when `message` is Radar's own summary rather than text from
|
|
226
|
+
* Kubernetes or a controller. Such a message is never shown as raw output;
|
|
227
|
+
* the server writes a `cause` that stands without it. */
|
|
228
|
+
message_source?: 'radar';
|
|
204
229
|
/** Parsed domain diagnosis: plain-English cause, suggested next step, and
|
|
205
230
|
* an optional structured one-click fix.
|
|
206
231
|
* Server-emitted (omitempty); empty for issues without a parser. */
|
|
@@ -283,6 +308,9 @@ export interface Issue {
|
|
|
283
308
|
* NEITHER correlation field was not checked — absence must not be read as
|
|
284
309
|
* "no changes". MCP-only, like correlated_changes. */
|
|
285
310
|
no_recent_changes?: IssueNoRecentChanges;
|
|
311
|
+
/** The subject's change history in the window was larger than Radar reads,
|
|
312
|
+
* so correlated_changes are leads, not its full history. MCP-only. */
|
|
313
|
+
correlated_changes_incomplete?: boolean;
|
|
286
314
|
}
|
|
287
315
|
|
|
288
316
|
/** subjectRef builds a deep-linkable ref for an issue's subject — the row's
|
|
@@ -3,8 +3,17 @@ import { act, type ComponentProps, type ReactNode } from 'react'
|
|
|
3
3
|
import { createRoot, type Root } from 'react-dom/client'
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
5
|
import { LogCore } from './LogCore'
|
|
6
|
+
import { copyText } from '../../utils/clipboard'
|
|
6
7
|
|
|
7
|
-
vi.mock('
|
|
8
|
+
vi.mock('../../utils/clipboard', () => ({ copyText: vi.fn(async () => true) }))
|
|
9
|
+
|
|
10
|
+
let atBottom: ((bottom: boolean) => void) | undefined
|
|
11
|
+
let totalHeightChanged: (() => void) | undefined
|
|
12
|
+
vi.mock('react-virtuoso', () => ({ Virtuoso: ({ data, itemContent, atBottomStateChange, totalListHeightChanged, scrollerRef }: { data: unknown[]; itemContent: (index: number, value: unknown) => ReactNode; atBottomStateChange: (bottom: boolean) => void; totalListHeightChanged: () => void; scrollerRef: (element: HTMLElement | null) => void }) => {
|
|
13
|
+
atBottom = atBottomStateChange
|
|
14
|
+
totalHeightChanged = totalListHeightChanged
|
|
15
|
+
return <div ref={scrollerRef} data-virtuoso-scroller>{data.map((value, index) => <div key={index}>{itemContent(index, value)}</div>)}</div>
|
|
16
|
+
} }))
|
|
8
17
|
|
|
9
18
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
10
19
|
|
|
@@ -19,6 +28,7 @@ beforeEach(() => {
|
|
|
19
28
|
afterEach(async () => {
|
|
20
29
|
await act(async () => root.unmount())
|
|
21
30
|
element.remove()
|
|
31
|
+
vi.unstubAllGlobals()
|
|
22
32
|
})
|
|
23
33
|
|
|
24
34
|
const noop = () => {}
|
|
@@ -153,3 +163,118 @@ it('keeps Export and Clear usable when a Pod filter hides a nonempty buffer', as
|
|
|
153
163
|
expect(element.querySelector<HTMLButtonElement>('[aria-label="Export logs"]')!.disabled).toBe(false)
|
|
154
164
|
expect(element.querySelector<HTMLButtonElement>('[aria-label="Clear logs"]')!.disabled).toBe(false)
|
|
155
165
|
})
|
|
166
|
+
|
|
167
|
+
it('keeps return to live available when a Pod filter hides the saved reading window', async () => {
|
|
168
|
+
const onFollowingChange = vi.fn()
|
|
169
|
+
await render({ entries: [], isFollowing: false, pendingLineCount: 17, onFollowingChange })
|
|
170
|
+
const button = element.querySelector<HTMLButtonElement>('[aria-label="Scroll to bottom"]')
|
|
171
|
+
expect(button?.textContent).toContain('17 new lines')
|
|
172
|
+
await act(async () => button?.click())
|
|
173
|
+
expect(onFollowingChange).toHaveBeenCalledWith(true)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('collects retained windows only when exporting and downloads both windows', async () => {
|
|
177
|
+
const saved = { id: 1, container: 'app', level: 'info', levelSource: 'keyword', isJson: false, isLogfmt: false, content: 'saved record', timestamp: '' } satisfies ComponentProps<typeof LogCore>['entries'][number]
|
|
178
|
+
const live = { ...saved, id: 30, content: 'live record' }
|
|
179
|
+
const getRetainedEntries = vi.fn(() => [saved, live])
|
|
180
|
+
const onDownload = vi.fn()
|
|
181
|
+
await render({ entries: [saved], getRetainedEntries, retainedLineCount: 2, missingLineCount: 28, onDownload })
|
|
182
|
+
expect(getRetainedEntries).not.toHaveBeenCalled()
|
|
183
|
+
await act(async () => element.querySelector<HTMLButtonElement>('[aria-label="Export logs"]')!.click())
|
|
184
|
+
expect(getRetainedEntries).toHaveBeenCalled()
|
|
185
|
+
const all = [...element.querySelectorAll<HTMLButtonElement>('[role="radio"]')].find(button => button.textContent === 'All 2')!
|
|
186
|
+
await act(async () => all.click())
|
|
187
|
+
expect(element.textContent).toContain('Some logs between your saved view and the latest logs are no longer available.')
|
|
188
|
+
const download = [...element.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === 'Download')!
|
|
189
|
+
await act(async () => download.click())
|
|
190
|
+
expect(onDownload).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('saved record') }))
|
|
191
|
+
expect(onDownload).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('live record') }))
|
|
192
|
+
await act(async () => element.querySelector<HTMLButtonElement>('[aria-label="Export logs"]')!.click())
|
|
193
|
+
const copy = [...element.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === 'Copy')!
|
|
194
|
+
await act(async () => copy.click())
|
|
195
|
+
expect(copyText).toHaveBeenLastCalledWith(onDownload.mock.calls[0][0].content)
|
|
196
|
+
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('does not resume at the saved end while newer records wait outside the reading view', async () => {
|
|
200
|
+
const onFollowingChange = vi.fn()
|
|
201
|
+
const entry = { id: 1, timestamp: '', content: 'INFO saved', container: 'app', level: 'info', levelSource: 'keyword', isJson: false, isLogfmt: false } satisfies ComponentProps<typeof LogCore>['entries'][number]
|
|
202
|
+
await render({ entries: [entry], isFollowing: false, pendingLineCount: 300, onFollowingChange })
|
|
203
|
+
const scroller = element.querySelector('[data-virtuoso-scroller]')!
|
|
204
|
+
await act(async () => {
|
|
205
|
+
scroller.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 40 }))
|
|
206
|
+
atBottom?.(true)
|
|
207
|
+
})
|
|
208
|
+
expect(onFollowingChange).not.toHaveBeenCalled()
|
|
209
|
+
await render({ entries: [entry], isFollowing: false, pendingLineCount: 0, onFollowingChange })
|
|
210
|
+
await act(async () => {
|
|
211
|
+
scroller.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 40 }))
|
|
212
|
+
atBottom?.(true)
|
|
213
|
+
})
|
|
214
|
+
expect(onFollowingChange).toHaveBeenCalledWith(true)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('pauses and reveals focused row controls without pausing for toolbar focus', async () => {
|
|
218
|
+
const onFollowingChange = vi.fn()
|
|
219
|
+
const reveal = vi.fn()
|
|
220
|
+
const entry = { id: 1, timestamp: '', content: 'INFO saved', container: 'app', level: 'info', levelSource: 'keyword', isJson: false, isLogfmt: false } satisfies ComponentProps<typeof LogCore>['entries'][number]
|
|
221
|
+
await render({ entries: [entry], isFollowing: true, onFollowingChange })
|
|
222
|
+
await act(async () => element.querySelector<HTMLButtonElement>('[aria-label="Refresh logs"]')!.focus())
|
|
223
|
+
expect(onFollowingChange).not.toHaveBeenCalled()
|
|
224
|
+
const copy = element.querySelector<HTMLButtonElement>('[title="Copy line"]')!
|
|
225
|
+
copy.scrollIntoView = reveal
|
|
226
|
+
await act(async () => copy.focus())
|
|
227
|
+
expect(onFollowingChange).toHaveBeenCalledWith(false)
|
|
228
|
+
expect(reveal).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' })
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('pauses following before text selection or middle-button scrolling in the log area', async () => {
|
|
232
|
+
const onFollowingChange = vi.fn()
|
|
233
|
+
const entry = { id: 1, timestamp: '', content: 'INFO saved', container: 'app', level: 'info', levelSource: 'keyword', isJson: false, isLogfmt: false } satisfies ComponentProps<typeof LogCore>['entries'][number]
|
|
234
|
+
await render({ entries: [entry], isFollowing: true, onFollowingChange })
|
|
235
|
+
const scroller = element.querySelector<HTMLElement>('[data-virtuoso-scroller]')!
|
|
236
|
+
scroller.getBoundingClientRect = () => new DOMRect(0, 0, 500, 300)
|
|
237
|
+
for (const button of [0, 1]) {
|
|
238
|
+
onFollowingChange.mockClear()
|
|
239
|
+
await act(async () => scroller.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button, clientX: 100 })))
|
|
240
|
+
expect(onFollowingChange).toHaveBeenCalledWith(false)
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('schedules one bottom update per height change without polling idle frames', async () => {
|
|
245
|
+
const frames: FrameRequestCallback[] = []
|
|
246
|
+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.push(callback); return frames.length })
|
|
247
|
+
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
|
248
|
+
const entry = { id: 1, timestamp: '', content: 'INFO saved', container: 'app', level: 'info', levelSource: 'keyword', isJson: false, isLogfmt: false } satisfies ComponentProps<typeof LogCore>['entries'][number]
|
|
249
|
+
await render({ entries: [entry] })
|
|
250
|
+
expect(frames).toHaveLength(1)
|
|
251
|
+
await act(async () => frames.shift()?.(0))
|
|
252
|
+
expect(frames).toHaveLength(0)
|
|
253
|
+
await act(async () => { totalHeightChanged?.(); totalHeightChanged?.() })
|
|
254
|
+
expect(frames).toHaveLength(1)
|
|
255
|
+
await act(async () => frames.shift()?.(0))
|
|
256
|
+
expect(frames).toHaveLength(0)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('pauses for browser text selection inside logs but ignores selection outside them', async () => {
|
|
260
|
+
const onFollowingChange = vi.fn()
|
|
261
|
+
const entry = { id: 1, timestamp: '', content: 'INFO selected record', container: 'app', level: 'info', levelSource: 'keyword', isJson: false, isLogfmt: false } satisfies ComponentProps<typeof LogCore>['entries'][number]
|
|
262
|
+
await render({ entries: [entry], isFollowing: true, onFollowingChange })
|
|
263
|
+
const outside = document.createTextNode('outside logs')
|
|
264
|
+
document.body.appendChild(outside)
|
|
265
|
+
const selection = document.getSelection()!
|
|
266
|
+
const range = document.createRange()
|
|
267
|
+
range.selectNodeContents(outside)
|
|
268
|
+
selection.removeAllRanges()
|
|
269
|
+
selection.addRange(range)
|
|
270
|
+
await act(async () => document.dispatchEvent(new Event('selectionchange')))
|
|
271
|
+
expect(onFollowingChange).not.toHaveBeenCalled()
|
|
272
|
+
const rowText = [...element.querySelectorAll('span')].find(span => span.textContent === entry.content)!
|
|
273
|
+
range.selectNodeContents(rowText)
|
|
274
|
+
selection.removeAllRanges()
|
|
275
|
+
selection.addRange(range)
|
|
276
|
+
await act(async () => document.dispatchEvent(new Event('selectionchange')))
|
|
277
|
+
expect(onFollowingChange).toHaveBeenCalledWith(false)
|
|
278
|
+
selection.removeAllRanges()
|
|
279
|
+
outside.remove()
|
|
280
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useCallback, useState, useMemo, useEffect, type ReactNode } from 'react'
|
|
1
|
+
import { useRef, useCallback, useState, useMemo, useEffect, useLayoutEffect, type ReactNode } from 'react'
|
|
2
2
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
|
3
3
|
import { Play, Square, Download, FileDown, Search, X, Terminal, RotateCcw, ChevronUp, ChevronDown, ChevronRight, CaseSensitive, Regex, WrapText, Clock, Copy, Trash2, Filter, EyeOff, Highlighter, Braces, Palette, ListCollapse, Sun, Moon } from 'lucide-react'
|
|
4
4
|
import type { LogEntry, LogLevel } from './useLogBuffer'
|
|
@@ -42,6 +42,12 @@ export type ToolbarExtraRenderer =
|
|
|
42
42
|
|
|
43
43
|
interface LogCoreProps {
|
|
44
44
|
entries: LogEntry[]
|
|
45
|
+
isFollowing?: boolean
|
|
46
|
+
onFollowingChange?: (following: boolean) => void
|
|
47
|
+
pendingLineCount?: number
|
|
48
|
+
retainedLineCount?: number
|
|
49
|
+
missingLineCount?: number
|
|
50
|
+
getRetainedEntries?: () => LogEntry[]
|
|
45
51
|
/**
|
|
46
52
|
* The unfiltered buffer, when the host filters `entries` before passing them
|
|
47
53
|
* (the workload viewer's pod picker), so that export's "All" describes the
|
|
@@ -136,6 +142,12 @@ const TIP_DELAY = 150
|
|
|
136
142
|
|
|
137
143
|
export function LogCore({
|
|
138
144
|
entries,
|
|
145
|
+
isFollowing: controlledFollowing,
|
|
146
|
+
onFollowingChange,
|
|
147
|
+
pendingLineCount = 0,
|
|
148
|
+
retainedLineCount,
|
|
149
|
+
missingLineCount = 0,
|
|
150
|
+
getRetainedEntries,
|
|
139
151
|
allEntries,
|
|
140
152
|
isLoading,
|
|
141
153
|
isStreaming,
|
|
@@ -154,7 +166,33 @@ export function LogCore({
|
|
|
154
166
|
defaultDark = true,
|
|
155
167
|
}: LogCoreProps) {
|
|
156
168
|
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
|
157
|
-
const [
|
|
169
|
+
const [localFollowing, setLocalFollowing] = useState(true)
|
|
170
|
+
const isFollowing = controlledFollowing ?? localFollowing
|
|
171
|
+
const followingRef = useRef(isFollowing)
|
|
172
|
+
const scrollerRef = useRef<HTMLElement | null>(null)
|
|
173
|
+
const resumeAtBottom = useRef(false)
|
|
174
|
+
const scrollbarDragging = useRef(false)
|
|
175
|
+
const scrollPosition = useRef<number | null>(null)
|
|
176
|
+
const setFollowing = useCallback((following: boolean) => {
|
|
177
|
+
followingRef.current = following
|
|
178
|
+
resumeAtBottom.current = false
|
|
179
|
+
setLocalFollowing(following)
|
|
180
|
+
onFollowingChange?.(following)
|
|
181
|
+
}, [onFollowingChange])
|
|
182
|
+
const touchY = useRef<number | null>(null)
|
|
183
|
+
const pauseFollowing = useCallback(() => setFollowing(false), [setFollowing])
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const handleSelectionChange = () => {
|
|
186
|
+
const selection = document.getSelection()
|
|
187
|
+
if (!followingRef.current || !selection || selection.isCollapsed || !selection.anchorNode || !scrollerRef.current?.contains(selection.anchorNode)) return
|
|
188
|
+
pauseFollowing()
|
|
189
|
+
const anchor = selection.anchorNode
|
|
190
|
+
const element = anchor instanceof Element ? anchor : anchor.parentElement
|
|
191
|
+
element?.closest('[data-index]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
192
|
+
}
|
|
193
|
+
document.addEventListener('selectionchange', handleSelectionChange)
|
|
194
|
+
return () => document.removeEventListener('selectionchange', handleSelectionChange)
|
|
195
|
+
}, [pauseFollowing])
|
|
158
196
|
const themeLocked = typeof forceDark === 'boolean'
|
|
159
197
|
// Seed isDark: forceDark prop wins; else localStorage['radar-logs-dark'];
|
|
160
198
|
// else defaultDark (the host app's theme, dark if not given). See
|
|
@@ -284,7 +322,7 @@ export function LogCore({
|
|
|
284
322
|
(e: LogEntry) => association.headIdById.get(e.id) ?? e.id,
|
|
285
323
|
[association],
|
|
286
324
|
)
|
|
287
|
-
const search = useLogSearch(levelFilteredEntries, virtuosoRef, recordIdOf)
|
|
325
|
+
const search = useLogSearch(levelFilteredEntries, virtuosoRef, recordIdOf, pauseFollowing)
|
|
288
326
|
|
|
289
327
|
const displayEntries = search.isFiltering ? search.filteredEntries : levelFilteredEntries
|
|
290
328
|
const hiddenCount = levelFilteredEntries.length - displayEntries.length
|
|
@@ -329,7 +367,14 @@ export function LogCore({
|
|
|
329
367
|
// express on screen: everything else (timestamps, ANSI, level, search) has a
|
|
330
368
|
// toolbar control, and the popover's preview shows where those land.
|
|
331
369
|
const bufferEntries = allEntries ?? entries
|
|
332
|
-
const
|
|
370
|
+
const bufferLineCount = retainedLineCount ?? bufferEntries.length
|
|
371
|
+
const retainedExportEntries = useMemo(
|
|
372
|
+
() => downloadMenu.shouldRender ? getRetainedEntries?.() ?? bufferEntries : bufferEntries,
|
|
373
|
+
[downloadMenu.shouldRender, getRetainedEntries, bufferEntries],
|
|
374
|
+
)
|
|
375
|
+
const exportHasGap = exportScope === 'all' && missingLineCount > 0
|
|
376
|
+
const gapNotice = 'Some logs between your saved view and the latest logs are no longer available.'
|
|
377
|
+
const exportEntries = exportScope === 'all' ? retainedExportEntries : displayEntries
|
|
333
378
|
const exportOptions = useMemo(
|
|
334
379
|
() => ({ format: exportFormat, showTimestamps, showPodName }),
|
|
335
380
|
[exportFormat, showTimestamps, showPodName],
|
|
@@ -341,7 +386,7 @@ export function LogCore({
|
|
|
341
386
|
[downloadMenu.shouldRender, exportEntries, exportOptions],
|
|
342
387
|
)
|
|
343
388
|
// Offering a scope that resolves to the same lines is a choice about nothing.
|
|
344
|
-
const scopeIsMeaningful = displayEntries.length !==
|
|
389
|
+
const scopeIsMeaningful = displayEntries.length !== bufferLineCount
|
|
345
390
|
|
|
346
391
|
// Scope describes the filters in play at the moment of asking, not a lasting
|
|
347
392
|
// intent, so it starts over at the narrower option every time rather than
|
|
@@ -360,15 +405,16 @@ export function LogCore({
|
|
|
360
405
|
const count = exportEntries.length
|
|
361
406
|
const failed = () => showApiError('Failed to copy logs', 'The browser blocked clipboard access.')
|
|
362
407
|
copyText(serializeLogEntries(exportEntries, exportOptions).content).then(
|
|
363
|
-
ok => ok ? showApiSuccess('Copied to clipboard', `${count} log line${count === 1 ? '' : 's'} copied
|
|
408
|
+
ok => ok ? showApiSuccess('Copied to clipboard', `${count} log line${count === 1 ? '' : 's'} copied.${exportHasGap ? ` ${gapNotice}` : ''}`) : failed(),
|
|
364
409
|
failed,
|
|
365
410
|
)
|
|
366
|
-
}, [closeExportMenu, exportEntries, exportOptions])
|
|
411
|
+
}, [closeExportMenu, exportEntries, exportOptions, exportHasGap])
|
|
367
412
|
|
|
368
413
|
const handleExportDownload = useCallback(() => {
|
|
369
414
|
closeExportMenu()
|
|
370
415
|
onDownload(serializeLogEntries(exportEntries, exportOptions))
|
|
371
|
-
|
|
416
|
+
if (exportHasGap) showApiSuccess('Some logs were unavailable', gapNotice)
|
|
417
|
+
}, [closeExportMenu, onDownload, exportEntries, exportOptions, exportHasGap])
|
|
372
418
|
|
|
373
419
|
// Same close-on-outside-click for the timestamp format menu.
|
|
374
420
|
const tsMenuRef = useRef<HTMLDivElement>(null)
|
|
@@ -436,14 +482,9 @@ export function LogCore({
|
|
|
436
482
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
437
483
|
}, [search.open, onStartStream, onStopStream, isStreaming, sourceUnavailable])
|
|
438
484
|
|
|
439
|
-
const handleFollowOutput = useCallback((isAtBottom: boolean) => {
|
|
440
|
-
if (isAtBottom) return 'smooth' as const
|
|
441
|
-
return false as const
|
|
442
|
-
}, [])
|
|
443
|
-
|
|
444
485
|
const handleAtBottomStateChange = useCallback((bottom: boolean) => {
|
|
445
|
-
|
|
446
|
-
}, [])
|
|
486
|
+
if (bottom && resumeAtBottom.current && pendingLineCount === 0) setFollowing(true)
|
|
487
|
+
}, [setFollowing, pendingLineCount])
|
|
447
488
|
|
|
448
489
|
const toggleWrap = useCallback(() => {
|
|
449
490
|
setWordWrap(prev => {
|
|
@@ -532,13 +573,54 @@ export function LogCore({
|
|
|
532
573
|
return groupContinuations(displayEntries, association.headIdById)
|
|
533
574
|
}, [displayEntries, collapseStacks, highlightQuery, association])
|
|
534
575
|
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
576
|
+
const pinToBottom = useCallback(() => {
|
|
577
|
+
const scroller = scrollerRef.current
|
|
578
|
+
if (!followingRef.current || !scroller || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <= 1) return
|
|
579
|
+
scroller.scrollTop = scroller.scrollHeight
|
|
580
|
+
}, [])
|
|
581
|
+
useLayoutEffect(() => {
|
|
582
|
+
followingRef.current = isFollowing
|
|
583
|
+
pinToBottom()
|
|
584
|
+
}, [groupedEntries, isFollowing, pinToBottom])
|
|
585
|
+
const pinFrame = useRef<number | null>(null)
|
|
586
|
+
const viewportObserver = useRef<ResizeObserver | null>(null)
|
|
587
|
+
const schedulePinToBottom = useCallback(() => {
|
|
588
|
+
if (!followingRef.current || !scrollerRef.current || pinFrame.current !== null) return
|
|
589
|
+
pinFrame.current = requestAnimationFrame(() => {
|
|
590
|
+
pinFrame.current = null
|
|
591
|
+
pinToBottom()
|
|
540
592
|
})
|
|
541
|
-
}, [
|
|
593
|
+
}, [pinToBottom])
|
|
594
|
+
const handleScrollerRef = useCallback((element: HTMLElement | Window | null) => {
|
|
595
|
+
viewportObserver.current?.disconnect()
|
|
596
|
+
const scroller = element instanceof HTMLElement ? element : null
|
|
597
|
+
scrollerRef.current = scroller
|
|
598
|
+
if (scroller && typeof ResizeObserver !== 'undefined') {
|
|
599
|
+
viewportObserver.current = new ResizeObserver(schedulePinToBottom)
|
|
600
|
+
viewportObserver.current.observe(scroller)
|
|
601
|
+
const list = scroller.querySelector('[data-testid="virtuoso-item-list"]')
|
|
602
|
+
if (list) viewportObserver.current.observe(list, { box: 'border-box' })
|
|
603
|
+
}
|
|
604
|
+
schedulePinToBottom()
|
|
605
|
+
}, [schedulePinToBottom])
|
|
606
|
+
useEffect(() => () => {
|
|
607
|
+
if (pinFrame.current !== null) cancelAnimationFrame(pinFrame.current)
|
|
608
|
+
pinFrame.current = null
|
|
609
|
+
viewportObserver.current?.disconnect()
|
|
610
|
+
}, [])
|
|
611
|
+
|
|
612
|
+
const scrollToBottom = useCallback(() => {
|
|
613
|
+
setFollowing(true)
|
|
614
|
+
}, [setFollowing])
|
|
615
|
+
|
|
616
|
+
const returnToLiveButton = !isFollowing && (
|
|
617
|
+
<Tooltip content={missingLineCount > 0 ? `${gapNotice} Jump to the live stream.` : pendingLineCount > 0 ? 'Your place is held while new logs arrive across collected sources. Jump to the live stream.' : 'Scroll to bottom'} wrapperClassName="absolute bottom-4 right-14 z-10" position="top">
|
|
618
|
+
<button onClick={scrollToBottom} aria-label="Scroll to bottom" className="px-3 py-1.5 btn-brand text-xs rounded-full shadow-lg">
|
|
619
|
+
Scroll to bottom
|
|
620
|
+
{pendingLineCount > 0 && ` · ${pendingLineCount >= 20_000 ? '20K+' : pendingLineCount.toLocaleString()} new ${pendingLineCount === 1 ? 'line' : 'lines'}`}
|
|
621
|
+
</button>
|
|
622
|
+
</Tooltip>
|
|
623
|
+
)
|
|
542
624
|
|
|
543
625
|
const toolbarExtraNode = typeof toolbarExtra === 'function'
|
|
544
626
|
? toolbarExtra({ isDark, palette })
|
|
@@ -805,10 +887,10 @@ export function LogCore({
|
|
|
805
887
|
|
|
806
888
|
{/* Export */}
|
|
807
889
|
<div className="relative flex items-center" ref={downloadMenuRef}>
|
|
808
|
-
<Tooltip content={
|
|
890
|
+
<Tooltip content={bufferLineCount === 0 ? 'No logs loaded' : 'Export logs'} delay={TIP_DELAY} position="bottom" disabled={showDownloadMenu} preserveWrapperWhenDisabled>
|
|
809
891
|
<button
|
|
810
892
|
ref={exportTriggerRef}
|
|
811
|
-
disabled={
|
|
893
|
+
disabled={bufferLineCount === 0}
|
|
812
894
|
onClick={toggleExportMenu}
|
|
813
895
|
aria-label="Export logs"
|
|
814
896
|
aria-haspopup="dialog"
|
|
@@ -836,16 +918,17 @@ export function LogCore({
|
|
|
836
918
|
Filtered {displayEntries.length.toLocaleString()}
|
|
837
919
|
</ExportChoice>
|
|
838
920
|
<ExportChoice active={exportScope === 'all'} onClick={() => setExportScope('all')} palette={palette}>
|
|
839
|
-
All {
|
|
921
|
+
All {bufferLineCount.toLocaleString()}
|
|
840
922
|
</ExportChoice>
|
|
841
923
|
</div>
|
|
842
924
|
) : (
|
|
843
925
|
<span className={`text-[11px] ${palette.textSecondary}`}>
|
|
844
|
-
{
|
|
926
|
+
{bufferLineCount.toLocaleString()} line{bufferLineCount === 1 ? '' : 's'}
|
|
845
927
|
</span>
|
|
846
928
|
)}
|
|
847
929
|
</div>
|
|
848
930
|
|
|
931
|
+
{exportHasGap && <p className={`text-xs ${palette.textSecondary}`}>{gapNotice}</p>}
|
|
849
932
|
<div className="flex items-center gap-3">
|
|
850
933
|
<span id="log-export-format-label" className={exportLabelCls}>Format</span>
|
|
851
934
|
<div role="radiogroup" aria-labelledby="log-export-format-label" className="flex items-center gap-1">
|
|
@@ -889,11 +972,11 @@ export function LogCore({
|
|
|
889
972
|
|
|
890
973
|
{/* Clear */}
|
|
891
974
|
{onClear && (
|
|
892
|
-
<Tooltip content={
|
|
975
|
+
<Tooltip content={bufferLineCount === 0 ? 'No logs loaded' : 'Clear logs'} delay={TIP_DELAY} position="bottom">
|
|
893
976
|
<button
|
|
894
977
|
onClick={onClear}
|
|
895
978
|
aria-label="Clear logs"
|
|
896
|
-
disabled={
|
|
979
|
+
disabled={bufferLineCount === 0}
|
|
897
980
|
className={`${iconBtnInactive} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
898
981
|
>
|
|
899
982
|
<Trash2 className="w-4 h-4" />
|
|
@@ -1013,9 +1096,10 @@ export function LogCore({
|
|
|
1013
1096
|
<span>{errorMessage}</span>
|
|
1014
1097
|
</div>
|
|
1015
1098
|
) : groupedEntries.length === 0 ? (
|
|
1016
|
-
<div className={`${EMPTY_STATE_CLASS} ${palette.textTertiary}`}>
|
|
1099
|
+
<div className={`${EMPTY_STATE_CLASS} relative ${palette.textTertiary}`}>
|
|
1017
1100
|
<Terminal className="w-8 h-8" />
|
|
1018
1101
|
<div>{entries.length > 0 ? `Filters hide all ${entries.length.toLocaleString()} loaded lines` : emptyMessage}</div>
|
|
1102
|
+
{returnToLiveButton}
|
|
1019
1103
|
{entries.length === 0 && emptyCommand && (
|
|
1020
1104
|
<button type="button" onClick={() => { void copyText(emptyCommand) }} className={`mt-2 inline-flex max-w-[80%] items-center gap-2 rounded border px-3 py-2 font-mono text-xs ${palette.border} ${palette.toolbarBg}`} title="Copy recovery command">
|
|
1021
1105
|
<code className="truncate">{emptyCommand}</code>
|
|
@@ -1024,12 +1108,54 @@ export function LogCore({
|
|
|
1024
1108
|
)}
|
|
1025
1109
|
</div>
|
|
1026
1110
|
) : (
|
|
1027
|
-
<div
|
|
1111
|
+
<div
|
|
1112
|
+
className="flex-1 relative min-h-0"
|
|
1113
|
+
tabIndex={0}
|
|
1114
|
+
onFocusCapture={event => {
|
|
1115
|
+
const target = event.target
|
|
1116
|
+
if (!(target instanceof HTMLElement) || !target.closest('[data-virtuoso-scroller]')) return
|
|
1117
|
+
if (!target.closest('button, a, input, select, textarea, [role="button"]')) return
|
|
1118
|
+
setFollowing(false)
|
|
1119
|
+
target.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
1120
|
+
}}
|
|
1121
|
+
onWheelCapture={event => {
|
|
1122
|
+
if (event.deltaY < 0) setFollowing(false)
|
|
1123
|
+
if (event.deltaY > 0) resumeAtBottom.current = true
|
|
1124
|
+
}}
|
|
1125
|
+
onScrollCapture={event => {
|
|
1126
|
+
const scroller = event.target
|
|
1127
|
+
if (!(scroller instanceof HTMLElement) || !scroller.hasAttribute('data-virtuoso-scroller')) return
|
|
1128
|
+
const previous = scrollPosition.current
|
|
1129
|
+
if (previous !== null && scroller.scrollTop > previous && scrollbarDragging.current && !followingRef.current) resumeAtBottom.current = true
|
|
1130
|
+
scrollPosition.current = scroller.scrollTop
|
|
1131
|
+
}}
|
|
1132
|
+
onKeyDownCapture={event => {
|
|
1133
|
+
if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) setFollowing(false)
|
|
1134
|
+
if (['ArrowDown', 'PageDown', 'End'].includes(event.key) || (event.key === ' ' && !event.shiftKey)) resumeAtBottom.current = true
|
|
1135
|
+
}}
|
|
1136
|
+
onTouchStartCapture={event => { touchY.current = event.touches[0]?.clientY ?? null }}
|
|
1137
|
+
onTouchMoveCapture={event => {
|
|
1138
|
+
const y = event.touches[0]?.clientY
|
|
1139
|
+
if (y !== undefined && touchY.current !== null && y > touchY.current) setFollowing(false)
|
|
1140
|
+
if (y !== undefined && touchY.current !== null && y < touchY.current) resumeAtBottom.current = true
|
|
1141
|
+
touchY.current = y ?? null
|
|
1142
|
+
}}
|
|
1143
|
+
onPointerDownCapture={event => {
|
|
1144
|
+
const scroller = event.currentTarget.querySelector('[data-virtuoso-scroller]')
|
|
1145
|
+
scrollbarDragging.current = !!scroller && event.clientX >= scroller.getBoundingClientRect().right - 20
|
|
1146
|
+
const target = event.target
|
|
1147
|
+
const interactingWithLogs = target instanceof Element && !!target.closest('[data-virtuoso-scroller]')
|
|
1148
|
+
if (scrollbarDragging.current || (interactingWithLogs && (event.button === 0 || event.button === 1))) setFollowing(false)
|
|
1149
|
+
}}
|
|
1150
|
+
onPointerUpCapture={() => { scrollbarDragging.current = false }}
|
|
1151
|
+
onPointerCancelCapture={() => { scrollbarDragging.current = false }}
|
|
1152
|
+
>
|
|
1028
1153
|
<Virtuoso
|
|
1029
1154
|
ref={virtuosoRef}
|
|
1030
1155
|
data={groupedEntries}
|
|
1031
|
-
|
|
1032
|
-
|
|
1156
|
+
scrollerRef={handleScrollerRef}
|
|
1157
|
+
computeItemKey={(_index, group) => group.head.id}
|
|
1158
|
+
totalListHeightChanged={schedulePinToBottom}
|
|
1033
1159
|
atBottomStateChange={handleAtBottomStateChange}
|
|
1034
1160
|
atBottomThreshold={50}
|
|
1035
1161
|
increaseViewportBy={200}
|
|
@@ -1057,14 +1183,7 @@ export function LogCore({
|
|
|
1057
1183
|
className="h-full font-mono text-xs"
|
|
1058
1184
|
/>
|
|
1059
1185
|
{/* Scroll-to-bottom button */}
|
|
1060
|
-
{
|
|
1061
|
-
<button
|
|
1062
|
-
onClick={scrollToBottom}
|
|
1063
|
-
className="absolute bottom-4 right-14 px-3 py-1.5 btn-brand text-xs rounded-full shadow-lg z-10"
|
|
1064
|
-
>
|
|
1065
|
-
Scroll to bottom
|
|
1066
|
-
</button>
|
|
1067
|
-
)}
|
|
1186
|
+
{returnToLiveButton}
|
|
1068
1187
|
</div>
|
|
1069
1188
|
)}
|
|
1070
1189
|
|
|
@@ -1255,7 +1374,7 @@ function LogLine({
|
|
|
1255
1374
|
<span className="flex-1 min-w-0">{contentElement}</span>
|
|
1256
1375
|
<button
|
|
1257
1376
|
onClick={handleCopy}
|
|
1258
|
-
className={`opacity-0 group-hover:opacity-100 ml-1 p-0.5 rounded ${palette.textTertiary} ${palette.hoverText} shrink-0 transition-opacity`}
|
|
1377
|
+
className={`opacity-0 group-hover:opacity-100 focus-visible:opacity-100 ml-1 p-0.5 rounded ${palette.textTertiary} ${palette.hoverText} shrink-0 transition-opacity`}
|
|
1259
1378
|
title="Copy line"
|
|
1260
1379
|
>
|
|
1261
1380
|
<Copy className="w-3 h-3" />
|
|
@@ -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,14 +60,14 @@ 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']) {
|
|
47
64
|
await act(async () =>
|
|
48
65
|
root.render(
|
|
49
66
|
<ToastProvider>
|
|
50
67
|
<LogsViewer
|
|
51
68
|
namespace="default"
|
|
52
69
|
podName="web-0"
|
|
53
|
-
containers={
|
|
70
|
+
containers={containers}
|
|
54
71
|
fetchLogs={fetchLogs}
|
|
55
72
|
createStream={() => new EventSource('/stream')}
|
|
56
73
|
autoStream={autoStream}
|
|
@@ -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`)
|