@skyhook-io/k8s-ui 1.17.1 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +13 -0
  2. package/package.json +4 -4
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
  7. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  8. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  9. package/src/components/gitops/detail-helpers.test.ts +58 -13
  10. package/src/components/gitops/detail-helpers.ts +37 -6
  11. package/src/components/gitops/index.ts +1 -0
  12. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
  14. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  15. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  16. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
  17. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
  18. package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
  19. package/src/components/gitops/tree/tree-helpers.ts +5 -1
  20. package/src/components/issues/IssuesView.tsx +18 -10
  21. package/src/components/issues/ResourceIssuesSection.tsx +5 -2
  22. package/src/components/issues/diagnostic.ts +2 -0
  23. package/src/components/issues/index.ts +1 -1
  24. package/src/components/issues/issues.test.ts +26 -0
  25. package/src/components/issues/severity.ts +9 -2
  26. package/src/components/issues/types.ts +32 -4
  27. package/src/components/logs/LogCore.theme.test.tsx +126 -1
  28. package/src/components/logs/LogCore.tsx +159 -40
  29. package/src/components/logs/LogsViewer.test.tsx +183 -60
  30. package/src/components/logs/LogsViewer.tsx +49 -12
  31. package/src/components/logs/StructuredLogLine.tsx +1 -1
  32. package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
  33. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  34. package/src/components/logs/useLogBuffer.ts +48 -18
  35. package/src/components/logs/useLogSearch.ts +3 -1
  36. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  37. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  38. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  39. package/src/components/resources/ColumnPickerList.tsx +174 -0
  40. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  41. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  42. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
  43. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  44. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  45. package/src/components/resources/ResourcesView.tsx +198 -93
  46. package/src/components/resources/column-order.test.ts +104 -0
  47. package/src/components/resources/column-order.ts +64 -0
  48. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  49. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  50. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  51. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  52. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  53. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  54. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  55. package/src/components/resources/renderers/PodRenderer.tsx +72 -20
  56. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  57. package/src/components/resources/resource-utils-trivy.ts +122 -0
  58. package/src/components/resources/resource-utils.ts +15 -2
  59. package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
  60. package/src/components/shared/DetailShell.tsx +9 -7
  61. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  62. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  63. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  64. package/src/components/shared/ManagedByChip.tsx +24 -4
  65. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  66. package/src/components/shared/ResourceActionsBar.tsx +12 -6
  67. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  68. package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
  69. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  70. package/src/components/timeline/DiffViewer.tsx +113 -4
  71. package/src/components/timeline/TimelineList.tsx +5 -1
  72. package/src/components/timeline/shared.test.tsx +48 -0
  73. package/src/components/timeline/shared.tsx +14 -19
  74. package/src/components/ui/FetchResult.test.tsx +19 -2
  75. package/src/components/ui/FetchResult.tsx +13 -1
  76. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  77. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  78. package/src/components/ui/PageHeader.tsx +6 -4
  79. package/src/components/ui/Toast.tsx +48 -30
  80. package/src/components/ui/drawer-components.test.tsx +253 -0
  81. package/src/components/ui/drawer-components.tsx +315 -11
  82. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  83. package/src/components/ui/index.ts +1 -1
  84. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  85. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  86. package/src/components/workload/WorkloadView.tsx +68 -14
  87. package/src/hooks/index.ts +2 -0
  88. package/src/hooks/useMediaQuery.ts +21 -0
  89. package/src/hooks/usePhoneLayout.ts +12 -0
  90. package/src/theme/tailwind-theme.css +5 -0
  91. package/src/types/core.ts +91 -3
  92. package/src/types/gitops-flux-inventory.test.ts +24 -0
  93. package/src/types/gitops-insights.ts +1 -0
  94. package/src/types/gitops-tree.ts +2 -0
  95. package/src/types/gitops.ts +5 -5
  96. package/src/types/index.ts +3 -0
  97. package/src/types/read-coverage.ts +65 -0
  98. package/src/types/resource-coverage.ts +40 -0
  99. package/src/utils/format.test.ts +7 -0
  100. package/src/utils/format.ts +6 -1
  101. package/src/utils/gitops-owner.test.ts +42 -1
  102. package/src/utils/gitops-owner.ts +22 -0
  103. package/src/utils/index.ts +2 -0
  104. package/src/utils/read-actions.test.ts +22 -0
  105. package/src/utils/read-actions.ts +56 -0
  106. package/src/utils/resource-coverage.test.ts +121 -0
  107. package/src/utils/resource-coverage.ts +117 -0
@@ -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) or directly consumed ConfigMap data; not a causal claim. */
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 that mount/reference this ConfigMap directly ("Deployment/flagd").
139
- * Direct spec references only — runtime consumers via an intermediary
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('react-virtuoso', () => ({ Virtuoso: ({ data, itemContent }: { data: unknown[]; itemContent: (index: number, value: unknown) => ReactNode }) => <div>{data.map((value, index) => <div key={index}>{itemContent(index, value)}</div>)}</div> }))
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 [atBottom, setAtBottom] = useState(true)
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 exportEntries = exportScope === 'all' ? bufferEntries : displayEntries
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 !== bufferEntries.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.`) : failed(),
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
- }, [closeExportMenu, onDownload, exportEntries, exportOptions])
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
- setAtBottom(bottom)
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 scrollToBottom = useCallback(() => {
536
- virtuosoRef.current?.scrollToIndex({
537
- index: groupedEntries.length - 1,
538
- align: 'end',
539
- behavior: 'smooth',
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
- }, [groupedEntries.length])
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={bufferEntries.length === 0 ? 'No logs loaded' : 'Export logs'} delay={TIP_DELAY} position="bottom" disabled={showDownloadMenu} preserveWrapperWhenDisabled>
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={bufferEntries.length === 0}
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 {bufferEntries.length.toLocaleString()}
921
+ All {bufferLineCount.toLocaleString()}
840
922
  </ExportChoice>
841
923
  </div>
842
924
  ) : (
843
925
  <span className={`text-[11px] ${palette.textSecondary}`}>
844
- {bufferEntries.length.toLocaleString()} line{bufferEntries.length === 1 ? '' : 's'}
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={bufferEntries.length === 0 ? 'No logs loaded' : 'Clear logs'} delay={TIP_DELAY} position="bottom">
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={bufferEntries.length === 0}
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 className="flex-1 relative">
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
- followOutput={handleFollowOutput}
1032
- initialTopMostItemIndex={groupedEntries.length - 1}
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
- {!atBottom && (
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" />