@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.
Files changed (55) hide show
  1. package/package.json +4 -4
  2. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
  3. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
  4. package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
  5. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  6. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  7. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
  8. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
  9. package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
  10. package/src/components/gitops/tree/tree-helpers.ts +5 -1
  11. package/src/components/issues/IssuesView.tsx +4 -1
  12. package/src/components/issues/ResourceIssuesSection.tsx +5 -2
  13. package/src/components/issues/diagnostic.ts +2 -0
  14. package/src/components/issues/index.ts +1 -1
  15. package/src/components/issues/issues.test.ts +26 -0
  16. package/src/components/issues/severity.ts +9 -2
  17. package/src/components/issues/types.ts +32 -4
  18. package/src/components/logs/LogCore.theme.test.tsx +126 -1
  19. package/src/components/logs/LogCore.tsx +159 -40
  20. package/src/components/logs/LogsViewer.test.tsx +80 -2
  21. package/src/components/logs/LogsViewer.tsx +21 -4
  22. package/src/components/logs/StructuredLogLine.tsx +1 -1
  23. package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
  24. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  25. package/src/components/logs/useLogBuffer.ts +48 -18
  26. package/src/components/logs/useLogSearch.ts +3 -1
  27. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  28. package/src/components/resources/ColumnPickerList.tsx +174 -0
  29. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
  30. package/src/components/resources/ResourcesView.tsx +93 -75
  31. package/src/components/resources/column-order.test.ts +104 -0
  32. package/src/components/resources/column-order.ts +64 -0
  33. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  34. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  35. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  36. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  37. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  38. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  39. package/src/components/resources/renderers/PodRenderer.tsx +40 -11
  40. package/src/components/resources/resource-utils.ts +15 -2
  41. package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
  42. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  43. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  44. package/src/components/shared/ResourceActionsBar.tsx +3 -3
  45. package/src/components/shared/ResourceRendererDispatch.tsx +3 -1
  46. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  47. package/src/components/timeline/DiffViewer.tsx +113 -4
  48. package/src/components/timeline/shared.test.tsx +48 -0
  49. package/src/components/timeline/shared.tsx +14 -19
  50. package/src/components/ui/Toast.tsx +15 -4
  51. package/src/components/ui/index.ts +1 -1
  52. package/src/components/workload/WorkloadView.tsx +14 -8
  53. package/src/types/core.ts +25 -1
  54. package/src/types/gitops-insights.ts +1 -0
  55. 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={fetchError || (entries.length === 0 && !emptyMessage && !streamAnswer ? streamError : null)}
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 [entries, setEntries] = useState<LogEntry[]>([])
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
- setEntries(prev => {
70
- const enriched = batch.map(enrichEntry)
71
- const combined = [...prev, ...enriched]
72
- if (combined.length > MAX_BUFFER_SIZE) {
73
- return combined.slice(combined.length - MAX_BUFFER_SIZE)
74
- }
75
- return combined
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
- pendingRef.current.push(...batch)
88
- if (rafRef.current === null) {
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
- setEntries(enriched.slice(enriched.length - MAX_BUFFER_SIZE))
114
+ setBuffer({ latest: enriched.slice(enriched.length - MAX_BUFFER_SIZE), reading: null })
104
115
  } else {
105
- setEntries(enriched)
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
- setEntries([])
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
- return { entries, append, appendBatch, set, clear }
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
+ }