@skyhook-io/k8s-ui 1.16.3 → 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 (309) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  3. package/src/components/applications/ApplicationDetail.test.tsx +15 -0
  4. package/src/components/applications/ApplicationDetail.tsx +17 -15
  5. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  6. package/src/components/charts/AreaChart.test.tsx +25 -0
  7. package/src/components/charts/AreaChart.tsx +141 -12
  8. package/src/components/charts/index.ts +1 -0
  9. package/src/components/checks/ChecksView.tsx +18 -1
  10. package/src/components/checks/types.ts +2 -13
  11. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  12. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  13. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  14. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  15. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  16. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  17. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  18. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  19. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  20. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  21. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  22. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  23. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  24. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  25. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  26. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  27. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  28. package/src/components/cnpg/backupRuns.test.ts +17 -0
  29. package/src/components/cnpg/backupRuns.ts +19 -0
  30. package/src/components/cnpg/connect.test.ts +124 -0
  31. package/src/components/cnpg/connect.ts +155 -0
  32. package/src/components/cnpg/databaseRole.test.ts +38 -0
  33. package/src/components/cnpg/databaseRole.ts +80 -0
  34. package/src/components/cnpg/ha.test.ts +446 -0
  35. package/src/components/cnpg/ha.ts +621 -0
  36. package/src/components/cnpg/index.ts +18 -0
  37. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  38. package/src/components/cnpg/logicalReplication.ts +252 -0
  39. package/src/components/cnpg/pooler.test.ts +84 -0
  40. package/src/components/cnpg/pooler.ts +225 -0
  41. package/src/components/cnpg/primitives.tsx +21 -117
  42. package/src/components/cnpg/problems.ts +12 -0
  43. package/src/components/cnpg/relations.test.ts +63 -13
  44. package/src/components/cnpg/relations.ts +61 -52
  45. package/src/components/cnpg/schedule.ts +35 -0
  46. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  47. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  48. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  49. package/src/components/cnpg/workspace.test.ts +380 -9
  50. package/src/components/cnpg/workspace.ts +1137 -136
  51. package/src/components/dock/DockContext.test.tsx +32 -0
  52. package/src/components/dock/DockContext.tsx +18 -4
  53. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  54. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  55. package/src/components/dock/TerminalTab.tsx +20 -8
  56. package/src/components/facts/certainty.tsx +36 -0
  57. package/src/components/facts/facts.test.tsx +24 -0
  58. package/src/components/facts/facts.tsx +62 -0
  59. package/src/components/facts/index.ts +6 -0
  60. package/src/components/facts/managed-by.test.tsx +17 -0
  61. package/src/components/facts/managed-by.tsx +36 -0
  62. package/src/components/facts/public-exports.test.tsx +35 -0
  63. package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
  64. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  65. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  66. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  67. package/src/components/gitops/detail-helpers.ts +1 -1
  68. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
  69. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  70. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  71. package/src/components/gitops/insights/GitOpsInsightViews.tsx +29 -83
  72. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  73. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +40 -60
  74. package/src/components/gitops/tree/layout.test.ts +111 -0
  75. package/src/components/gitops/tree/layout.ts +128 -0
  76. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  77. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  78. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  79. package/src/components/issues/IssuesView.tsx +40 -5
  80. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  81. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  82. package/src/components/issues/index.ts +3 -1
  83. package/src/components/issues/issue-timing.ts +14 -3
  84. package/src/components/issues/issues.test.ts +50 -2
  85. package/src/components/issues/severity.ts +21 -0
  86. package/src/components/issues/types.ts +23 -0
  87. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  88. package/src/components/logs/LogCore.tsx +25 -17
  89. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  90. package/src/components/logs/LogsViewer.test.tsx +291 -0
  91. package/src/components/logs/LogsViewer.tsx +72 -11
  92. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  93. package/src/components/logs/StructuredLogLine.tsx +22 -30
  94. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  95. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  96. package/src/components/logs/WorkloadLogsViewer.tsx +44 -10
  97. package/src/components/logs/useLogBuffer.test.ts +8 -0
  98. package/src/components/logs/useLogStream.ts +28 -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/ResourcesSidebar.keyboard.test.tsx +42 -0
  105. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  106. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  107. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  108. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  109. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  110. package/src/components/resources/ResourcesView.tsx +309 -117
  111. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  112. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  113. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  114. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  115. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +5 -4
  116. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  117. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  118. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  119. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  120. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  121. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  122. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  123. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  124. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +7 -8
  125. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  126. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  127. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +9 -17
  128. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  129. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  130. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  131. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  132. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  133. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  134. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  135. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  136. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  137. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  138. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +62 -0
  139. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +15 -14
  140. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  141. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  142. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  143. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  144. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  145. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  146. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  147. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  148. package/src/components/resources/renderers/HPARenderer.tsx +57 -17
  149. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  150. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  151. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  152. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  153. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  154. package/src/components/resources/renderers/IngressRenderer.tsx +36 -5
  155. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  156. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  157. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  158. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  159. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  160. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +15 -13
  161. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  162. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  163. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  164. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  165. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  166. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  167. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  168. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  169. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  170. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  171. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  172. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  173. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  174. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  175. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  176. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  177. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  178. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  179. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  180. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  181. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  182. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  183. package/src/components/resources/renderers/PodRenderer.tsx +62 -36
  184. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  185. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  186. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  187. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  188. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  189. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  190. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  191. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +9 -22
  192. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  193. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  194. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  195. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  196. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  197. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  198. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  199. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  200. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  201. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  202. package/src/components/resources/resource-utils-capi.ts +1 -1
  203. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  204. package/src/components/resources/resource-utils-cnpg.ts +51 -24
  205. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  206. package/src/components/resources/resource-utils-contour.ts +16 -0
  207. package/src/components/resources/resource-utils-istio.ts +12 -8
  208. package/src/components/resources/resource-utils-keda.ts +1 -7
  209. package/src/components/resources/resource-utils-knative.ts +18 -0
  210. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  211. package/src/components/resources/resource-utils-velero.ts +0 -12
  212. package/src/components/resources/resource-utils.ts +99 -39
  213. package/src/components/resources/rules-column-width.test.ts +1 -1
  214. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  215. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  216. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  217. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  218. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  219. package/src/components/shared/DetailShell.test.tsx +80 -0
  220. package/src/components/shared/DetailShell.tsx +85 -10
  221. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  222. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  223. package/src/components/shared/EditableYamlView.tsx +1 -1
  224. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  225. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  226. package/src/components/shared/ResourceActionsBar.tsx +20 -24
  227. package/src/components/shared/ResourceRendererDispatch.tsx +13 -10
  228. package/src/components/shared/SetImageDialog.test.ts +75 -6
  229. package/src/components/shared/SetImageDialog.tsx +92 -84
  230. package/src/components/shared/index.ts +4 -1
  231. package/src/components/timeline/TimelineList.tsx +2 -15
  232. package/src/components/timeline/TimelineSwimlanes.tsx +3 -11
  233. package/src/components/timeline/timeline-filters.ts +1 -0
  234. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  235. package/src/components/topology/K8sResourceNode.tsx +44 -2
  236. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  237. package/src/components/topology/TopologyGraph.tsx +8 -74
  238. package/src/components/topology/TopologySearch.tsx +1 -0
  239. package/src/components/topology/internet-groups.test.ts +35 -0
  240. package/src/components/topology/internet-groups.ts +55 -0
  241. package/src/components/topology/layout.ts +1 -1
  242. package/src/components/ui/Badge.tsx +1 -0
  243. package/src/components/ui/FoldSection.tsx +68 -0
  244. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  245. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  246. package/src/components/ui/RefLink.tsx +20 -0
  247. package/src/components/ui/RowActionMenu.tsx +8 -3
  248. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  249. package/src/components/ui/ScheduleValue.tsx +3 -2
  250. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  251. package/src/components/ui/drawer-components.test.tsx +175 -1
  252. package/src/components/ui/drawer-components.tsx +103 -36
  253. package/src/components/ui/index.ts +3 -1
  254. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  255. package/src/components/ui/monacoRuntime.ts +14 -19
  256. package/src/components/ui/status-tone.test.ts +10 -1
  257. package/src/components/ui/status-tone.tsx +29 -0
  258. package/src/components/workload/WorkloadView.deleted.test.tsx +101 -0
  259. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  260. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  261. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  262. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  263. package/src/components/workload/WorkloadView.tsx +297 -104
  264. package/src/index.ts +14 -1
  265. package/src/monaco-deep.d.ts +19 -12
  266. package/src/theme/components.css +13 -0
  267. package/src/types/capacity.ts +21 -2
  268. package/src/types/core.ts +60 -2
  269. package/src/types/gitops-flux-status.test.ts +32 -1
  270. package/src/types/gitops-insights.ts +19 -2
  271. package/src/types/gitops-tree.ts +10 -0
  272. package/src/types/gitops.ts +26 -6
  273. package/src/utils/api-resources.ts +7 -0
  274. package/src/utils/application-history.ts +1 -0
  275. package/src/utils/cnpg-backup.test.ts +26 -0
  276. package/src/utils/cnpg-backup.ts +70 -0
  277. package/src/utils/format.test.ts +49 -2
  278. package/src/utils/format.ts +9 -1
  279. package/src/utils/gateway-references.test.ts +17 -0
  280. package/src/utils/gateway-references.ts +29 -0
  281. package/src/utils/gitops-owner.ts +3 -2
  282. package/src/utils/gitops-write-guard.test.ts +401 -0
  283. package/src/utils/gitops-write-guard.ts +509 -0
  284. package/src/utils/grant.test.ts +19 -0
  285. package/src/utils/grant.ts +35 -0
  286. package/src/utils/index.ts +6 -0
  287. package/src/utils/ingress-class-references.ts +22 -0
  288. package/src/utils/k8s-errors.ts +7 -7
  289. package/src/utils/log-format.test.ts +41 -0
  290. package/src/utils/log-format.ts +71 -15
  291. package/src/utils/log-level.ts +12 -8
  292. package/src/utils/navigation.test.ts +70 -0
  293. package/src/utils/navigation.ts +91 -8
  294. package/src/utils/node-lifecycle.test.ts +36 -0
  295. package/src/utils/node-lifecycle.ts +77 -0
  296. package/src/utils/object-reference-navigation.test.ts +43 -0
  297. package/src/utils/resource-hierarchy.test.ts +69 -0
  298. package/src/utils/resource-hierarchy.ts +41 -7
  299. package/src/utils/resource-icons.ts +2 -0
  300. package/src/utils/resource-refs.test.ts +23 -0
  301. package/src/utils/resource-refs.ts +30 -0
  302. package/src/utils/skeleton-yaml.test.ts +21 -0
  303. package/src/utils/skeleton-yaml.ts +16 -0
  304. package/src/utils/url-path.test.ts +35 -0
  305. package/src/utils/url-path.ts +21 -0
  306. package/src/utils/validators.test.ts +4 -13
  307. package/src/utils/validators.ts +5 -19
  308. package/src/utils/view-transition.test.ts +35 -1
  309. package/src/utils/view-transition.ts +16 -1
@@ -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
+ })
@@ -40,6 +40,22 @@ export interface LogsViewerProps {
40
40
  autoStream?: boolean
41
41
  }
42
42
 
43
+ // Kubernetes' answers that there are no logs to show, such as "no earlier run".
44
+ // They describe the pod, not a failure in Radar, so they are shown like an
45
+ // empty pane rather than as an error.
46
+ const CLUSTER_ANSWER_CODES = new Set(['no_previous_run', 'logs_unavailable'])
47
+
48
+ function isClusterAnswerCode(code: unknown): boolean {
49
+ return typeof code === 'string' && CLUSTER_ANSWER_CODES.has(code)
50
+ }
51
+
52
+ function clusterAnswerOf(err: unknown): string | null {
53
+ if (!(err instanceof Error) || !('data' in err)) return null
54
+ const { data } = err
55
+ if (typeof data !== 'object' || data === null || !('error_code' in data)) return null
56
+ return isClusterAnswerCode(data.error_code) ? err.message : null
57
+ }
58
+
43
59
  export function LogsViewer({
44
60
  namespace: _namespace,
45
61
  podName,
@@ -55,13 +71,14 @@ export function LogsViewer({
55
71
  const [selectedContainer, setSelectedContainer] = useState(initialContainer || containers[0] || '')
56
72
  const [isLoading, setIsLoading] = useState(false)
57
73
  const [fetchError, setFetchError] = useState<string | null>(null)
74
+ const [emptyMessage, setEmptyMessage] = useState<string | null>(null)
58
75
  const [logRange, setLogRange] = useState('500')
59
76
  const [showPrevious, setShowPrevious] = useState(false)
60
77
  const { showError, showSuccess } = useToast()
61
78
 
62
79
  const { tailLines, sinceSeconds } = parseLogRange(logRange)
63
80
  const { entries, append, set, clear } = useLogBuffer()
64
- const { isStreaming, streamError, connecting, startStreaming, stopStreaming } = useLogStream()
81
+ const { isStreaming, streamError, streamErrorCode, connecting, startStreaming, stopStreaming } = useLogStream()
65
82
 
66
83
  const willAutoStream = autoStream && !!createStream
67
84
  // Tracks the container we've already auto-started for, so re-renders don't
@@ -69,33 +86,61 @@ export function LogsViewer({
69
86
  const autoStartedForRef = useRef<string | null>(null)
70
87
  // Once the user explicitly Stops, don't auto-resume for this viewer's lifetime.
71
88
  const userStoppedRef = useRef(false)
89
+ // Set when starting a stream unticks Previous, so that change does not also
90
+ // load a snapshot over the stream.
91
+ const skipSnapshotRef = useRef(false)
92
+ // Numbers each snapshot load; a load superseded by a newer one or by a
93
+ // stream start must not write over what replaced it.
94
+ const loadSeqRef = useRef(0)
95
+ // Whether a stream is open or opening, read where its end must not itself
96
+ // trigger anything.
97
+ const streamOpenRef = useRef(false)
98
+ useEffect(() => { streamOpenRef.current = isStreaming || connecting }, [isStreaming, connecting])
72
99
 
73
100
  const loadLogs = useCallback(async () => {
74
101
  if (!selectedContainer) return
102
+ const seq = ++loadSeqRef.current
103
+ // The snapshot now owns the pane, so an ended stream's answer or failure
104
+ // must not outlive it. A stream still open or opening is left alone.
105
+ if (!streamOpenRef.current) stopStreaming()
75
106
  setIsLoading(true)
76
107
  setFetchError(null)
108
+ setEmptyMessage(null)
77
109
  try {
78
110
  const data = await fetchLogs({ container: selectedContainer, tailLines, sinceSeconds, previous: showPrevious })
111
+ if (seq !== loadSeqRef.current) return
79
112
  const logText = data[selectedContainer] ?? Object.values(data)[0] ?? ''
80
113
  set(logText.split('\n').filter(Boolean).map(line => {
81
114
  const { timestamp, content } = parseLogLine(line)
82
115
  return { timestamp, content, container: selectedContainer }
83
116
  }))
84
117
  } catch (err) {
118
+ if (seq !== loadSeqRef.current) return
119
+ const answer = clusterAnswerOf(err)
120
+ if (answer) {
121
+ set([])
122
+ setEmptyMessage(answer)
123
+ return
124
+ }
85
125
  console.error('Failed to fetch logs:', err)
86
126
  setFetchError(err instanceof Error ? err.message : 'Failed to fetch logs')
87
127
  } finally {
88
- setIsLoading(false)
128
+ if (seq === loadSeqRef.current) setIsLoading(false)
89
129
  }
90
- }, [selectedContainer, tailLines, sinceSeconds, showPrevious, fetchLogs, set])
130
+ }, [selectedContainer, tailLines, sinceSeconds, showPrevious, fetchLogs, set, stopStreaming])
91
131
 
92
132
  // When auto-streaming the stream supplies the initial tail, so the static
93
133
  // snapshot fetch is skipped to avoid a redundant request and a flash of
94
134
  // snapshot content before the stream takes over. If the user has Stopped we
95
135
  // won't auto-start, so fall back to the snapshot — otherwise a container
96
- // switch would keep showing the previous container's lines.
136
+ // switch would keep showing the previous container's lines. Once the stream
137
+ // has ended by itself, Previous and the range load a snapshot too.
97
138
  useEffect(() => {
98
- if (!willAutoStream || userStoppedRef.current) loadLogs()
139
+ if (skipSnapshotRef.current) {
140
+ skipSnapshotRef.current = false
141
+ return
142
+ }
143
+ if (!willAutoStream || userStoppedRef.current || !streamOpenRef.current) loadLogs()
99
144
  }, [loadLogs, willAutoStream])
100
145
  useEffect(() => { stopStreaming() }, [selectedContainer, stopStreaming])
101
146
 
@@ -109,6 +154,18 @@ export function LogsViewer({
109
154
 
110
155
  const handleStartStreaming = useCallback(() => {
111
156
  if (!createStream) return
157
+ // The stream always shows the current run, so starting one leaves the
158
+ // Previous view without loading a snapshot over the stream. The updater
159
+ // reads Previous so this callback does not depend on it: a new identity
160
+ // would re-run the auto-start effect and reopen the stream.
161
+ setShowPrevious(previous => {
162
+ if (previous) skipSnapshotRef.current = true
163
+ return false
164
+ })
165
+ loadSeqRef.current++
166
+ setIsLoading(false)
167
+ setFetchError(null)
168
+ setEmptyMessage(null)
112
169
  // The stream replays the last N lines (TailLines + Follow); clear first so
113
170
  // they don't duplicate lines already in the buffer (the snapshot on the
114
171
  // manual path, or an earlier stream on restart).
@@ -134,13 +191,15 @@ export function LogsViewer({
134
191
  useEffect(() => {
135
192
  if (!willAutoStream || !selectedContainer) return
136
193
  if (userStoppedRef.current) return
137
- if (autoStartedForRef.current === selectedContainer) return
138
- autoStartedForRef.current = selectedContainer
139
- handleStartStreaming()
194
+ if (autoStartedForRef.current !== selectedContainer) {
195
+ autoStartedForRef.current = selectedContainer
196
+ handleStartStreaming()
197
+ }
140
198
  // Reset the arm latch on teardown so a re-run re-streams — without this,
141
199
  // React Strict Mode's mount→unmount→mount closes the stream but the latch
142
- // stays set, leaving the viewer static.
143
- return () => { autoStartedForRef.current = null }
200
+ // stays set, leaving the viewer static. Only while a stream is open: one
201
+ // that ended by itself stays ended, and a range change loads a snapshot.
202
+ return () => { if (streamOpenRef.current) autoStartedForRef.current = null }
144
203
  }, [willAutoStream, selectedContainer, handleStartStreaming])
145
204
 
146
205
  const downloadLogs = useCallback(({ content, mime, extension }: LogExportPayload) => {
@@ -179,18 +238,20 @@ export function LogsViewer({
179
238
  // While the auto-stream is opening (before it first settles), show the
180
239
  // loading state rather than the empty-logs placeholder.
181
240
  const isConnecting = willAutoStream && connecting && entries.length === 0
241
+ const streamAnswer = isClusterAnswerCode(streamErrorCode) ? streamError : null
182
242
 
183
243
  return (
184
244
  <LogCore
185
245
  entries={entries}
186
246
  isLoading={isLoading || isConnecting}
187
- errorMessage={fetchError || (entries.length === 0 ? streamError : null)}
247
+ errorMessage={fetchError || (entries.length === 0 && !emptyMessage && !streamAnswer ? streamError : null)}
188
248
  isStreaming={isStreaming}
189
249
  onStartStream={createStream ? handleStartStreaming : undefined}
190
250
  onStopStream={handleStopStreaming}
191
251
  onRefresh={loadLogs}
192
252
  onDownload={downloadLogs}
193
253
  onClear={clear}
254
+ emptyMessage={emptyMessage ?? streamAnswer ?? undefined}
194
255
  toolbarExtra={renderToolbarExtra}
195
256
  forceDark={forceDark}
196
257
  defaultDark={defaultDark}
@@ -0,0 +1,76 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+ import { createRoot } from 'react-dom/client'
5
+ import { describe, expect, it, vi } from 'vitest'
6
+ import { StructuredLogLine } from './StructuredLogLine'
7
+
8
+ const render = (content: string) => renderToStaticMarkup(<StructuredLogLine content={content} level="info" wordWrap={false} />)
9
+
10
+ describe('StructuredLogLine summary', () => {
11
+ it('shows a CloudNativePG PostgreSQL record by its own message and severity', () => {
12
+ const html = render(
13
+ JSON.stringify({
14
+ level: 'info',
15
+ logger: 'postgres',
16
+ msg: 'record',
17
+ record: { error_severity: 'FATAL', message: 'terminating connection due to administrator command' },
18
+ }),
19
+ )
20
+ expect(html).toContain('terminating connection due to administrator command')
21
+ expect(html).toContain('FATAL')
22
+ expect(html).not.toContain('>record<')
23
+ })
24
+
25
+ it('keeps msg for ordinary structured lines', () => {
26
+ const html = render(JSON.stringify({ level: 'info', msg: 'Fencing status changed', record: 'not an object' }))
27
+ expect(html).toContain('Fencing status changed')
28
+ })
29
+
30
+ it.each([
31
+ { msg: 'record' },
32
+ { logger: 'worker', msg: 'record' },
33
+ { logger: 'postgres', msg: 'wrapper message' },
34
+ ])('keeps unrelated record fields out of the summary: %j', (fields) => {
35
+ const html = render(JSON.stringify({ level: 'info', ...fields, record: { error_severity: 'FATAL', message: 'unrelated nested text' } }))
36
+ expect(html).toContain(`>${fields.msg}<`)
37
+ expect(html).toContain('INFO')
38
+ expect(html).not.toContain('unrelated nested text')
39
+ expect(html).not.toContain('FATAL')
40
+ })
41
+
42
+ it('requires PostgreSQL record evidence before selecting a nested message', () => {
43
+ const html = render(JSON.stringify({ level: 'info', logger: 'postgres', msg: 'record', record: { message: 'unverified nested text' } }))
44
+ expect(html).toContain('>record<')
45
+ expect(html).not.toContain('unverified nested text')
46
+ })
47
+ })
48
+
49
+ it.each([false, true])('keeps annotations intact with word wrapping, expanded=%s', (expanded) => {
50
+ const html = renderToStaticMarkup(<StructuredLogLine content={JSON.stringify({ level: 'info', msg: 'normal words', token: 'x'.repeat(400) })} level="info" wordWrap defaultExpanded={expanded} />)
51
+ expect(html).toMatch(/inline-block whitespace-nowrap[^>]*>\{3 fields\}/)
52
+ expect(html).toContain('[overflow-wrap:anywhere]')
53
+ expect(html).not.toContain('break-all')
54
+ })
55
+
56
+ it('filters expanded strings, numbers and booleans by their displayed value', () => {
57
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
58
+ const filter = vi.fn()
59
+ const message = 'true null 123 "quoted"\nnext'
60
+ const host = document.createElement('div')
61
+ const root = createRoot(host)
62
+ try {
63
+ act(() => root.render(<StructuredLogLine content={JSON.stringify({ msg: message, number: -12.5, ready: true, empty: null, html: '<script>unsafe</script>' })} level="info" wordWrap defaultExpanded onFilterValue={filter} />))
64
+ const buttons = [...host.querySelectorAll('button')]
65
+ for (const value of [message, '-12.5', 'true', '<script>unsafe</script>']) {
66
+ const button = buttons.find((button) => button.getAttribute('aria-label') === `Filter to lines containing ${value}`)
67
+ expect(button).toBeDefined()
68
+ act(() => button!.click())
69
+ expect(filter).toHaveBeenLastCalledWith(value)
70
+ }
71
+ expect(host.querySelector('script')).toBeNull()
72
+ expect(buttons.some((button) => button.getAttribute('aria-label') === 'Filter to lines containing null')).toBe(false)
73
+ } finally {
74
+ act(() => root.unmount())
75
+ }
76
+ })
@@ -1,8 +1,8 @@
1
1
  import { useState, useMemo } from 'react'
2
2
  import { ChevronRight, ChevronDown, Filter } from 'lucide-react'
3
3
  import type { LogLevel } from './useLogBuffer'
4
- import { selectLevelField } from '../../utils/log-level'
5
- import { unescapeJsonStrings, parseLogfmt } from '../../utils/log-format'
4
+ import { selectLevelField, selectPostgresRecord } from '../../utils/log-level'
5
+ import { unescapeJsonStrings, parseLogfmt, tokenizeJson } from '../../utils/log-format'
6
6
  import { getLogPalette, getLogLevelColor, type LogPalette } from './log-palette'
7
7
 
8
8
  interface StructuredLogLineProps {
@@ -44,7 +44,7 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
44
44
 
45
45
  if (!parsed) {
46
46
  return (
47
- <span className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${getLogLevelColor(level, isDark)}`}>
47
+ <span className={`${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'} ${getLogLevelColor(level, isDark)}`}>
48
48
  {content}
49
49
  </span>
50
50
  )
@@ -63,11 +63,11 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
63
63
  // Collapsed: entire summary line is clickable
64
64
  <span
65
65
  onClick={toggle}
66
- className={`cursor-pointer ${palette.hoverSurface} rounded px-0.5 -ml-0.5 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}
66
+ className={`cursor-pointer ${palette.hoverSurface} rounded px-0.5 -ml-0.5 ${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'}`}
67
67
  >
68
68
  <span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
69
69
  <SummaryLine obj={parsed} level={level} palette={palette} />
70
- <span className={`${palette.textTertiary} ml-1`}>{`{${fieldCount} fields}`}</span>
70
+ <span className={`${palette.textTertiary} ml-1 inline-block whitespace-nowrap`}>{`{${fieldCount} fields}`}</span>
71
71
  </span>
72
72
  ) : (
73
73
  // Expanded: summary header is clickable to collapse, JSON content is selectable
@@ -78,9 +78,9 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
78
78
  >
79
79
  <span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
80
80
  <SummaryLine obj={parsed} level={level} palette={palette} />
81
- <span className={`${palette.textTertiary} ml-1`}>{`{${fieldCount} fields}`}</span>
81
+ <span className={`${palette.textTertiary} ml-1 inline-block whitespace-nowrap`}>{`{${fieldCount} fields}`}</span>
82
82
  </span>
83
- <span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}>
83
+ <span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'}`}>
84
84
  {isLogfmt ? (
85
85
  <ExpandedLogfmt obj={parsed} onFilterValue={onFilterValue} palette={palette} />
86
86
  ) : (
@@ -129,48 +129,40 @@ function FilterableValue({
129
129
  * and emits React nodes so the hover chip can be wired per value.
130
130
  */
131
131
  function JsonExpanded({ text, onFilterValue, palette }: { text: string; onFilterValue?: (v: string) => void; palette: LogPalette }) {
132
- const tokenRe = /("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|\b(true|false)\b|\b(null)\b/g
133
132
  const nodes: React.ReactNode[] = []
134
- let lastIndex = 0
135
- let match: RegExpExecArray | null
136
133
  let idx = 0
137
- while ((match = tokenRe.exec(text)) !== null) {
138
- if (match.index > lastIndex) {
139
- nodes.push(<span key={`t${idx++}`}>{text.slice(lastIndex, match.index)}</span>)
140
- }
141
- const [, key, str, num, bool, nil] = match
142
- if (key !== undefined) {
143
- nodes.push(<span key={`k${idx++}`} style={{ color: palette.syntaxKey }}>{key}</span>)
134
+ for (const { type, value } of tokenizeJson(text)) {
135
+ if (type === 'key') {
136
+ nodes.push(<span key={`k${idx++}`} style={{ color: palette.syntaxKey }}>{value}</span>)
144
137
  nodes.push(<span key={`c${idx++}`}>:</span>)
145
- } else if (str !== undefined) {
138
+ } else if (type === 'string') {
146
139
  // Unescape the quoted string for the filter value (users expect to filter on
147
140
  // the displayed string, not JSON-escaped bytes).
148
- const inner = str.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
141
+ const inner = value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
149
142
  nodes.push(
150
143
  <FilterableValue key={`s${idx++}`} value={inner} onFilter={onFilterValue} color={palette.syntaxString} palette={palette} />
151
144
  )
152
- } else if (num !== undefined) {
145
+ } else if (type === 'number') {
153
146
  nodes.push(
154
- <FilterableValue key={`n${idx++}`} value={num} onFilter={onFilterValue} color={palette.syntaxNumber} palette={palette} />
147
+ <FilterableValue key={`n${idx++}`} value={value} onFilter={onFilterValue} color={palette.syntaxNumber} palette={palette} />
155
148
  )
156
- } else if (bool !== undefined) {
149
+ } else if (type === 'boolean') {
157
150
  nodes.push(
158
- <FilterableValue key={`b${idx++}`} value={bool} onFilter={onFilterValue} color={palette.syntaxBoolean} palette={palette} />
151
+ <FilterableValue key={`b${idx++}`} value={value} onFilter={onFilterValue} color={palette.syntaxBoolean} palette={palette} />
159
152
  )
160
- } else if (nil !== undefined) {
161
- nodes.push(<span key={`z${idx++}`} style={{ color: palette.syntaxNull }}>{nil}</span>)
153
+ } else if (type === 'null') {
154
+ nodes.push(<span key={`z${idx++}`} style={{ color: palette.syntaxNull }}>{value}</span>)
155
+ } else {
156
+ nodes.push(<span key={`t${idx++}`}>{value}</span>)
162
157
  }
163
- lastIndex = tokenRe.lastIndex
164
- }
165
- if (lastIndex < text.length) {
166
- nodes.push(<span key={`t${idx++}`}>{text.slice(lastIndex)}</span>)
167
158
  }
168
159
  return <>{nodes}</>
169
160
  }
170
161
 
171
162
  function SummaryLine({ obj, level, palette }: { obj: Record<string, unknown>; level: LogLevel; palette: LogPalette }) {
172
163
  const lvl = selectLevelField(obj)?.raw
173
- const msg = obj.msg ?? obj.message
164
+ const pgRecord = selectPostgresRecord(obj)
165
+ const msg = pgRecord?.record.message ?? obj.msg ?? obj.message
174
166
  const rawErr = obj.error ?? obj.err
175
167
  const err = typeof rawErr === 'string'
176
168
  ? rawErr