@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
@@ -106,3 +106,31 @@ it('keeps pod/template evidence in expanded neutral context with navigable witne
106
106
  expect(expanded).not.toContain('confidence')
107
107
  expect(expanded).not.toContain('Caused by')
108
108
  })
109
+
110
+ it('wraps a compact Pod scheduling cause separately and retains the full message in details', () => {
111
+ const raw = '0/2 nodes are available: 2 Too many pods. preemption: 0/2 nodes are available: 2 No preemption victims found for incoming pod.'
112
+ const pod: Issue = { ...issue, kind: 'Pod', reason: 'Unschedulable', cause: raw, message: raw }
113
+ const html = renderToString(<IssueRow issue={pod} compact open onToggle={() => {}} />)
114
+ expect(html).toContain('break-words text-xs text-theme-text-secondary')
115
+ expect(html).toContain('both nodes have reached their Pod limit')
116
+ expect(html).toContain(raw)
117
+ const producerMessage = '2 node(s) insufficient pods (0/2 nodes available)'
118
+ const reported = renderToString(<IssueRow issue={{ ...pod, cause: undefined, message: producerMessage }} compact open onToggle={() => {}} />)
119
+ expect(reported).toContain('both nodes have reached their Pod limit')
120
+ expect(reported).toContain(producerMessage)
121
+ const regular = renderToString(<IssueRow issue={pod} open={false} onToggle={() => {}} />)
122
+ expect(regular).not.toContain('both nodes have reached their Pod limit')
123
+ })
124
+
125
+ it.each(['Cluster', 'Deployment'])('wraps compact scheduling causes aggregated under %s', (kind) => {
126
+ const raw = '2 node(s) insufficient pods (0/2 nodes available)'
127
+ const aggregated: Issue = { ...issue, kind, reason: 'Unschedulable', cause: raw, message: raw }
128
+ const collapsed = renderToString(<IssueRow issue={aggregated} compact open={false} onToggle={() => {}} />)
129
+ expect(collapsed).toContain('<div class="break-words text-xs text-theme-text-secondary">both nodes have reached their Pod limit</div>')
130
+ expect(collapsed).not.toContain(raw)
131
+ const expanded = renderToString(<IssueRow issue={aggregated} compact open onToggle={() => {}} />)
132
+ expect(expanded).toContain(raw)
133
+ const regular = renderToString(<IssueRow issue={aggregated} open={false} onToggle={() => {}} />)
134
+ expect(regular).toContain('Unschedulable')
135
+ expect(regular).not.toContain('both nodes have reached their Pod limit')
136
+ })
@@ -8,7 +8,9 @@ export function ResourceIssuesSection({
8
8
  issues,
9
9
  onResourceClick,
10
10
  subjectResource,
11
+ compact,
11
12
  }: {
13
+ compact?: boolean
12
14
  issues: Issue[] | undefined
13
15
  /** When provided, related resources in a causal link become clickable. */
14
16
  onResourceClick?: (ref: IssueResourceRef) => void
@@ -36,6 +38,7 @@ export function ResourceIssuesSection({
36
38
  <IssueRow
37
39
  key={key}
38
40
  issue={issue}
41
+ compact={compact}
39
42
  open={openId === key}
40
43
  onToggle={() => setOpenId((cur) => (cur === key ? null : key))}
41
44
  onResourceClick={onResourceClick}
@@ -16,7 +16,7 @@ export {
16
16
  issueSortAnchor,
17
17
  compareIssueSortAnchors,
18
18
  } from './types';
19
- export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
19
+ export type { Issue, IssueConditionalCleanup, IssueFinalizerRemoval, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
20
20
  export {
21
21
  ISSUE_SEVERITY_LABEL,
22
22
  ISSUE_SEVERITY_BADGE_CLASS,
@@ -24,5 +24,7 @@ export {
24
24
  ISSUE_SEVERITY_FILL_CLASS,
25
25
  ISSUE_SEVERITY_RAIL_CLASS,
26
26
  categoryLabel,
27
+ issueTitle,
28
+ issueReasonTitle,
27
29
  groupLabel,
28
30
  } from './severity';
@@ -1,7 +1,7 @@
1
1
  import { isDeploymentLikeWorkloadKind } from '../../types';
2
2
  import type { Issue } from './types';
3
3
 
4
- export type IssueTimingDisplayKind = 'creation' | 'regression';
4
+ export type IssueTimingDisplayKind = 'creation' | 'regression' | 'deletion';
5
5
 
6
6
  export interface IssueTimingDisplay {
7
7
  kind: IssueTimingDisplayKind;
@@ -77,6 +77,17 @@ export function issueTiming(issue: Issue): IssueTimingDisplay | null {
77
77
  const grouped = (coverage?.known ?? 0) + (coverage?.unknown ?? 0) > 1;
78
78
  const uncertaintyMeta = onsetUncertaintyMeta(issue);
79
79
  const uncertaintyTooltip = onsetUncertaintyTooltip(issue);
80
+ // A stuck deletion is timed from its deletionTimestamp. The creation-vs-healthy
81
+ // classification only says whether the object was deleted soon after it was
82
+ // created, which is not what the age beside this chip measures.
83
+ if (issue.issue_timing_basis === 'deletion' && issue.issue_timing) {
84
+ return {
85
+ kind: 'deletion',
86
+ chip: 'since deletion',
87
+ meta: 'pending since deletion was requested',
88
+ tooltip: 'Timed from when deletion was requested; the object is still present.',
89
+ };
90
+ }
80
91
  switch (issue.issue_timing) {
81
92
  case 'started_after_resource_was_healthy': {
82
93
  if (issue.issue_timing_basis === 'owner_condition') {
@@ -113,9 +124,9 @@ export function issueTiming(issue: Issue): IssueTimingDisplay | null {
113
124
  }
114
125
  return {
115
126
  kind: 'regression',
116
- chip: 'after healthy',
127
+ chip: issue.kind === 'Node' && issue.reason === 'NotReady' ? 'NotReady' : 'after healthy',
117
128
  meta: 'failing evidence followed a healthy period',
118
- tooltip: 'A healthy period preceded this failing evidence.',
129
+ tooltip: issue.kind === 'Node' && issue.reason === 'NotReady' ? 'The Ready condition stopped reporting healthy at this time.' : 'A healthy period preceded this failing evidence.',
119
130
  };
120
131
  }
121
132
  case 'started_at_resource_creation': {
@@ -2,7 +2,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest'
2
2
  import { createElement } from 'react'
3
3
  import { renderToString } from 'react-dom/server'
4
4
  import { compareIssues, issueSortAnchor, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
5
- import { categoryLabel, groupBadgeClass, groupLabel } from './severity'
5
+ import { categoryLabel, groupBadgeClass, groupLabel, issueTitle } from './severity'
6
6
  import { IssueRow } from './IssuesView'
7
7
  import { issueFirstSeenTitle, issueResourceCreatedTitle, issueTiming } from './issue-timing'
8
8
 
@@ -83,6 +83,8 @@ describe('category/group label fallbacks', () => {
83
83
  it('returns the mapped label, else humanizes (server-added category needs no frontend deploy)', () => {
84
84
  expect(categoryLabel('crashloop')).toBe('Crash loop')
85
85
  expect(categoryLabel('some_new_future_category')).toBe('Some new future category')
86
+ expect(issueTitle({ category: 'backup_failed', reason: 'CNPGWALArchivingFailing' })).toBe('WAL archiving failing')
87
+ expect(issueTitle({ category: 'backup_failed', reason: 'BackupFailed' })).toBe('Backup failed')
86
88
  })
87
89
  it('humanizes an unmapped group', () => {
88
90
  expect(groupLabel('runtime')).toBe('Runtime')
@@ -137,9 +139,36 @@ describe('IssueRow', () => {
137
139
  expect(html).toContain('after healthy')
138
140
  })
139
141
 
142
+ it('labels a stuck deletion by how long deletion has been pending', () => {
143
+ vi.useFakeTimers()
144
+ vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
145
+
146
+ const html = renderToString(createElement(IssueRow, {
147
+ issue: mk({
148
+ id: 'terminating',
149
+ kind: 'VMAgent',
150
+ group: 'operator.victoriametrics.com',
151
+ category: 'termination_stuck',
152
+ category_group: 'control_plane',
153
+ reason: 'Terminating stuck',
154
+ first_seen: '2026-06-30T11:30:00Z',
155
+ issue_timing: 'started_at_resource_creation',
156
+ issue_timing_basis: 'deletion',
157
+ }),
158
+ open: true,
159
+ onToggle: () => undefined,
160
+ }))
161
+
162
+ expect(html).toContain('30m')
163
+ expect(html).toContain('since deletion')
164
+ expect(html).toContain('pending since deletion was requested')
165
+ expect(html).not.toContain('since creation')
166
+ expect(html).not.toContain('first reconciliation')
167
+ })
168
+
140
169
  it('uses the category-group chip class in the queue row', () => {
141
170
  const html = renderToString(createElement(IssueRow, {
142
- issue: mk({ category_group: 'control_plane' }),
171
+ issue: mk({ category_group: 'control_plane', kind: 'Node' }),
143
172
  open: false,
144
173
  onToggle: () => undefined,
145
174
  }))
@@ -394,6 +423,25 @@ describe('issueTiming', () => {
394
423
  })
395
424
  })
396
425
 
426
+ it('times stuck deletions from the deletion request, however soon after creation it came', () => {
427
+ for (const timing of ['started_at_resource_creation', 'started_after_resource_was_healthy'] as const) {
428
+ for (const kind of ['Pod', 'VMAgent']) {
429
+ const display = issueTiming(mk({
430
+ kind,
431
+ category: 'termination_stuck',
432
+ issue_timing: timing,
433
+ issue_timing_basis: 'deletion',
434
+ }))
435
+ expect(display).toMatchObject({
436
+ kind: 'deletion',
437
+ chip: 'since deletion',
438
+ meta: 'pending since deletion was requested',
439
+ })
440
+ expect(`${display?.chip} ${display?.meta} ${display?.tooltip}`).not.toMatch(/creation|healthy|deploy/i)
441
+ }
442
+ }
443
+ })
444
+
397
445
  it('describes direct after-healthy timing without coupling it to first_seen', () => {
398
446
  const display = issueTiming(mk({
399
447
  first_seen: '2026-06-30T10:00:00Z',
@@ -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)}