@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,8 +1,19 @@
1
1
  // @vitest-environment jsdom
2
- import { act } from 'react'
2
+ import { act, type ComponentProps, type ReactNode } from 'react'
3
3
  import { createRoot, type Root } from 'react-dom/client'
4
- import { afterEach, beforeEach, describe, expect, it } from 'vitest'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
5
  import { LogCore } from './LogCore'
6
+ import { copyText } from '../../utils/clipboard'
7
+
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
+ } }))
6
17
 
7
18
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
19
 
@@ -17,10 +28,11 @@ beforeEach(() => {
17
28
  afterEach(async () => {
18
29
  await act(async () => root.unmount())
19
30
  element.remove()
31
+ vi.unstubAllGlobals()
20
32
  })
21
33
 
22
34
  const noop = () => {}
23
- async function render(props: { forceDark?: boolean; defaultDark?: boolean }) {
35
+ async function render(props: Partial<ComponentProps<typeof LogCore>>) {
24
36
  await act(async () =>
25
37
  root.render(
26
38
  <LogCore
@@ -75,3 +87,194 @@ describe('LogCore theme', () => {
75
87
  expect(toggle()).toBeNull()
76
88
  })
77
89
  })
90
+
91
+ it('dims unavailable streaming, explains why, suppresses its hint and keyboard action', async () => {
92
+ vi.useFakeTimers()
93
+ const start = vi.fn()
94
+ await render({ onStartStream: start, sourceUnavailable: true })
95
+ const stream = [...element.querySelectorAll('button')].find((b) => b.textContent === 'Stream')!
96
+ expect(stream.disabled).toBe(true)
97
+ expect(stream.classList.contains('disabled:opacity-50')).toBe(true)
98
+ expect([...element.querySelectorAll('kbd')].some((k) => k.textContent === 'S')).toBe(false)
99
+ act(() => { stream.parentElement!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); vi.advanceTimersByTime(500) })
100
+ expect(document.body.textContent).toContain('Available once an instance is running')
101
+ act(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 's' })))
102
+ expect(start).not.toHaveBeenCalled()
103
+ vi.useRealTimers()
104
+ })
105
+ it('keeps a non-CNPG caller streamable without Pods and wraps its utilities together', async () => {
106
+ const start = vi.fn()
107
+ await render({ onStartStream: start, onClear: noop, toolbarExtra: <span>Deployment nginx sources</span>, entries: [{ id: 1, content: '{"msg":"nginx started","level":"info"}', timestamp: '', container: 'nginx', pod: 'nginx-1', isJson: true, isLogfmt: false, level: 'info', levelSource: 'structured' }] })
108
+ const stream = [...element.querySelectorAll('button')].find((b) => b.textContent === 'Stream')!
109
+ expect(stream.disabled).toBe(false)
110
+ expect([...element.querySelectorAll('kbd')].some((k) => k.textContent === 'S')).toBe(true)
111
+ act(() => stream.click())
112
+ expect(start).toHaveBeenCalledOnce()
113
+ const utilities = element.querySelector('[aria-label="Log display and utilities"]')!
114
+ expect(utilities.classList.contains('flex-nowrap')).toBe(true)
115
+ expect(utilities.classList.contains('shrink-0')).toBe(true)
116
+ for (const icon of ['lucide-trash-2', 'lucide-download', 'lucide-search', 'lucide-clock', 'lucide-braces']) expect(utilities.querySelector(`.${icon}`)).not.toBeNull()
117
+ })
118
+
119
+ it('still stops an existing stream when its source becomes unavailable', async () => {
120
+ const stop = vi.fn()
121
+ await render({ onStartStream: noop, onStopStream: stop, isStreaming: true, sourceUnavailable: true })
122
+ const button = [...element.querySelectorAll('button')].find((b) => b.textContent === 'Stop')!
123
+ expect(button.disabled).toBe(false)
124
+ act(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 's' })))
125
+ expect(stop).toHaveBeenCalledOnce()
126
+ })
127
+
128
+ it.each([true, false])('wraps non-CNPG plain logs at words with ANSI enabled=%s', async (ansi) => {
129
+ localStorage.setItem('radar-logs-ansi', String(ansi))
130
+ await render({ entries: [{ id: 1, timestamp: '', content: 'nginx serving requests', container: 'nginx', isJson: false, isLogfmt: false, level: 'info', levelSource: 'keyword' }] })
131
+ const line = [...element.querySelectorAll('span')].find((el) => el.textContent === 'nginx serving requests' && el.classList.contains('whitespace-pre-wrap'))!
132
+ expect(line.classList.contains('[overflow-wrap:anywhere]')).toBe(true)
133
+ expect(line.classList.contains('break-all')).toBe(false)
134
+ })
135
+ it('keeps normal word wrapping when searching a structured log', async () => {
136
+ await render({ entries: [{ id: 1, timestamp: '', content: '{"msg":"nginx serving requests"}', container: 'nginx', isJson: true, isLogfmt: false, level: 'info', levelSource: 'structured' }] })
137
+ await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true })))
138
+ const input = element.querySelector<HTMLInputElement>('input[placeholder="Search logs..."]')!
139
+ await act(async () => {
140
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'nginx')
141
+ input.dispatchEvent(new Event('input', { bubbles: true }))
142
+ })
143
+ const highlight = element.querySelector('mark')!
144
+ expect(highlight).not.toBeNull()
145
+ expect(highlight.parentElement!.classList.contains('[overflow-wrap:anywhere]')).toBe(true)
146
+ expect(highlight.parentElement!.classList.contains('break-all')).toBe(false)
147
+ })
148
+
149
+ it('disables empty Export and Clear while preserving Refresh and enables them after logs load', async () => {
150
+ await render({ onClear: noop })
151
+ const exportButton = () => element.querySelector<HTMLButtonElement>('[aria-label="Export logs"]')!
152
+ const clear = () => element.querySelector<HTMLButtonElement>('[aria-label="Clear logs"]')!
153
+ expect(exportButton().disabled).toBe(true)
154
+ expect(clear().disabled).toBe(true)
155
+ expect(element.querySelector<HTMLButtonElement>('[aria-label="Refresh logs"]')?.disabled).toBe(false)
156
+ await render({ onClear: noop, entries: [{ id: 1, container: 'postgres', level: 'info', levelSource: 'keyword', isJson: false, isLogfmt: false, content: 'hello', timestamp: '2026-10-01T00:00:00Z' }] })
157
+ expect(exportButton().disabled).toBe(false)
158
+ expect(clear().disabled).toBe(false)
159
+ })
160
+
161
+ it('keeps Export and Clear usable when a Pod filter hides a nonempty buffer', async () => {
162
+ await render({ entries: [], allEntries: [{ id: 1, container: 'postgres', level: 'info', levelSource: 'keyword', isJson: false, isLogfmt: false, content: 'hello', timestamp: '' }], onClear: noop })
163
+ expect(element.querySelector<HTMLButtonElement>('[aria-label="Export logs"]')!.disabled).toBe(false)
164
+ expect(element.querySelector<HTMLButtonElement>('[aria-label="Clear logs"]')!.disabled).toBe(false)
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
+ })