@skyhook-io/k8s-ui 1.17.0 → 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 (250) hide show
  1. package/README.md +41 -0
  2. package/package.json +6 -6
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +7 -3
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +142 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -81
  67. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  68. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  69. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +91 -2
  70. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +76 -76
  71. package/src/components/gitops/tree/layout.test.ts +111 -0
  72. package/src/components/gitops/tree/layout.ts +128 -0
  73. package/src/components/gitops/tree/tree-helpers.test.ts +23 -0
  74. package/src/components/gitops/tree/tree-helpers.ts +12 -2
  75. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  76. package/src/components/issues/IssuesView.tsx +44 -6
  77. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  78. package/src/components/issues/ResourceIssuesSection.tsx +8 -2
  79. package/src/components/issues/diagnostic.ts +2 -0
  80. package/src/components/issues/index.ts +3 -1
  81. package/src/components/issues/issue-timing.ts +12 -1
  82. package/src/components/issues/issues.test.ts +75 -1
  83. package/src/components/issues/severity.ts +30 -2
  84. package/src/components/issues/types.ts +55 -4
  85. package/src/components/logs/LogCore.theme.test.tsx +206 -3
  86. package/src/components/logs/LogCore.tsx +180 -53
  87. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  88. package/src/components/logs/LogsViewer.test.tsx +369 -0
  89. package/src/components/logs/LogsViewer.tsx +91 -13
  90. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  91. package/src/components/logs/StructuredLogLine.tsx +23 -31
  92. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  93. package/src/components/logs/WorkloadLogsViewer.tsx +46 -11
  94. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  95. package/src/components/logs/useLogBuffer.test.ts +8 -0
  96. package/src/components/logs/useLogBuffer.ts +48 -18
  97. package/src/components/logs/useLogSearch.ts +3 -1
  98. package/src/components/logs/useLogStream.ts +17 -1
  99. package/src/components/problems/index.ts +1 -0
  100. package/src/components/problems/problems.test.tsx +63 -0
  101. package/src/components/problems/problems.tsx +165 -0
  102. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  103. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  104. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  105. package/src/components/resources/ColumnPickerList.tsx +174 -0
  106. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  107. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  108. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  109. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  110. package/src/components/resources/ResourcesView.kind-view.test.tsx +296 -0
  111. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  112. package/src/components/resources/ResourcesView.tsx +392 -181
  113. package/src/components/resources/column-order.test.ts +104 -0
  114. package/src/components/resources/column-order.ts +64 -0
  115. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  116. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  117. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  118. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  119. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  120. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  121. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  122. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  123. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  124. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  126. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  127. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  128. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  129. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  130. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  131. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  132. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  133. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  134. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  135. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  136. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  137. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  138. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  139. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  140. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  141. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  142. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  143. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  144. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  145. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  146. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  147. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  148. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  149. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  150. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  151. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  152. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  153. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  154. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  155. package/src/components/resources/renderers/PodRenderer.tsx +81 -40
  156. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  157. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  158. package/src/components/resources/resource-utils-capi.ts +1 -1
  159. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  160. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  161. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  162. package/src/components/resources/resource-utils-contour.ts +16 -0
  163. package/src/components/resources/resource-utils-istio.ts +12 -8
  164. package/src/components/resources/resource-utils-knative.ts +18 -0
  165. package/src/components/resources/resource-utils.ts +96 -7
  166. package/src/components/resources/rules-column-width.test.ts +1 -1
  167. package/src/components/resources/summarize-scheduler-message.test.ts +31 -0
  168. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  169. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  170. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  171. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  172. package/src/components/shared/DetailShell.test.tsx +80 -0
  173. package/src/components/shared/DetailShell.tsx +85 -10
  174. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  175. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  176. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  177. package/src/components/shared/EditableYamlView.tsx +1 -1
  178. package/src/components/shared/ResourceActionsBar.tsx +14 -7
  179. package/src/components/shared/ResourceRendererDispatch.tsx +15 -10
  180. package/src/components/shared/index.ts +2 -0
  181. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  182. package/src/components/timeline/DiffViewer.tsx +113 -4
  183. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  184. package/src/components/timeline/shared.test.tsx +48 -0
  185. package/src/components/timeline/shared.tsx +14 -19
  186. package/src/components/timeline/timeline-filters.ts +1 -0
  187. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  188. package/src/components/topology/K8sResourceNode.tsx +44 -2
  189. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  190. package/src/components/topology/TopologyGraph.tsx +3 -71
  191. package/src/components/topology/TopologySearch.tsx +1 -0
  192. package/src/components/topology/internet-groups.test.ts +35 -0
  193. package/src/components/topology/internet-groups.ts +55 -0
  194. package/src/components/topology/layout.ts +1 -1
  195. package/src/components/ui/Badge.tsx +1 -0
  196. package/src/components/ui/FoldSection.tsx +68 -0
  197. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  198. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  199. package/src/components/ui/RefLink.tsx +20 -0
  200. package/src/components/ui/RowActionMenu.tsx +8 -3
  201. package/src/components/ui/Toast.tsx +15 -4
  202. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  203. package/src/components/ui/drawer-components.test.tsx +110 -1
  204. package/src/components/ui/drawer-components.tsx +62 -9
  205. package/src/components/ui/index.ts +4 -2
  206. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  207. package/src/components/ui/monacoRuntime.ts +14 -19
  208. package/src/components/ui/status-tone.test.ts +10 -1
  209. package/src/components/ui/status-tone.tsx +29 -0
  210. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  211. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  212. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  213. package/src/components/workload/WorkloadView.tsx +193 -71
  214. package/src/index.ts +14 -1
  215. package/src/monaco-deep.d.ts +19 -12
  216. package/src/theme/components.css +13 -0
  217. package/src/types/capacity.ts +11 -2
  218. package/src/types/core.ts +53 -2
  219. package/src/types/gitops-flux-status.test.ts +32 -1
  220. package/src/types/gitops-insights.ts +20 -2
  221. package/src/types/gitops-tree.ts +12 -0
  222. package/src/types/gitops.ts +26 -6
  223. package/src/utils/api-resources.ts +7 -0
  224. package/src/utils/application-history.ts +1 -0
  225. package/src/utils/cnpg-backup.test.ts +26 -0
  226. package/src/utils/cnpg-backup.ts +70 -0
  227. package/src/utils/format.test.ts +9 -0
  228. package/src/utils/format.ts +4 -0
  229. package/src/utils/gitops-owner.ts +3 -2
  230. package/src/utils/grant.test.ts +19 -0
  231. package/src/utils/grant.ts +35 -0
  232. package/src/utils/index.ts +4 -0
  233. package/src/utils/k8s-errors.ts +7 -7
  234. package/src/utils/log-format.test.ts +41 -0
  235. package/src/utils/log-format.ts +71 -15
  236. package/src/utils/log-level.ts +12 -8
  237. package/src/utils/navigation.test.ts +57 -0
  238. package/src/utils/navigation.ts +86 -8
  239. package/src/utils/object-reference-navigation.test.ts +43 -0
  240. package/src/utils/resource-hierarchy.test.ts +37 -0
  241. package/src/utils/resource-hierarchy.ts +15 -7
  242. package/src/utils/resource-icons.ts +2 -0
  243. package/src/utils/skeleton-yaml.test.ts +21 -0
  244. package/src/utils/skeleton-yaml.ts +16 -0
  245. package/src/utils/url-path.test.ts +35 -0
  246. package/src/utils/url-path.ts +21 -0
  247. package/src/utils/validators.test.ts +4 -13
  248. package/src/utils/validators.ts +5 -19
  249. package/src/utils/view-transition.test.ts +35 -1
  250. package/src/utils/view-transition.ts +16 -1
@@ -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
@@ -58,7 +64,8 @@ interface LogCoreProps {
58
64
  onClear?: () => void
59
65
  toolbarExtra?: ToolbarExtraRenderer
60
66
  showPodName?: boolean
61
- emptyMessage?: string
67
+ emptyMessage?: ReactNode
68
+ sourceUnavailable?: boolean
62
69
  emptyCommand?: string | null
63
70
  errorMessage?: string | null
64
71
  /**
@@ -135,6 +142,12 @@ const TIP_DELAY = 150
135
142
 
136
143
  export function LogCore({
137
144
  entries,
145
+ isFollowing: controlledFollowing,
146
+ onFollowingChange,
147
+ pendingLineCount = 0,
148
+ retainedLineCount,
149
+ missingLineCount = 0,
150
+ getRetainedEntries,
138
151
  allEntries,
139
152
  isLoading,
140
153
  isStreaming,
@@ -146,13 +159,40 @@ export function LogCore({
146
159
  toolbarExtra,
147
160
  showPodName = false,
148
161
  emptyMessage = 'No logs available',
162
+ sourceUnavailable = false,
149
163
  emptyCommand,
150
164
  errorMessage,
151
165
  forceDark,
152
166
  defaultDark = true,
153
167
  }: LogCoreProps) {
154
168
  const virtuosoRef = useRef<VirtuosoHandle>(null)
155
- 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])
156
196
  const themeLocked = typeof forceDark === 'boolean'
157
197
  // Seed isDark: forceDark prop wins; else localStorage['radar-logs-dark'];
158
198
  // else defaultDark (the host app's theme, dark if not given). See
@@ -282,7 +322,7 @@ export function LogCore({
282
322
  (e: LogEntry) => association.headIdById.get(e.id) ?? e.id,
283
323
  [association],
284
324
  )
285
- const search = useLogSearch(levelFilteredEntries, virtuosoRef, recordIdOf)
325
+ const search = useLogSearch(levelFilteredEntries, virtuosoRef, recordIdOf, pauseFollowing)
286
326
 
287
327
  const displayEntries = search.isFiltering ? search.filteredEntries : levelFilteredEntries
288
328
  const hiddenCount = levelFilteredEntries.length - displayEntries.length
@@ -327,7 +367,14 @@ export function LogCore({
327
367
  // express on screen: everything else (timestamps, ANSI, level, search) has a
328
368
  // toolbar control, and the popover's preview shows where those land.
329
369
  const bufferEntries = allEntries ?? entries
330
- 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
331
378
  const exportOptions = useMemo(
332
379
  () => ({ format: exportFormat, showTimestamps, showPodName }),
333
380
  [exportFormat, showTimestamps, showPodName],
@@ -339,7 +386,7 @@ export function LogCore({
339
386
  [downloadMenu.shouldRender, exportEntries, exportOptions],
340
387
  )
341
388
  // Offering a scope that resolves to the same lines is a choice about nothing.
342
- const scopeIsMeaningful = displayEntries.length !== bufferEntries.length
389
+ const scopeIsMeaningful = displayEntries.length !== bufferLineCount
343
390
 
344
391
  // Scope describes the filters in play at the moment of asking, not a lasting
345
392
  // intent, so it starts over at the narrower option every time rather than
@@ -358,15 +405,16 @@ export function LogCore({
358
405
  const count = exportEntries.length
359
406
  const failed = () => showApiError('Failed to copy logs', 'The browser blocked clipboard access.')
360
407
  copyText(serializeLogEntries(exportEntries, exportOptions).content).then(
361
- 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(),
362
409
  failed,
363
410
  )
364
- }, [closeExportMenu, exportEntries, exportOptions])
411
+ }, [closeExportMenu, exportEntries, exportOptions, exportHasGap])
365
412
 
366
413
  const handleExportDownload = useCallback(() => {
367
414
  closeExportMenu()
368
415
  onDownload(serializeLogEntries(exportEntries, exportOptions))
369
- }, [closeExportMenu, onDownload, exportEntries, exportOptions])
416
+ if (exportHasGap) showApiSuccess('Some logs were unavailable', gapNotice)
417
+ }, [closeExportMenu, onDownload, exportEntries, exportOptions, exportHasGap])
370
418
 
371
419
  // Same close-on-outside-click for the timestamp format menu.
372
420
  const tsMenuRef = useRef<HTMLDivElement>(null)
@@ -422,7 +470,7 @@ export function LogCore({
422
470
  search.open()
423
471
  return
424
472
  }
425
- if (e.key === 's' && !e.ctrlKey && !e.metaKey && !e.altKey && onStartStream) {
473
+ if (e.key === 's' && !e.ctrlKey && !e.metaKey && !e.altKey && onStartStream && (isStreaming || !sourceUnavailable)) {
426
474
  const target = e.target as HTMLElement | null
427
475
  if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) return
428
476
  e.preventDefault()
@@ -432,16 +480,11 @@ export function LogCore({
432
480
  }
433
481
  window.addEventListener('keydown', handleKeyDown)
434
482
  return () => window.removeEventListener('keydown', handleKeyDown)
435
- }, [search.open, onStartStream, onStopStream, isStreaming])
436
-
437
- const handleFollowOutput = useCallback((isAtBottom: boolean) => {
438
- if (isAtBottom) return 'smooth' as const
439
- return false as const
440
- }, [])
483
+ }, [search.open, onStartStream, onStopStream, isStreaming, sourceUnavailable])
441
484
 
442
485
  const handleAtBottomStateChange = useCallback((bottom: boolean) => {
443
- setAtBottom(bottom)
444
- }, [])
486
+ if (bottom && resumeAtBottom.current && pendingLineCount === 0) setFollowing(true)
487
+ }, [setFollowing, pendingLineCount])
445
488
 
446
489
  const toggleWrap = useCallback(() => {
447
490
  setWordWrap(prev => {
@@ -530,13 +573,54 @@ export function LogCore({
530
573
  return groupContinuations(displayEntries, association.headIdById)
531
574
  }, [displayEntries, collapseStacks, highlightQuery, association])
532
575
 
533
- const scrollToBottom = useCallback(() => {
534
- virtuosoRef.current?.scrollToIndex({
535
- index: groupedEntries.length - 1,
536
- align: 'end',
537
- 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()
538
592
  })
539
- }, [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
+ )
540
624
 
541
625
  const toolbarExtraNode = typeof toolbarExtra === 'function'
542
626
  ? toolbarExtra({ isDark, palette })
@@ -568,15 +652,16 @@ export function LogCore({
568
652
  style={{ colorScheme: isDark ? 'dark' : 'light', fontFamily: "'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace" }}
569
653
  >
570
654
  {/* Toolbar */}
571
- <div className={`flex items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBg}`}>
655
+ <div className={`flex flex-wrap items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBg}`}>
572
656
  {toolbarExtraNode}
573
657
 
574
658
  {/* Stream / Stop toggle — only shown when streaming is supported */}
575
659
  {onStartStream && (
576
- <Tooltip content={isStreaming ? 'Stop streaming' : 'Start streaming'} delay={TIP_DELAY} position="bottom">
660
+ <Tooltip content={isStreaming ? 'Stop streaming' : sourceUnavailable ? 'Available once an instance is running' : 'Start streaming'} delay={TIP_DELAY} position="bottom">
577
661
  <button
578
662
  onClick={isStreaming ? onStopStream : onStartStream}
579
- className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors ${
663
+ disabled={sourceUnavailable && !isStreaming}
664
+ className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
580
665
  isStreaming
581
666
  ? 'bg-green-600 text-white hover:bg-green-700'
582
667
  : `${palette.elevatedBg} ${palette.textSecondary} ${palette.hoverBg}`
@@ -592,6 +677,7 @@ export function LogCore({
592
677
  <Tooltip content="Refresh logs" delay={TIP_DELAY} position="bottom">
593
678
  <button
594
679
  onClick={onRefresh}
680
+ aria-label="Refresh logs"
595
681
  disabled={isLoading || isStreaming}
596
682
  className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded ${palette.elevatedBg} ${palette.textSecondary} ${palette.hoverBg} disabled:opacity-50 disabled:cursor-not-allowed`}
597
683
  >
@@ -608,7 +694,7 @@ export function LogCore({
608
694
  <Tooltip key={opt.level} content={`${active ? 'Hide' : 'Show'} ${opt.noun}`} delay={TIP_DELAY} position="bottom">
609
695
  <button
610
696
  onClick={() => toggleLevel(opt.level)}
611
- className={`px-1.5 py-0.5 text-[10px] font-medium rounded border transition-colors ${
697
+ className={`px-1.5 py-0.5 text-[10px] font-medium rounded border whitespace-nowrap transition-colors ${
612
698
  active
613
699
  ? getLevelActiveColor(opt.level, palette)
614
700
  : levelChipInactive
@@ -621,7 +707,7 @@ export function LogCore({
621
707
  })}
622
708
  </div>
623
709
 
624
- <div className="flex-1" />
710
+ <div role="group" aria-label="Log display and utilities" className="ml-auto flex shrink-0 flex-nowrap items-center gap-2">
625
711
 
626
712
  {/* Structured-log display mode: icon cycles compact→expanded→raw, chevron picks explicitly. */}
627
713
  {hasStructuredEntries && (
@@ -801,14 +887,15 @@ export function LogCore({
801
887
 
802
888
  {/* Export */}
803
889
  <div className="relative flex items-center" ref={downloadMenuRef}>
804
- <Tooltip content="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>
805
891
  <button
806
892
  ref={exportTriggerRef}
893
+ disabled={bufferLineCount === 0}
807
894
  onClick={toggleExportMenu}
808
895
  aria-label="Export logs"
809
896
  aria-haspopup="dialog"
810
897
  aria-expanded={showDownloadMenu}
811
- className={iconBtnInactive}
898
+ className={`${iconBtnInactive} disabled:opacity-50 disabled:cursor-not-allowed`}
812
899
  >
813
900
  <Download className="w-4 h-4" />
814
901
  </button>
@@ -831,16 +918,17 @@ export function LogCore({
831
918
  Filtered {displayEntries.length.toLocaleString()}
832
919
  </ExportChoice>
833
920
  <ExportChoice active={exportScope === 'all'} onClick={() => setExportScope('all')} palette={palette}>
834
- All {bufferEntries.length.toLocaleString()}
921
+ All {bufferLineCount.toLocaleString()}
835
922
  </ExportChoice>
836
923
  </div>
837
924
  ) : (
838
925
  <span className={`text-[11px] ${palette.textSecondary}`}>
839
- {bufferEntries.length.toLocaleString()} line{bufferEntries.length === 1 ? '' : 's'}
926
+ {bufferLineCount.toLocaleString()} line{bufferLineCount === 1 ? '' : 's'}
840
927
  </span>
841
928
  )}
842
929
  </div>
843
930
 
931
+ {exportHasGap && <p className={`text-xs ${palette.textSecondary}`}>{gapNotice}</p>}
844
932
  <div className="flex items-center gap-3">
845
933
  <span id="log-export-format-label" className={exportLabelCls}>Format</span>
846
934
  <div role="radiogroup" aria-labelledby="log-export-format-label" className="flex items-center gap-1">
@@ -884,15 +972,18 @@ export function LogCore({
884
972
 
885
973
  {/* Clear */}
886
974
  {onClear && (
887
- <Tooltip content="Clear logs" delay={TIP_DELAY} position="bottom">
975
+ <Tooltip content={bufferLineCount === 0 ? 'No logs loaded' : 'Clear logs'} delay={TIP_DELAY} position="bottom">
888
976
  <button
889
977
  onClick={onClear}
890
- className={iconBtnInactive}
978
+ aria-label="Clear logs"
979
+ disabled={bufferLineCount === 0}
980
+ className={`${iconBtnInactive} disabled:opacity-50 disabled:cursor-not-allowed`}
891
981
  >
892
982
  <Trash2 className="w-4 h-4" />
893
983
  </button>
894
984
  </Tooltip>
895
985
  )}
986
+ </div>
896
987
  </div>
897
988
 
898
989
  {/* Search bar */}
@@ -1005,9 +1096,10 @@ export function LogCore({
1005
1096
  <span>{errorMessage}</span>
1006
1097
  </div>
1007
1098
  ) : groupedEntries.length === 0 ? (
1008
- <div className={`${EMPTY_STATE_CLASS} ${palette.textTertiary}`}>
1099
+ <div className={`${EMPTY_STATE_CLASS} relative ${palette.textTertiary}`}>
1009
1100
  <Terminal className="w-8 h-8" />
1010
- <span>{entries.length > 0 ? `Filters hide all ${entries.length.toLocaleString()} loaded lines` : emptyMessage}</span>
1101
+ <div>{entries.length > 0 ? `Filters hide all ${entries.length.toLocaleString()} loaded lines` : emptyMessage}</div>
1102
+ {returnToLiveButton}
1011
1103
  {entries.length === 0 && emptyCommand && (
1012
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">
1013
1105
  <code className="truncate">{emptyCommand}</code>
@@ -1016,12 +1108,54 @@ export function LogCore({
1016
1108
  )}
1017
1109
  </div>
1018
1110
  ) : (
1019
- <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
+ >
1020
1153
  <Virtuoso
1021
1154
  ref={virtuosoRef}
1022
1155
  data={groupedEntries}
1023
- followOutput={handleFollowOutput}
1024
- initialTopMostItemIndex={groupedEntries.length - 1}
1156
+ scrollerRef={handleScrollerRef}
1157
+ computeItemKey={(_index, group) => group.head.id}
1158
+ totalListHeightChanged={schedulePinToBottom}
1025
1159
  atBottomStateChange={handleAtBottomStateChange}
1026
1160
  atBottomThreshold={50}
1027
1161
  increaseViewportBy={200}
@@ -1049,20 +1183,13 @@ export function LogCore({
1049
1183
  className="h-full font-mono text-xs"
1050
1184
  />
1051
1185
  {/* Scroll-to-bottom button */}
1052
- {!atBottom && (
1053
- <button
1054
- onClick={scrollToBottom}
1055
- className="absolute bottom-4 right-14 px-3 py-1.5 btn-brand text-xs rounded-full shadow-lg z-10"
1056
- >
1057
- Scroll to bottom
1058
- </button>
1059
- )}
1186
+ {returnToLiveButton}
1060
1187
  </div>
1061
1188
  )}
1062
1189
 
1063
1190
  {/* Keyboard shortcut hints */}
1064
1191
  <div className={`flex items-center gap-4 px-3 py-1 border-t ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
1065
- {onStartStream && <Shortcut keys="S" label={isStreaming ? 'Stop stream' : 'Stream'} palette={palette} />}
1192
+ {onStartStream && (!sourceUnavailable || isStreaming) && <Shortcut keys="S" label={isStreaming ? 'Stop stream' : 'Stream'} palette={palette} />}
1066
1193
  <Shortcut keys="Ctrl+F" label="Search" palette={palette} />
1067
1194
  {search.mode !== 'hide' && (
1068
1195
  <>
@@ -1189,7 +1316,7 @@ function LogLine({
1189
1316
  const highlighted = highlightSearchMatches(plain, searchQuery, searchIsRegex, searchIsCaseSensitive)
1190
1317
  contentElement = (
1191
1318
  <span
1192
- className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${levelColor}`}
1319
+ className={`${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'} ${levelColor}`}
1193
1320
  dangerouslySetInnerHTML={{ __html: highlighted }}
1194
1321
  />
1195
1322
  )
@@ -1209,13 +1336,13 @@ function LogLine({
1209
1336
  const html = ansiToHtml(entry.content)
1210
1337
  contentElement = (
1211
1338
  <span
1212
- className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${levelColor}`}
1339
+ className={`${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'} ${levelColor}`}
1213
1340
  dangerouslySetInnerHTML={{ __html: html }}
1214
1341
  />
1215
1342
  )
1216
1343
  } else {
1217
1344
  contentElement = (
1218
- <span className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${levelColor}`}>
1345
+ <span className={`${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'} ${levelColor}`}>
1219
1346
  {stripAnsi(entry.content)}
1220
1347
  </span>
1221
1348
  )
@@ -1247,7 +1374,7 @@ function LogLine({
1247
1374
  <span className="flex-1 min-w-0">{contentElement}</span>
1248
1375
  <button
1249
1376
  onClick={handleCopy}
1250
- 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`}
1251
1378
  title="Copy line"
1252
1379
  >
1253
1380
  <Copy className="w-3 h-3" />
@@ -50,6 +50,7 @@ interface LogRangeSelectProps {
50
50
  isDark?: boolean
51
51
  /** When true, the control is greyed out and non-interactive. */
52
52
  disabled?: boolean
53
+ disabledReason?: string
53
54
  }
54
55
 
55
56
  export function LogRangeSelect({
@@ -59,10 +60,11 @@ export function LogRangeSelect({
59
60
  tooltip = 'How many logs to load — by line count or time range',
60
61
  isDark = true,
61
62
  disabled = false,
63
+ disabledReason = 'Stop streaming to change the log range',
62
64
  }: LogRangeSelectProps) {
63
65
  const palette = getLogPalette(isDark)
64
66
  return (
65
- <Tooltip content={disabled ? 'Stop streaming to change the log range' : tooltip} position="bottom">
67
+ <Tooltip content={disabled ? disabledReason : tooltip} position="bottom">
66
68
  <select
67
69
  value={value}
68
70
  onChange={(e) => onChange(e.target.value)}