@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
|
@@ -77,8 +77,11 @@ export function LogsViewer({
|
|
|
77
77
|
const { showError, showSuccess } = useToast()
|
|
78
78
|
|
|
79
79
|
const { tailLines, sinceSeconds } = parseLogRange(logRange)
|
|
80
|
-
const { entries, append, set, clear } = useLogBuffer()
|
|
80
|
+
const { entries, isFollowing, pendingLineCount, retainedLineCount, missingLineCount, getRetainedEntries, setFollowing, append, set, clear } = useLogBuffer()
|
|
81
81
|
const { isStreaming, streamError, streamErrorCode, connecting, startStreaming, stopStreaming } = useLogStream()
|
|
82
|
+
const selectionKey = JSON.stringify([_namespace, podName, selectedContainer, logRange])
|
|
83
|
+
// Identify the selection that supplied the buffered snapshot or stream.
|
|
84
|
+
const bufferSelectionRef = useRef<{ key: string; previous: boolean } | null>(null)
|
|
82
85
|
|
|
83
86
|
const willAutoStream = autoStream && !!createStream
|
|
84
87
|
// Tracks the container we've already auto-started for, so re-renders don't
|
|
@@ -109,6 +112,7 @@ export function LogsViewer({
|
|
|
109
112
|
try {
|
|
110
113
|
const data = await fetchLogs({ container: selectedContainer, tailLines, sinceSeconds, previous: showPrevious })
|
|
111
114
|
if (seq !== loadSeqRef.current) return
|
|
115
|
+
bufferSelectionRef.current = { key: selectionKey, previous: showPrevious }
|
|
112
116
|
const logText = data[selectedContainer] ?? Object.values(data)[0] ?? ''
|
|
113
117
|
set(logText.split('\n').filter(Boolean).map(line => {
|
|
114
118
|
const { timestamp, content } = parseLogLine(line)
|
|
@@ -123,11 +127,12 @@ export function LogsViewer({
|
|
|
123
127
|
return
|
|
124
128
|
}
|
|
125
129
|
console.error('Failed to fetch logs:', err)
|
|
130
|
+
if (bufferSelectionRef.current?.key !== selectionKey || bufferSelectionRef.current.previous !== showPrevious) set([])
|
|
126
131
|
setFetchError(err instanceof Error ? err.message : 'Failed to fetch logs')
|
|
127
132
|
} finally {
|
|
128
133
|
if (seq === loadSeqRef.current) setIsLoading(false)
|
|
129
134
|
}
|
|
130
|
-
}, [selectedContainer, tailLines, sinceSeconds, showPrevious, fetchLogs, set, stopStreaming])
|
|
135
|
+
}, [selectedContainer, tailLines, sinceSeconds, showPrevious, fetchLogs, set, stopStreaming, selectionKey])
|
|
131
136
|
|
|
132
137
|
// When auto-streaming the stream supplies the initial tail, so the static
|
|
133
138
|
// snapshot fetch is skipped to avoid a redundant request and a flash of
|
|
@@ -170,6 +175,7 @@ export function LogsViewer({
|
|
|
170
175
|
// they don't duplicate lines already in the buffer (the snapshot on the
|
|
171
176
|
// manual path, or an earlier stream on restart).
|
|
172
177
|
clear()
|
|
178
|
+
bufferSelectionRef.current = { key: selectionKey, previous: false }
|
|
173
179
|
startStreaming(
|
|
174
180
|
() => createStream({ container: selectedContainer, tailLines: 100, sinceSeconds }),
|
|
175
181
|
{
|
|
@@ -181,7 +187,7 @@ export function LogsViewer({
|
|
|
181
187
|
},
|
|
182
188
|
'Log stream connection failed',
|
|
183
189
|
)
|
|
184
|
-
}, [createStream, startStreaming, selectedContainer, sinceSeconds, append, clear])
|
|
190
|
+
}, [createStream, startStreaming, selectedContainer, sinceSeconds, append, clear, selectionKey])
|
|
185
191
|
|
|
186
192
|
const handleStopStreaming = useCallback(() => {
|
|
187
193
|
userStoppedRef.current = true
|
|
@@ -241,10 +247,19 @@ export function LogsViewer({
|
|
|
241
247
|
const streamAnswer = isClusterAnswerCode(streamErrorCode) ? streamError : null
|
|
242
248
|
|
|
243
249
|
return (
|
|
250
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
251
|
+
{(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>}
|
|
252
|
+
<div className="min-h-0 flex-1">
|
|
244
253
|
<LogCore
|
|
245
254
|
entries={entries}
|
|
255
|
+
isFollowing={isFollowing}
|
|
256
|
+
onFollowingChange={setFollowing}
|
|
257
|
+
pendingLineCount={pendingLineCount}
|
|
258
|
+
retainedLineCount={retainedLineCount}
|
|
259
|
+
missingLineCount={missingLineCount}
|
|
260
|
+
getRetainedEntries={getRetainedEntries}
|
|
246
261
|
isLoading={isLoading || isConnecting}
|
|
247
|
-
errorMessage={
|
|
262
|
+
errorMessage={entries.length === 0 ? fetchError || (!emptyMessage && !streamAnswer ? streamError : null) : null}
|
|
248
263
|
isStreaming={isStreaming}
|
|
249
264
|
onStartStream={createStream ? handleStartStreaming : undefined}
|
|
250
265
|
onStopStream={handleStopStreaming}
|
|
@@ -256,5 +271,7 @@ export function LogsViewer({
|
|
|
256
271
|
forceDark={forceDark}
|
|
257
272
|
defaultDark={defaultDark}
|
|
258
273
|
/>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
259
276
|
)
|
|
260
277
|
}
|
|
@@ -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
|
+
})
|
|
@@ -35,6 +35,12 @@ type RawLogEntry = Omit<LogEntry, 'id' | 'level' | 'levelSource' | 'isJson' | 'i
|
|
|
35
35
|
|
|
36
36
|
interface UseLogBufferReturn {
|
|
37
37
|
entries: LogEntry[]
|
|
38
|
+
isFollowing: boolean
|
|
39
|
+
retainedLineCount: number
|
|
40
|
+
missingLineCount: number
|
|
41
|
+
getRetainedEntries: () => LogEntry[]
|
|
42
|
+
pendingLineCount: number
|
|
43
|
+
setFollowing: (following: boolean) => void
|
|
38
44
|
append: (entry: RawLogEntry) => void
|
|
39
45
|
appendBatch: (entries: RawLogEntry[]) => void
|
|
40
46
|
set: (entries: RawLogEntry[]) => void
|
|
@@ -42,7 +48,7 @@ interface UseLogBufferReturn {
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export function useLogBuffer(): UseLogBufferReturn {
|
|
45
|
-
const [
|
|
51
|
+
const [buffer, setBuffer] = useState<{ latest: LogEntry[]; reading: LogEntry[] | null }>({ latest: [], reading: null })
|
|
46
52
|
const idCounter = useRef(0)
|
|
47
53
|
const pendingRef = useRef<RawLogEntry[]>([])
|
|
48
54
|
const rafRef = useRef<number | null>(null)
|
|
@@ -65,30 +71,35 @@ export function useLogBuffer(): UseLogBufferReturn {
|
|
|
65
71
|
const batch = pendingRef.current
|
|
66
72
|
if (batch.length === 0) return
|
|
67
73
|
pendingRef.current = []
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const combined = [...prev, ...enriched]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const enriched = batch.map(enrichEntry)
|
|
75
|
+
|
|
76
|
+
setBuffer(prev => {
|
|
77
|
+
const combined = [...prev.latest, ...enriched]
|
|
78
|
+
const latest = combined.length > MAX_BUFFER_SIZE ? combined.slice(-MAX_BUFFER_SIZE) : combined
|
|
79
|
+
// Keep appending while the reader's window fits. Once eviction begins,
|
|
80
|
+
// retain that window rather than replacing the lines being read.
|
|
81
|
+
const reading = prev.reading === null ? null
|
|
82
|
+
: prev.reading === prev.latest && combined.length <= MAX_BUFFER_SIZE ? latest : prev.reading
|
|
83
|
+
return { latest, reading }
|
|
76
84
|
})
|
|
77
85
|
}, [enrichEntry])
|
|
78
86
|
|
|
79
87
|
const append = useCallback((entry: RawLogEntry) => {
|
|
80
88
|
pendingRef.current.push(entry)
|
|
89
|
+
// Hidden tabs can stop delivering animation frames while logs still arrive.
|
|
90
|
+
if (pendingRef.current.length >= MAX_BUFFER_SIZE) {
|
|
91
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
|
92
|
+
flushPending()
|
|
93
|
+
return
|
|
94
|
+
}
|
|
81
95
|
if (rafRef.current === null) {
|
|
82
96
|
rafRef.current = requestAnimationFrame(flushPending)
|
|
83
97
|
}
|
|
84
98
|
}, [flushPending])
|
|
85
99
|
|
|
86
100
|
const appendBatch = useCallback((batch: RawLogEntry[]) => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
rafRef.current = requestAnimationFrame(flushPending)
|
|
90
|
-
}
|
|
91
|
-
}, [flushPending])
|
|
101
|
+
for (const entry of batch) append(entry)
|
|
102
|
+
}, [append])
|
|
92
103
|
|
|
93
104
|
const set = useCallback((rawEntries: RawLogEntry[]) => {
|
|
94
105
|
// Cancel any pending RAF
|
|
@@ -100,9 +111,9 @@ export function useLogBuffer(): UseLogBufferReturn {
|
|
|
100
111
|
|
|
101
112
|
const enriched = rawEntries.map(enrichEntry)
|
|
102
113
|
if (enriched.length > MAX_BUFFER_SIZE) {
|
|
103
|
-
|
|
114
|
+
setBuffer({ latest: enriched.slice(enriched.length - MAX_BUFFER_SIZE), reading: null })
|
|
104
115
|
} else {
|
|
105
|
-
|
|
116
|
+
setBuffer({ latest: enriched, reading: null })
|
|
106
117
|
}
|
|
107
118
|
}, [enrichEntry])
|
|
108
119
|
|
|
@@ -112,8 +123,27 @@ export function useLogBuffer(): UseLogBufferReturn {
|
|
|
112
123
|
rafRef.current = null
|
|
113
124
|
}
|
|
114
125
|
pendingRef.current = []
|
|
115
|
-
|
|
126
|
+
setBuffer({ latest: [], reading: null })
|
|
127
|
+
}, [])
|
|
128
|
+
|
|
129
|
+
const setFollowing = useCallback((following: boolean) => {
|
|
130
|
+
setBuffer(prev => {
|
|
131
|
+
if (following) return prev.reading === null ? prev : { ...prev, reading: null }
|
|
132
|
+
return prev.reading === null ? { ...prev, reading: prev.latest } : prev
|
|
133
|
+
})
|
|
116
134
|
}, [])
|
|
117
135
|
|
|
118
|
-
|
|
136
|
+
const entries = buffer.reading ?? buffer.latest
|
|
137
|
+
const pendingLineCount = (buffer.latest.at(-1)?.id ?? -1) - (entries.at(-1)?.id ?? -1)
|
|
138
|
+
const readingLastId = entries.at(-1)?.id ?? -1
|
|
139
|
+
const latestFirstId = buffer.latest[0]?.id ?? 0
|
|
140
|
+
const overlap = Math.max(0, readingLastId - latestFirstId + 1)
|
|
141
|
+
const retainedLineCount = buffer.reading === null ? buffer.latest.length : entries.length + buffer.latest.length - overlap
|
|
142
|
+
const missingLineCount = buffer.reading === null ? 0 : Math.max(0, latestFirstId - readingLastId - 1)
|
|
143
|
+
const getRetainedEntries = useCallback(() => {
|
|
144
|
+
if (buffer.reading === null || buffer.reading === buffer.latest) return buffer.latest
|
|
145
|
+
const lastId = buffer.reading.at(-1)?.id ?? -1
|
|
146
|
+
return [...buffer.reading, ...buffer.latest.filter(entry => entry.id > lastId)]
|
|
147
|
+
}, [buffer])
|
|
148
|
+
return { entries, retainedLineCount, missingLineCount, getRetainedEntries, isFollowing: buffer.reading === null, pendingLineCount, setFollowing, append, appendBatch, set, clear }
|
|
119
149
|
}
|
|
@@ -54,6 +54,7 @@ export function useLogSearch(
|
|
|
54
54
|
virtuosoRef: React.RefObject<VirtuosoHandle | null>,
|
|
55
55
|
/** Id of the record an entry belongs to (its first line's id); defaults to the entry itself */
|
|
56
56
|
recordIdOf?: (entry: LogEntry) => number,
|
|
57
|
+
onNavigate?: () => void,
|
|
57
58
|
): UseLogSearchReturn {
|
|
58
59
|
const [query, setQuery] = useState('')
|
|
59
60
|
const [isRegex, setIsRegex] = useState(false)
|
|
@@ -122,6 +123,7 @@ export function useLogSearch(
|
|
|
122
123
|
const scrollToMatch = useCallback((matchIdx: number) => {
|
|
123
124
|
if (matchIdx < 0 || matchIdx >= matchIndices.length) return
|
|
124
125
|
if (mode === 'hide') return
|
|
126
|
+
onNavigate?.()
|
|
125
127
|
if (mode === 'only') {
|
|
126
128
|
// The list holds whole matching records, so find the matched line within it
|
|
127
129
|
const index = filteredIndexById?.get(entries[matchIndices[matchIdx]].id)
|
|
@@ -139,7 +141,7 @@ export function useLogSearch(
|
|
|
139
141
|
behavior: 'smooth',
|
|
140
142
|
})
|
|
141
143
|
}
|
|
142
|
-
}, [entries, matchIndices, mode, filteredIndexById, virtuosoRef])
|
|
144
|
+
}, [entries, matchIndices, mode, filteredIndexById, virtuosoRef, onNavigate])
|
|
143
145
|
|
|
144
146
|
const goToNext = useCallback(() => {
|
|
145
147
|
if (matchIndices.length === 0 || mode === 'hide') return
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ColumnPickerList } from './ColumnPickerList'
|
|
6
|
+
|
|
7
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
8
|
+
|
|
9
|
+
describe('ColumnPickerList keyboard focus', () => {
|
|
10
|
+
it.each([
|
|
11
|
+
['namespace', 'ArrowUp'],
|
|
12
|
+
['age', 'ArrowDown'],
|
|
13
|
+
])('does not restore stale focus after repeated boundary moves on %s', (key, direction) => {
|
|
14
|
+
const host = document.createElement('div')
|
|
15
|
+
document.body.append(host)
|
|
16
|
+
const root = createRoot(host)
|
|
17
|
+
const onReorder = vi.fn()
|
|
18
|
+
const columns = ['name', 'namespace', 'age'].map(key => ({ key, label: key }))
|
|
19
|
+
const render = () => act(() => root.render(<ColumnPickerList
|
|
20
|
+
columns={columns}
|
|
21
|
+
visibleColumns={new Set(['name', 'namespace', 'age'])}
|
|
22
|
+
customColumnKeys={new Set()}
|
|
23
|
+
onToggle={() => {}}
|
|
24
|
+
onRemoveCustom={() => {}}
|
|
25
|
+
onReorder={onReorder}
|
|
26
|
+
/>))
|
|
27
|
+
try {
|
|
28
|
+
render()
|
|
29
|
+
const inputs = [...host.querySelectorAll<HTMLInputElement>('input')]
|
|
30
|
+
const current = inputs[columns.findIndex(column => column.key === key)]
|
|
31
|
+
const other = inputs[key === 'namespace' ? 2 : 1]
|
|
32
|
+
if (!current || !other) throw new Error('Expected both movable checkboxes')
|
|
33
|
+
current.focus()
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
act(() => current.dispatchEvent(new KeyboardEvent('keydown', { key: direction, altKey: true, bubbles: true })))
|
|
36
|
+
}
|
|
37
|
+
other.focus()
|
|
38
|
+
render()
|
|
39
|
+
expect(document.activeElement).toBe(other)
|
|
40
|
+
expect(onReorder).not.toHaveBeenCalled()
|
|
41
|
+
} finally {
|
|
42
|
+
act(() => root.unmount())
|
|
43
|
+
host.remove()
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import type React from 'react'
|
|
3
|
+
import { GripVertical, X } from 'lucide-react'
|
|
4
|
+
import { clsx } from 'clsx'
|
|
5
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
6
|
+
import { isMac } from '../../utils/platform'
|
|
7
|
+
import { moveColumn, pinnedColumnCount } from './column-order'
|
|
8
|
+
import type { Column } from './ResourcesView'
|
|
9
|
+
|
|
10
|
+
interface ColumnPickerListProps {
|
|
11
|
+
/** Every offered column, in display order. */
|
|
12
|
+
columns: Column[]
|
|
13
|
+
visibleColumns: Set<string>
|
|
14
|
+
customColumnKeys: Set<string>
|
|
15
|
+
onToggle: (key: string) => void
|
|
16
|
+
onRemoveCustom: (key: string) => void
|
|
17
|
+
/** Receives the movable column keys in their new order. */
|
|
18
|
+
onReorder: (order: string[]) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The Columns picker's rows: show/hide, remove a custom column, and reorder by
|
|
22
|
+
* dragging the grip or with Alt/Option+Up/Down on the row's checkbox. */
|
|
23
|
+
export function ColumnPickerList({
|
|
24
|
+
columns,
|
|
25
|
+
visibleColumns,
|
|
26
|
+
customColumnKeys,
|
|
27
|
+
onToggle,
|
|
28
|
+
onRemoveCustom,
|
|
29
|
+
onReorder,
|
|
30
|
+
}: ColumnPickerListProps) {
|
|
31
|
+
const pinned = pinnedColumnCount(columns)
|
|
32
|
+
const [dragKey, setDragKey] = useState<string | null>(null)
|
|
33
|
+
// Insertion point among the movable rows: 0 = before the first one.
|
|
34
|
+
const [dropGap, setDropGap] = useState<number | null>(null)
|
|
35
|
+
const [announcement, setAnnouncement] = useState('')
|
|
36
|
+
const hintId = useId()
|
|
37
|
+
const mac = isMac()
|
|
38
|
+
|
|
39
|
+
// React moves a keyed row by re-inserting DOM nodes, and re-inserting the
|
|
40
|
+
// focused checkbox drops focus. Put it back so repeated presses keep working.
|
|
41
|
+
const refocus = useRef<HTMLElement | null>(null)
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
const el = refocus.current
|
|
44
|
+
if (!el) return
|
|
45
|
+
refocus.current = null
|
|
46
|
+
if (el.isConnected && document.activeElement !== el) el.focus()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const commitMove = (key: string, toIndex: number) => {
|
|
50
|
+
const next = moveColumn(columns, key, toIndex)
|
|
51
|
+
if (!next) return false
|
|
52
|
+
onReorder(next)
|
|
53
|
+
const col = columns.find(c => c.key === key)
|
|
54
|
+
setAnnouncement(`${col?.label ?? key} moved to position ${pinned + next.indexOf(key) + 1} of ${columns.length}.`)
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const handleKeyDown = (e: React.KeyboardEvent, key: string, movableIndex: number) => {
|
|
59
|
+
if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return
|
|
60
|
+
e.preventDefault()
|
|
61
|
+
e.stopPropagation()
|
|
62
|
+
const up = e.key === 'ArrowUp'
|
|
63
|
+
refocus.current = e.target instanceof HTMLElement ? e.target : null
|
|
64
|
+
if (!commitMove(key, movableIndex + (up ? -1 : 1))) {
|
|
65
|
+
refocus.current = null
|
|
66
|
+
const label = columns.find(c => c.key === key)?.label ?? key
|
|
67
|
+
setAnnouncement(up ? `${label} is already at the top.` : `${label} is already at the bottom.`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The gap above the row when the pointer is in its top half, else below it.
|
|
72
|
+
const gapAt = (e: React.DragEvent, movableIndex: number) => {
|
|
73
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
74
|
+
return e.clientY < rect.top + rect.height / 2 ? movableIndex : movableIndex + 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const endDrag = () => {
|
|
78
|
+
setDragKey(null)
|
|
79
|
+
setDropGap(null)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
<span id={hintId} className="sr-only">
|
|
85
|
+
{mac ? 'Press Option+Up Arrow or Option+Down Arrow to move this column.' : 'Press Alt+Up Arrow or Alt+Down Arrow to move this column.'}
|
|
86
|
+
</span>
|
|
87
|
+
<span className="sr-only" role="status" aria-live="polite">{announcement}</span>
|
|
88
|
+
{columns.map((col, i) => {
|
|
89
|
+
const isCustom = customColumnKeys.has(col.key)
|
|
90
|
+
const movableIndex = i - pinned
|
|
91
|
+
const movable = movableIndex >= 0
|
|
92
|
+
const fromIndex = dragKey ? columns.findIndex(c => c.key === dragKey) - pinned : -1
|
|
93
|
+
const showGap = (gap: number) => dropGap === gap && gap !== fromIndex && gap !== fromIndex + 1
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
key={col.key}
|
|
97
|
+
data-column-row
|
|
98
|
+
className={clsx(
|
|
99
|
+
'group relative flex items-center gap-1.5 pl-1.5 pr-3 py-1.5 hover:bg-theme-elevated',
|
|
100
|
+
dragKey === col.key && 'opacity-50',
|
|
101
|
+
)}
|
|
102
|
+
onKeyDown={movable ? e => handleKeyDown(e, col.key, movableIndex) : undefined}
|
|
103
|
+
onDragOver={movable && dragKey ? e => {
|
|
104
|
+
e.preventDefault()
|
|
105
|
+
e.dataTransfer.dropEffect = 'move'
|
|
106
|
+
setDropGap(gapAt(e, movableIndex))
|
|
107
|
+
} : undefined}
|
|
108
|
+
onDrop={movable && dragKey ? e => {
|
|
109
|
+
e.preventDefault()
|
|
110
|
+
const gap = gapAt(e, movableIndex)
|
|
111
|
+
commitMove(dragKey, gap > fromIndex ? gap - 1 : gap)
|
|
112
|
+
endDrag()
|
|
113
|
+
} : undefined}
|
|
114
|
+
>
|
|
115
|
+
{movable && showGap(movableIndex) && (
|
|
116
|
+
<span aria-hidden className="pointer-events-none absolute inset-x-2 -top-px h-0.5 rounded-full bg-accent" />
|
|
117
|
+
)}
|
|
118
|
+
{movable && showGap(movableIndex + 1) && i === columns.length - 1 && (
|
|
119
|
+
<span aria-hidden className="pointer-events-none absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
|
|
120
|
+
)}
|
|
121
|
+
{movable ? (
|
|
122
|
+
<Tooltip content={mac ? 'Drag to reorder, or press ⌥↑ / ⌥↓' : 'Drag to reorder, or press Alt+↑ / Alt+↓'} position="left">
|
|
123
|
+
<span
|
|
124
|
+
aria-hidden
|
|
125
|
+
draggable
|
|
126
|
+
onDragStart={e => {
|
|
127
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
128
|
+
// Firefox starts a drag only when data is set.
|
|
129
|
+
e.dataTransfer.setData('text/plain', col.key)
|
|
130
|
+
const row = e.currentTarget.closest('[data-column-row]')
|
|
131
|
+
if (row instanceof HTMLElement) e.dataTransfer.setDragImage(row, 12, row.offsetHeight / 2)
|
|
132
|
+
setDragKey(col.key)
|
|
133
|
+
}}
|
|
134
|
+
onDragEnd={endDrag}
|
|
135
|
+
className="flex shrink-0 cursor-grab items-center text-theme-text-tertiary opacity-60 group-hover:opacity-100 active:cursor-grabbing"
|
|
136
|
+
>
|
|
137
|
+
<GripVertical className="w-3.5 h-3.5" />
|
|
138
|
+
</span>
|
|
139
|
+
</Tooltip>
|
|
140
|
+
) : (
|
|
141
|
+
<span aria-hidden className="w-3.5 shrink-0" />
|
|
142
|
+
)}
|
|
143
|
+
<label className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
|
|
144
|
+
<input
|
|
145
|
+
type="checkbox"
|
|
146
|
+
checked={visibleColumns.has(col.key)}
|
|
147
|
+
onChange={() => onToggle(col.key)}
|
|
148
|
+
disabled={col.key === 'name'}
|
|
149
|
+
aria-describedby={movable ? hintId : undefined}
|
|
150
|
+
className="rounded border-theme-border"
|
|
151
|
+
/>
|
|
152
|
+
<span className={clsx(
|
|
153
|
+
'text-sm truncate',
|
|
154
|
+
col.key === 'name' ? 'text-theme-text-tertiary' : 'text-theme-text-primary'
|
|
155
|
+
)} title={col.tooltip || col.label}>
|
|
156
|
+
{col.label}
|
|
157
|
+
</span>
|
|
158
|
+
</label>
|
|
159
|
+
{isCustom && (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => onRemoveCustom(col.key)}
|
|
163
|
+
className="shrink-0 p-0.5 text-theme-text-tertiary hover:text-red-500 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
|
164
|
+
title="Remove column"
|
|
165
|
+
>
|
|
166
|
+
<X className="w-3.5 h-3.5" />
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
})}
|
|
172
|
+
</>
|
|
173
|
+
)
|
|
174
|
+
}
|