@skyhook-io/k8s-ui 1.17.0 → 1.17.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 (227) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  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 +6 -2
  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 +115 -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 +24 -71
  67. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  68. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
  69. package/src/components/gitops/tree/layout.test.ts +111 -0
  70. package/src/components/gitops/tree/layout.ts +128 -0
  71. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  72. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  73. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  74. package/src/components/issues/IssuesView.tsx +40 -5
  75. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  76. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  77. package/src/components/issues/index.ts +3 -1
  78. package/src/components/issues/issue-timing.ts +12 -1
  79. package/src/components/issues/issues.test.ts +49 -1
  80. package/src/components/issues/severity.ts +21 -0
  81. package/src/components/issues/types.ts +23 -0
  82. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  83. package/src/components/logs/LogCore.tsx +25 -17
  84. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  85. package/src/components/logs/LogsViewer.test.tsx +291 -0
  86. package/src/components/logs/LogsViewer.tsx +72 -11
  87. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  88. package/src/components/logs/StructuredLogLine.tsx +22 -30
  89. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  90. package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
  91. package/src/components/logs/useLogBuffer.test.ts +8 -0
  92. package/src/components/logs/useLogStream.ts +17 -1
  93. package/src/components/problems/index.ts +1 -0
  94. package/src/components/problems/problems.test.tsx +63 -0
  95. package/src/components/problems/problems.tsx +165 -0
  96. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  97. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  98. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  99. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  100. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  101. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  102. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  103. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  104. package/src/components/resources/ResourcesView.tsx +302 -109
  105. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  106. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  107. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  108. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  109. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  110. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  111. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  112. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  113. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  114. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  115. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  116. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  117. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  118. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  119. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  120. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  121. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  122. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  123. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  124. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  125. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  126. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  127. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  128. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  129. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  130. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  131. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  132. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  133. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  134. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  135. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  136. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  137. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  138. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  139. package/src/components/resources/renderers/PodRenderer.tsx +41 -29
  140. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  141. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  142. package/src/components/resources/resource-utils-capi.ts +1 -1
  143. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  144. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  145. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  146. package/src/components/resources/resource-utils-contour.ts +16 -0
  147. package/src/components/resources/resource-utils-istio.ts +12 -8
  148. package/src/components/resources/resource-utils-knative.ts +18 -0
  149. package/src/components/resources/resource-utils.ts +82 -6
  150. package/src/components/resources/rules-column-width.test.ts +1 -1
  151. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  152. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  153. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  154. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  155. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  156. package/src/components/shared/DetailShell.test.tsx +80 -0
  157. package/src/components/shared/DetailShell.tsx +85 -10
  158. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  159. package/src/components/shared/EditableYamlView.tsx +1 -1
  160. package/src/components/shared/ResourceActionsBar.tsx +11 -4
  161. package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
  162. package/src/components/shared/index.ts +2 -0
  163. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  164. package/src/components/timeline/timeline-filters.ts +1 -0
  165. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  166. package/src/components/topology/K8sResourceNode.tsx +44 -2
  167. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  168. package/src/components/topology/TopologyGraph.tsx +3 -71
  169. package/src/components/topology/TopologySearch.tsx +1 -0
  170. package/src/components/topology/internet-groups.test.ts +35 -0
  171. package/src/components/topology/internet-groups.ts +55 -0
  172. package/src/components/topology/layout.ts +1 -1
  173. package/src/components/ui/Badge.tsx +1 -0
  174. package/src/components/ui/FoldSection.tsx +68 -0
  175. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  176. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  177. package/src/components/ui/RefLink.tsx +20 -0
  178. package/src/components/ui/RowActionMenu.tsx +8 -3
  179. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  180. package/src/components/ui/drawer-components.test.tsx +110 -1
  181. package/src/components/ui/drawer-components.tsx +62 -9
  182. package/src/components/ui/index.ts +3 -1
  183. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  184. package/src/components/ui/monacoRuntime.ts +14 -19
  185. package/src/components/ui/status-tone.test.ts +10 -1
  186. package/src/components/ui/status-tone.tsx +29 -0
  187. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  188. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  189. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  190. package/src/components/workload/WorkloadView.tsx +180 -64
  191. package/src/index.ts +14 -1
  192. package/src/monaco-deep.d.ts +19 -12
  193. package/src/theme/components.css +13 -0
  194. package/src/types/capacity.ts +11 -2
  195. package/src/types/core.ts +29 -2
  196. package/src/types/gitops-flux-status.test.ts +32 -1
  197. package/src/types/gitops-insights.ts +19 -2
  198. package/src/types/gitops-tree.ts +10 -0
  199. package/src/types/gitops.ts +26 -6
  200. package/src/utils/api-resources.ts +7 -0
  201. package/src/utils/application-history.ts +1 -0
  202. package/src/utils/cnpg-backup.test.ts +26 -0
  203. package/src/utils/cnpg-backup.ts +70 -0
  204. package/src/utils/format.test.ts +9 -0
  205. package/src/utils/format.ts +4 -0
  206. package/src/utils/gitops-owner.ts +3 -2
  207. package/src/utils/grant.test.ts +19 -0
  208. package/src/utils/grant.ts +35 -0
  209. package/src/utils/index.ts +4 -0
  210. package/src/utils/k8s-errors.ts +7 -7
  211. package/src/utils/log-format.test.ts +41 -0
  212. package/src/utils/log-format.ts +71 -15
  213. package/src/utils/log-level.ts +12 -8
  214. package/src/utils/navigation.test.ts +57 -0
  215. package/src/utils/navigation.ts +86 -8
  216. package/src/utils/object-reference-navigation.test.ts +43 -0
  217. package/src/utils/resource-hierarchy.test.ts +37 -0
  218. package/src/utils/resource-hierarchy.ts +15 -7
  219. package/src/utils/resource-icons.ts +2 -0
  220. package/src/utils/skeleton-yaml.test.ts +21 -0
  221. package/src/utils/skeleton-yaml.ts +16 -0
  222. package/src/utils/url-path.test.ts +35 -0
  223. package/src/utils/url-path.ts +21 -0
  224. package/src/utils/validators.test.ts +4 -13
  225. package/src/utils/validators.ts +5 -19
  226. package/src/utils/view-transition.test.ts +35 -1
  227. package/src/utils/view-transition.ts +16 -1
@@ -136,6 +136,27 @@ export function categoryLabel(category: string): string {
136
136
  return CATEGORY_LABEL[category] ?? humanize(category);
137
137
  }
138
138
 
139
+ // Reasons filed under a category whose label names a different operation:
140
+ // CNPG archiving and an unanswered scheduled run sit under backup_failed.
141
+ const REASON_TITLE: Record<string, string> = {
142
+ CNPGWALArchivingFailing: "WAL archiving failing",
143
+ CNPGLastBackupFailed: "Latest backup failed",
144
+ CNPGScheduleDestinationMissing: "Backup schedule has no destination",
145
+ CNPGInstanceReadinessMismatch: "Instance Pods contradict Cluster readiness",
146
+ CNPGPrimaryLabelMismatch: "Primary Pod label contradicts Cluster status",
147
+ CNPGScheduledRunNoBackup: "No successful backup since a scheduled run",
148
+ };
149
+
150
+ /** The title a reason has wherever it is shown, when its category's label would name the wrong operation. */
151
+ export function issueReasonTitle(reason: string | undefined): string | undefined {
152
+ return reason ? REASON_TITLE[reason] : undefined;
153
+ }
154
+
155
+ /** An issue row's title: its category, unless the reason names it better. */
156
+ export function issueTitle(issue: { category: string; reason?: string }): string {
157
+ return issueReasonTitle(issue.reason) || categoryLabel(issue.category);
158
+ }
159
+
139
160
  export function groupLabel(group: string): string {
140
161
  return GROUP_LABEL[group] ?? humanize(group);
141
162
  }
@@ -141,6 +141,26 @@ export interface IssueRecentChange {
141
141
  consumed_by?: string[];
142
142
  }
143
143
 
144
+ /** One finalizer removal: a JSON Patch that tests the object UID and the
145
+ * finalizer at its index before removing it, previewed as dry runs. */
146
+ export interface IssueFinalizerRemoval {
147
+ finalizer: string;
148
+ /** patch_resource tool input, with dry_run=true. */
149
+ patch_resource: Record<string, unknown>;
150
+ /** The same patch as a kubectl server-side dry run. */
151
+ kubectl: string;
152
+ }
153
+
154
+ export interface IssueConditionalCleanup {
155
+ /** Lead-in the reader must confirm, e.g. "If victoria-metrics-operator was
156
+ * uninstalled on purpose (…), remove the finalizer". */
157
+ condition: string;
158
+ /** For agents: when the cleanup may be proposed. */
159
+ agent_guidance: string;
160
+ caution: string;
161
+ removals: IssueFinalizerRemoval[];
162
+ }
163
+
144
164
  /** "No tracked non-status changes in the window" — the claim is scoped by
145
165
  * window_seconds so a change just outside it can't be misread as absent. */
146
166
  export interface IssueNoRecentChanges {
@@ -188,6 +208,9 @@ export interface Issue {
188
208
  action?: string;
189
209
  remediation_kind?: string;
190
210
  remediation_target?: string;
211
+ /** A cleanup that is right only when its condition holds — never part of
212
+ * `action`. Shown collapsed, labeled with the condition. */
213
+ conditional_cleanup?: IssueConditionalCleanup;
191
214
  /** Controller-operation retry count (e.g. Argo's "(retried N times)") —
192
215
  * distinct from restart_count (pod restarts). stuck = not expected to
193
216
  * self-recover. */
@@ -1,9 +1,11 @@
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
6
 
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
+
7
9
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
10
 
9
11
  let root: Root
@@ -20,7 +22,7 @@ afterEach(async () => {
20
22
  })
21
23
 
22
24
  const noop = () => {}
23
- async function render(props: { forceDark?: boolean; defaultDark?: boolean }) {
25
+ async function render(props: Partial<ComponentProps<typeof LogCore>>) {
24
26
  await act(async () =>
25
27
  root.render(
26
28
  <LogCore
@@ -75,3 +77,79 @@ describe('LogCore theme', () => {
75
77
  expect(toggle()).toBeNull()
76
78
  })
77
79
  })
80
+
81
+ it('dims unavailable streaming, explains why, suppresses its hint and keyboard action', async () => {
82
+ vi.useFakeTimers()
83
+ const start = vi.fn()
84
+ await render({ onStartStream: start, sourceUnavailable: true })
85
+ const stream = [...element.querySelectorAll('button')].find((b) => b.textContent === 'Stream')!
86
+ expect(stream.disabled).toBe(true)
87
+ expect(stream.classList.contains('disabled:opacity-50')).toBe(true)
88
+ expect([...element.querySelectorAll('kbd')].some((k) => k.textContent === 'S')).toBe(false)
89
+ act(() => { stream.parentElement!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); vi.advanceTimersByTime(500) })
90
+ expect(document.body.textContent).toContain('Available once an instance is running')
91
+ act(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 's' })))
92
+ expect(start).not.toHaveBeenCalled()
93
+ vi.useRealTimers()
94
+ })
95
+ it('keeps a non-CNPG caller streamable without Pods and wraps its utilities together', async () => {
96
+ const start = vi.fn()
97
+ 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' }] })
98
+ const stream = [...element.querySelectorAll('button')].find((b) => b.textContent === 'Stream')!
99
+ expect(stream.disabled).toBe(false)
100
+ expect([...element.querySelectorAll('kbd')].some((k) => k.textContent === 'S')).toBe(true)
101
+ act(() => stream.click())
102
+ expect(start).toHaveBeenCalledOnce()
103
+ const utilities = element.querySelector('[aria-label="Log display and utilities"]')!
104
+ expect(utilities.classList.contains('flex-nowrap')).toBe(true)
105
+ expect(utilities.classList.contains('shrink-0')).toBe(true)
106
+ for (const icon of ['lucide-trash-2', 'lucide-download', 'lucide-search', 'lucide-clock', 'lucide-braces']) expect(utilities.querySelector(`.${icon}`)).not.toBeNull()
107
+ })
108
+
109
+ it('still stops an existing stream when its source becomes unavailable', async () => {
110
+ const stop = vi.fn()
111
+ await render({ onStartStream: noop, onStopStream: stop, isStreaming: true, sourceUnavailable: true })
112
+ const button = [...element.querySelectorAll('button')].find((b) => b.textContent === 'Stop')!
113
+ expect(button.disabled).toBe(false)
114
+ act(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 's' })))
115
+ expect(stop).toHaveBeenCalledOnce()
116
+ })
117
+
118
+ it.each([true, false])('wraps non-CNPG plain logs at words with ANSI enabled=%s', async (ansi) => {
119
+ localStorage.setItem('radar-logs-ansi', String(ansi))
120
+ await render({ entries: [{ id: 1, timestamp: '', content: 'nginx serving requests', container: 'nginx', isJson: false, isLogfmt: false, level: 'info', levelSource: 'keyword' }] })
121
+ const line = [...element.querySelectorAll('span')].find((el) => el.textContent === 'nginx serving requests' && el.classList.contains('whitespace-pre-wrap'))!
122
+ expect(line.classList.contains('[overflow-wrap:anywhere]')).toBe(true)
123
+ expect(line.classList.contains('break-all')).toBe(false)
124
+ })
125
+ it('keeps normal word wrapping when searching a structured log', async () => {
126
+ await render({ entries: [{ id: 1, timestamp: '', content: '{"msg":"nginx serving requests"}', container: 'nginx', isJson: true, isLogfmt: false, level: 'info', levelSource: 'structured' }] })
127
+ await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true })))
128
+ const input = element.querySelector<HTMLInputElement>('input[placeholder="Search logs..."]')!
129
+ await act(async () => {
130
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'nginx')
131
+ input.dispatchEvent(new Event('input', { bubbles: true }))
132
+ })
133
+ const highlight = element.querySelector('mark')!
134
+ expect(highlight).not.toBeNull()
135
+ expect(highlight.parentElement!.classList.contains('[overflow-wrap:anywhere]')).toBe(true)
136
+ expect(highlight.parentElement!.classList.contains('break-all')).toBe(false)
137
+ })
138
+
139
+ it('disables empty Export and Clear while preserving Refresh and enables them after logs load', async () => {
140
+ await render({ onClear: noop })
141
+ const exportButton = () => element.querySelector<HTMLButtonElement>('[aria-label="Export logs"]')!
142
+ const clear = () => element.querySelector<HTMLButtonElement>('[aria-label="Clear logs"]')!
143
+ expect(exportButton().disabled).toBe(true)
144
+ expect(clear().disabled).toBe(true)
145
+ expect(element.querySelector<HTMLButtonElement>('[aria-label="Refresh logs"]')?.disabled).toBe(false)
146
+ 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' }] })
147
+ expect(exportButton().disabled).toBe(false)
148
+ expect(clear().disabled).toBe(false)
149
+ })
150
+
151
+ it('keeps Export and Clear usable when a Pod filter hides a nonempty buffer', async () => {
152
+ await render({ entries: [], allEntries: [{ id: 1, container: 'postgres', level: 'info', levelSource: 'keyword', isJson: false, isLogfmt: false, content: 'hello', timestamp: '' }], onClear: noop })
153
+ expect(element.querySelector<HTMLButtonElement>('[aria-label="Export logs"]')!.disabled).toBe(false)
154
+ expect(element.querySelector<HTMLButtonElement>('[aria-label="Clear logs"]')!.disabled).toBe(false)
155
+ })
@@ -58,7 +58,8 @@ interface LogCoreProps {
58
58
  onClear?: () => void
59
59
  toolbarExtra?: ToolbarExtraRenderer
60
60
  showPodName?: boolean
61
- emptyMessage?: string
61
+ emptyMessage?: ReactNode
62
+ sourceUnavailable?: boolean
62
63
  emptyCommand?: string | null
63
64
  errorMessage?: string | null
64
65
  /**
@@ -146,6 +147,7 @@ export function LogCore({
146
147
  toolbarExtra,
147
148
  showPodName = false,
148
149
  emptyMessage = 'No logs available',
150
+ sourceUnavailable = false,
149
151
  emptyCommand,
150
152
  errorMessage,
151
153
  forceDark,
@@ -422,7 +424,7 @@ export function LogCore({
422
424
  search.open()
423
425
  return
424
426
  }
425
- if (e.key === 's' && !e.ctrlKey && !e.metaKey && !e.altKey && onStartStream) {
427
+ if (e.key === 's' && !e.ctrlKey && !e.metaKey && !e.altKey && onStartStream && (isStreaming || !sourceUnavailable)) {
426
428
  const target = e.target as HTMLElement | null
427
429
  if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) return
428
430
  e.preventDefault()
@@ -432,7 +434,7 @@ export function LogCore({
432
434
  }
433
435
  window.addEventListener('keydown', handleKeyDown)
434
436
  return () => window.removeEventListener('keydown', handleKeyDown)
435
- }, [search.open, onStartStream, onStopStream, isStreaming])
437
+ }, [search.open, onStartStream, onStopStream, isStreaming, sourceUnavailable])
436
438
 
437
439
  const handleFollowOutput = useCallback((isAtBottom: boolean) => {
438
440
  if (isAtBottom) return 'smooth' as const
@@ -568,15 +570,16 @@ export function LogCore({
568
570
  style={{ colorScheme: isDark ? 'dark' : 'light', fontFamily: "'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace" }}
569
571
  >
570
572
  {/* Toolbar */}
571
- <div className={`flex items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBg}`}>
573
+ <div className={`flex flex-wrap items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBg}`}>
572
574
  {toolbarExtraNode}
573
575
 
574
576
  {/* Stream / Stop toggle — only shown when streaming is supported */}
575
577
  {onStartStream && (
576
- <Tooltip content={isStreaming ? 'Stop streaming' : 'Start streaming'} delay={TIP_DELAY} position="bottom">
578
+ <Tooltip content={isStreaming ? 'Stop streaming' : sourceUnavailable ? 'Available once an instance is running' : 'Start streaming'} delay={TIP_DELAY} position="bottom">
577
579
  <button
578
580
  onClick={isStreaming ? onStopStream : onStartStream}
579
- className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors ${
581
+ disabled={sourceUnavailable && !isStreaming}
582
+ 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
583
  isStreaming
581
584
  ? 'bg-green-600 text-white hover:bg-green-700'
582
585
  : `${palette.elevatedBg} ${palette.textSecondary} ${palette.hoverBg}`
@@ -592,6 +595,7 @@ export function LogCore({
592
595
  <Tooltip content="Refresh logs" delay={TIP_DELAY} position="bottom">
593
596
  <button
594
597
  onClick={onRefresh}
598
+ aria-label="Refresh logs"
595
599
  disabled={isLoading || isStreaming}
596
600
  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
601
  >
@@ -608,7 +612,7 @@ export function LogCore({
608
612
  <Tooltip key={opt.level} content={`${active ? 'Hide' : 'Show'} ${opt.noun}`} delay={TIP_DELAY} position="bottom">
609
613
  <button
610
614
  onClick={() => toggleLevel(opt.level)}
611
- className={`px-1.5 py-0.5 text-[10px] font-medium rounded border transition-colors ${
615
+ className={`px-1.5 py-0.5 text-[10px] font-medium rounded border whitespace-nowrap transition-colors ${
612
616
  active
613
617
  ? getLevelActiveColor(opt.level, palette)
614
618
  : levelChipInactive
@@ -621,7 +625,7 @@ export function LogCore({
621
625
  })}
622
626
  </div>
623
627
 
624
- <div className="flex-1" />
628
+ <div role="group" aria-label="Log display and utilities" className="ml-auto flex shrink-0 flex-nowrap items-center gap-2">
625
629
 
626
630
  {/* Structured-log display mode: icon cycles compact→expanded→raw, chevron picks explicitly. */}
627
631
  {hasStructuredEntries && (
@@ -801,14 +805,15 @@ export function LogCore({
801
805
 
802
806
  {/* Export */}
803
807
  <div className="relative flex items-center" ref={downloadMenuRef}>
804
- <Tooltip content="Export logs" delay={TIP_DELAY} position="bottom" disabled={showDownloadMenu} preserveWrapperWhenDisabled>
808
+ <Tooltip content={bufferEntries.length === 0 ? 'No logs loaded' : 'Export logs'} delay={TIP_DELAY} position="bottom" disabled={showDownloadMenu} preserveWrapperWhenDisabled>
805
809
  <button
806
810
  ref={exportTriggerRef}
811
+ disabled={bufferEntries.length === 0}
807
812
  onClick={toggleExportMenu}
808
813
  aria-label="Export logs"
809
814
  aria-haspopup="dialog"
810
815
  aria-expanded={showDownloadMenu}
811
- className={iconBtnInactive}
816
+ className={`${iconBtnInactive} disabled:opacity-50 disabled:cursor-not-allowed`}
812
817
  >
813
818
  <Download className="w-4 h-4" />
814
819
  </button>
@@ -884,15 +889,18 @@ export function LogCore({
884
889
 
885
890
  {/* Clear */}
886
891
  {onClear && (
887
- <Tooltip content="Clear logs" delay={TIP_DELAY} position="bottom">
892
+ <Tooltip content={bufferEntries.length === 0 ? 'No logs loaded' : 'Clear logs'} delay={TIP_DELAY} position="bottom">
888
893
  <button
889
894
  onClick={onClear}
890
- className={iconBtnInactive}
895
+ aria-label="Clear logs"
896
+ disabled={bufferEntries.length === 0}
897
+ className={`${iconBtnInactive} disabled:opacity-50 disabled:cursor-not-allowed`}
891
898
  >
892
899
  <Trash2 className="w-4 h-4" />
893
900
  </button>
894
901
  </Tooltip>
895
902
  )}
903
+ </div>
896
904
  </div>
897
905
 
898
906
  {/* Search bar */}
@@ -1007,7 +1015,7 @@ export function LogCore({
1007
1015
  ) : groupedEntries.length === 0 ? (
1008
1016
  <div className={`${EMPTY_STATE_CLASS} ${palette.textTertiary}`}>
1009
1017
  <Terminal className="w-8 h-8" />
1010
- <span>{entries.length > 0 ? `Filters hide all ${entries.length.toLocaleString()} loaded lines` : emptyMessage}</span>
1018
+ <div>{entries.length > 0 ? `Filters hide all ${entries.length.toLocaleString()} loaded lines` : emptyMessage}</div>
1011
1019
  {entries.length === 0 && emptyCommand && (
1012
1020
  <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
1021
  <code className="truncate">{emptyCommand}</code>
@@ -1062,7 +1070,7 @@ export function LogCore({
1062
1070
 
1063
1071
  {/* Keyboard shortcut hints */}
1064
1072
  <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} />}
1073
+ {onStartStream && (!sourceUnavailable || isStreaming) && <Shortcut keys="S" label={isStreaming ? 'Stop stream' : 'Stream'} palette={palette} />}
1066
1074
  <Shortcut keys="Ctrl+F" label="Search" palette={palette} />
1067
1075
  {search.mode !== 'hide' && (
1068
1076
  <>
@@ -1189,7 +1197,7 @@ function LogLine({
1189
1197
  const highlighted = highlightSearchMatches(plain, searchQuery, searchIsRegex, searchIsCaseSensitive)
1190
1198
  contentElement = (
1191
1199
  <span
1192
- className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${levelColor}`}
1200
+ className={`${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'} ${levelColor}`}
1193
1201
  dangerouslySetInnerHTML={{ __html: highlighted }}
1194
1202
  />
1195
1203
  )
@@ -1209,13 +1217,13 @@ function LogLine({
1209
1217
  const html = ansiToHtml(entry.content)
1210
1218
  contentElement = (
1211
1219
  <span
1212
- className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${levelColor}`}
1220
+ className={`${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'} ${levelColor}`}
1213
1221
  dangerouslySetInnerHTML={{ __html: html }}
1214
1222
  />
1215
1223
  )
1216
1224
  } else {
1217
1225
  contentElement = (
1218
- <span className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${levelColor}`}>
1226
+ <span className={`${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'} ${levelColor}`}>
1219
1227
  {stripAnsi(entry.content)}
1220
1228
  </span>
1221
1229
  )
@@ -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)}
@@ -0,0 +1,291 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
5
+ import { ToastProvider } from '../ui/Toast'
6
+ import { LogsViewer, type LogsFetchParams } from './LogsViewer'
7
+
8
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
9
+
10
+ class FakeEventSource extends EventTarget {
11
+ static last: FakeEventSource | undefined
12
+ closed = false
13
+ constructor(readonly url: string) {
14
+ super()
15
+ FakeEventSource.last = this
16
+ }
17
+ close() { this.closed = true }
18
+ emit(type: string, data: unknown) {
19
+ this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) }))
20
+ }
21
+ }
22
+
23
+ // The shape the app's API client throws for a JSON error body.
24
+ class ApiError extends Error {
25
+ constructor(message: string, readonly data: Record<string, unknown>) {
26
+ super(message)
27
+ }
28
+ }
29
+
30
+ const NO_EARLIER_RUN = 'This container has not restarted, so there is no earlier run to show.'
31
+
32
+ let root: Root
33
+ let element: HTMLDivElement
34
+ beforeEach(() => {
35
+ vi.stubGlobal('EventSource', FakeEventSource)
36
+ element = document.createElement('div')
37
+ document.body.appendChild(element)
38
+ root = createRoot(element)
39
+ })
40
+ afterEach(async () => {
41
+ await act(async () => root.unmount())
42
+ element.remove()
43
+ vi.unstubAllGlobals()
44
+ })
45
+
46
+ async function renderViewer(fetchLogs: (params: LogsFetchParams) => Promise<{ [container: string]: string }>, autoStream = false) {
47
+ await act(async () =>
48
+ root.render(
49
+ <ToastProvider>
50
+ <LogsViewer
51
+ namespace="default"
52
+ podName="web-0"
53
+ containers={['app']}
54
+ fetchLogs={fetchLogs}
55
+ createStream={() => new EventSource('/stream')}
56
+ autoStream={autoStream}
57
+ />
58
+ </ToastProvider>,
59
+ ),
60
+ )
61
+ }
62
+
63
+ function previousCheckbox(): HTMLInputElement {
64
+ const box = element.querySelector<HTMLInputElement>('input[type="checkbox"]')
65
+ if (!box) throw new Error('Previous checkbox not rendered')
66
+ return box
67
+ }
68
+
69
+ function button(label: string): HTMLButtonElement {
70
+ const found = Array.from(element.querySelectorAll('button')).find(b => b.textContent?.trim() === label)
71
+ if (!found) throw new Error(`${label} button not rendered`)
72
+ return found
73
+ }
74
+
75
+ // The class of the box the message sits in: the innermost element holding the
76
+ // text is the message itself, and its parent carries the colour.
77
+ function messageClass(text: string): string {
78
+ const message = Array.from(element.querySelectorAll('span, div')).filter(el => el.textContent === text).pop()
79
+ if (!message?.parentElement) throw new Error(`"${text}" not rendered`)
80
+ return message.parentElement.className
81
+ }
82
+
83
+ it('starting a stream leaves the Previous view and its answer behind', async () => {
84
+ const fetchLogs = vi.fn(async (params: LogsFetchParams) => {
85
+ if (params.previous) throw new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' })
86
+ return { app: '' }
87
+ })
88
+ await renderViewer(fetchLogs)
89
+ await act(async () => previousCheckbox().click())
90
+ expect(element.textContent).toContain(NO_EARLIER_RUN)
91
+ const callsBeforeStream = fetchLogs.mock.calls.length
92
+
93
+ await act(async () => button('Stream').click())
94
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
95
+
96
+ expect(element.textContent).not.toContain(NO_EARLIER_RUN)
97
+ expect(previousCheckbox().checked).toBe(false)
98
+ // Unticking Previous must not also load a snapshot over the stream.
99
+ expect(fetchLogs.mock.calls.length).toBe(callsBeforeStream)
100
+ })
101
+
102
+ it('with auto-stream on, Stop, Previous, then Stream returns to the live stream', async () => {
103
+ const fetchLogs = vi.fn(async (params: LogsFetchParams) => {
104
+ if (params.previous) throw new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' })
105
+ return { app: '' }
106
+ })
107
+ await renderViewer(fetchLogs, true)
108
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
109
+ await act(async () => button('Stop').click())
110
+ await act(async () => previousCheckbox().click())
111
+ expect(element.textContent).toContain(NO_EARLIER_RUN)
112
+ const callsBeforeStream = fetchLogs.mock.calls.length
113
+
114
+ await act(async () => button('Stream').click())
115
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
116
+
117
+ expect(element.textContent).not.toContain(NO_EARLIER_RUN)
118
+ expect(previousCheckbox().checked).toBe(false)
119
+ expect(button('Stop')).toBeTruthy()
120
+ expect(fetchLogs.mock.calls.length).toBe(callsBeforeStream)
121
+ })
122
+
123
+ it('with auto-stream on, Previous loads the earlier run once the stream ended by itself', async () => {
124
+ const fetchLogs = vi.fn(async (params: LogsFetchParams) =>
125
+ ({ app: params.previous ? '2026-10-13T10:00:00Z earlier run crashed' : '' }))
126
+ await renderViewer(fetchLogs, true)
127
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
128
+ // The container exited: the stream ends without the user pressing Stop.
129
+ await act(async () => FakeEventSource.last?.emit('end', {}))
130
+ expect(button('Stream')).toBeTruthy()
131
+
132
+ await act(async () => previousCheckbox().click())
133
+
134
+ expect(fetchLogs).toHaveBeenCalledWith(expect.objectContaining({ previous: true }))
135
+ // The earlier run's line is in the buffer: the level chips count it.
136
+ expect(element.textContent).toContain('OTHER 1')
137
+ })
138
+
139
+ it('with auto-stream on, a time range picked after the stream ended loads it instead of reopening the stream', async () => {
140
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
141
+ await renderViewer(fetchLogs, true)
142
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
143
+ await act(async () => FakeEventSource.last?.emit('end', {}))
144
+ const ended = FakeEventSource.last
145
+ await act(async () => previousCheckbox().click())
146
+
147
+ const range = element.querySelector<HTMLSelectElement>('select')
148
+ if (!range) throw new Error('range select not rendered')
149
+ await act(async () => {
150
+ range.value = range.options[range.options.length - 1].value
151
+ range.dispatchEvent(new Event('change', { bubbles: true }))
152
+ })
153
+
154
+ expect(FakeEventSource.last).toBe(ended)
155
+ expect(previousCheckbox().checked).toBe(true)
156
+ expect(fetchLogs).toHaveBeenLastCalledWith(expect.objectContaining({ previous: true, sinceSeconds: expect.any(Number) }))
157
+ })
158
+
159
+ it('with auto-stream on, a range picked while the stream opens reopens it with that range', async () => {
160
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
161
+ await renderViewer(fetchLogs, true)
162
+ const opening = FakeEventSource.last
163
+
164
+ const range = element.querySelector<HTMLSelectElement>('select')
165
+ if (!range) throw new Error('range select not rendered')
166
+ await act(async () => {
167
+ range.value = range.options[range.options.length - 1].value
168
+ range.dispatchEvent(new Event('change', { bubbles: true }))
169
+ })
170
+
171
+ expect(opening?.closed).toBe(true)
172
+ expect(FakeEventSource.last).not.toBe(opening)
173
+ expect(fetchLogs).not.toHaveBeenCalled()
174
+ })
175
+
176
+ it('with auto-stream on, after the stream ended and was started again, a range picked while it opens reopens it', async () => {
177
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
178
+ await renderViewer(fetchLogs, true)
179
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
180
+ await act(async () => FakeEventSource.last?.emit('end', {}))
181
+ const range = element.querySelector<HTMLSelectElement>('select')
182
+ if (!range) throw new Error('range select not rendered')
183
+ const pick = (index: number) => act(async () => {
184
+ range.value = range.options[index].value
185
+ range.dispatchEvent(new Event('change', { bubbles: true }))
186
+ })
187
+ await pick(range.options.length - 1)
188
+ await act(async () => button('Stream').click())
189
+ const opening = FakeEventSource.last
190
+
191
+ await pick(range.options.length - 2)
192
+
193
+ expect(opening?.closed).toBe(true)
194
+ expect(FakeEventSource.last).not.toBe(opening)
195
+ })
196
+
197
+ it('a range picked while a manually started stream opens does not cancel it', async () => {
198
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
199
+ await renderViewer(fetchLogs, true)
200
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
201
+ await act(async () => button('Stop').click())
202
+ await act(async () => button('Stream').click())
203
+ const opening = FakeEventSource.last
204
+ const range = element.querySelector<HTMLSelectElement>('select')
205
+ if (!range) throw new Error('range select not rendered')
206
+
207
+ await act(async () => {
208
+ range.value = range.options[range.options.length - 1].value
209
+ range.dispatchEvent(new Event('change', { bubbles: true }))
210
+ })
211
+
212
+ expect(opening?.closed).toBe(false)
213
+ })
214
+
215
+ it('with auto-stream on, the open stream is not replaced by a snapshot', async () => {
216
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
217
+ await renderViewer(fetchLogs, true)
218
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
219
+ expect(fetchLogs).not.toHaveBeenCalled()
220
+ expect(button('Stop')).toBeTruthy()
221
+ })
222
+
223
+ it('a Previous answer that arrives after the stream started does not cover it', async () => {
224
+ let reject: (err: Error) => void = () => {}
225
+ const fetchLogs = vi.fn((params: LogsFetchParams) =>
226
+ params.previous
227
+ ? new Promise<{ [container: string]: string }>((_, r) => { reject = r })
228
+ : Promise.resolve({ app: '' }))
229
+ await renderViewer(fetchLogs)
230
+ await act(async () => previousCheckbox().click())
231
+ await act(async () => button('Stream').click())
232
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
233
+ await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-13T10:00:00Z', content: 'live line', container: 'app' }))
234
+ // Appends are batched to the next animation frame.
235
+ await act(async () => { await new Promise(resolve => requestAnimationFrame(() => resolve(null))) })
236
+ await act(async () => reject(new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' })))
237
+
238
+ expect(element.textContent).not.toContain(NO_EARLIER_RUN)
239
+ // The live line is still in the buffer: the level chips count it.
240
+ expect(element.textContent).toContain('OTHER 1')
241
+ })
242
+
243
+ it('a Previous answer replaces an earlier stream failure', async () => {
244
+ const fetchLogs = vi.fn(async (params: LogsFetchParams) => {
245
+ if (params.previous) throw new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' })
246
+ return { app: '' }
247
+ })
248
+ await renderViewer(fetchLogs)
249
+ await act(async () => button('Stream').click())
250
+ await act(async () => FakeEventSource.last?.emit('error', { error: 'stream dropped' }))
251
+ expect(element.textContent).toContain('stream dropped')
252
+
253
+ await act(async () => previousCheckbox().click())
254
+ expect(element.textContent).toContain(NO_EARLIER_RUN)
255
+ expect(element.textContent).not.toContain('stream dropped')
256
+ })
257
+
258
+ it('shows a Kubernetes answer in the empty-pane style, not as an error', async () => {
259
+ await renderViewer(async () => { throw new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' }) })
260
+ expect(element.textContent).toContain(NO_EARLIER_RUN)
261
+ expect(messageClass(NO_EARLIER_RUN)).not.toMatch(/text-red/)
262
+ })
263
+
264
+ it('shows the live stream\'s Kubernetes answer in the empty-pane style, and a stream failure as an error', async () => {
265
+ const unavailable = 'Kubernetes did not return this container\'s logs'
266
+ await renderViewer(async () => ({ app: '' }))
267
+ await act(async () => button('Stream').click())
268
+ await act(async () => FakeEventSource.last?.emit('error', { error: unavailable, error_code: 'logs_unavailable' }))
269
+ expect(messageClass(unavailable)).not.toMatch(/text-red/)
270
+
271
+ await act(async () => button('Stream').click())
272
+ await act(async () => FakeEventSource.last?.emit('error', { error: 'stream dropped' }))
273
+ expect(messageClass('stream dropped')).toMatch(/text-red/)
274
+ })
275
+
276
+ it('a snapshot load replaces an ended stream\'s answer even when it succeeds empty', async () => {
277
+ const unavailable = 'Kubernetes did not return this container\'s logs'
278
+ await renderViewer(async () => ({ app: '' }))
279
+ await act(async () => button('Stream').click())
280
+ await act(async () => FakeEventSource.last?.emit('error', { error: unavailable, error_code: 'logs_unavailable' }))
281
+ expect(element.textContent).toContain(unavailable)
282
+
283
+ await act(async () => previousCheckbox().click())
284
+ expect(element.textContent).not.toContain(unavailable)
285
+ })
286
+
287
+ it('still shows a Radar failure as an error', async () => {
288
+ const failure = 'Failed to fetch logs: connection refused'
289
+ await renderViewer(async () => { throw new ApiError(failure, { error: failure }) })
290
+ expect(messageClass(failure)).toMatch(/text-red/)
291
+ })