@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,59 @@
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 { WorkloadLogsViewer } from './WorkloadLogsViewer'
7
+
8
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
9
+
10
+ class FakeEventSource extends EventTarget {
11
+ static last: FakeEventSource | undefined
12
+ constructor(readonly url: string) {
13
+ super()
14
+ FakeEventSource.last = this
15
+ }
16
+ close() {}
17
+ emit(type: string, data: unknown) {
18
+ this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) }))
19
+ }
20
+ }
21
+
22
+ let root: Root
23
+ let element: HTMLDivElement
24
+ beforeEach(() => {
25
+ vi.stubGlobal('EventSource', FakeEventSource)
26
+ element = document.createElement('div')
27
+ document.body.appendChild(element)
28
+ root = createRoot(element)
29
+ })
30
+ afterEach(async () => {
31
+ await act(async () => root.unmount())
32
+ element.remove()
33
+ vi.unstubAllGlobals()
34
+ })
35
+
36
+ it('clears the unreadable-sources notice when the stream ends', async () => {
37
+ const notice = '1 source could not be read: web-0/app: Kubernetes did not return this container\'s logs'
38
+ await act(async () =>
39
+ root.render(
40
+ <ToastProvider>
41
+ <WorkloadLogsViewer
42
+ name="web"
43
+ fetchAll={async () => ({ pods: [], logs: [] })}
44
+ createStream={() => new EventSource('/stream')}
45
+ autoStream
46
+ />
47
+ </ToastProvider>,
48
+ ),
49
+ )
50
+ const source = FakeEventSource.last
51
+ if (!source) throw new Error('stream was not opened')
52
+
53
+ await act(async () => source.emit('connected', { pods: [{ name: 'web-0', containers: ['app'] }] }))
54
+ await act(async () => source.emit('notice', { notice }))
55
+ expect(element.textContent).toContain(notice)
56
+
57
+ await act(async () => source.emit('end', { reason: 'no pods found' }))
58
+ expect(element.textContent).not.toContain(notice)
59
+ })
@@ -0,0 +1,65 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+ import { WorkloadLogsViewer, type WorkloadLogsResult } from './WorkloadLogsViewer'
6
+ vi.mock('../ui/Toast', () => ({ useToast: () => ({ showError: vi.fn(), showSuccess: vi.fn() }) }))
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+ it('uses a host empty state and disables source controls until a source is read', async () => {
9
+ const host = document.createElement('div'); const root = createRoot(host)
10
+ let result: WorkloadLogsResult = { pods: [], logs: [], emptyMessage: 'No instance Pods yet' }
11
+ const stream = vi.fn()
12
+ const fetchAll = vi.fn(async () => result)
13
+ await act(async () => root.render(<WorkloadLogsViewer name="analytics" fetchAll={fetchAll} createStream={stream} disableSourceControlsWithoutSource emptySourceState={<><p>The first instance cannot start.</p><a href="?tab=overview">Overview’s problem</a></>} />))
14
+ expect(host.textContent).toContain('The first instance cannot start')
15
+ expect(host.querySelector('a')?.getAttribute('href')).toBe('?tab=overview')
16
+ expect(host.querySelector('fieldset')?.disabled).toBe(true)
17
+ expect([...host.querySelectorAll('select')].every((el) => el.matches(':disabled'))).toBe(true)
18
+ const streamButton = [...host.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === 'Stream')!
19
+ expect(streamButton.disabled).toBe(true)
20
+ act(() => streamButton.click())
21
+ expect(stream).not.toHaveBeenCalled()
22
+ result = { pods: [{ name: 'analytics-1', containers: ['postgres'], ready: true }], logs: [] }
23
+ await act(async () => [...host.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.querySelector('.lucide-rotate-ccw'))!.click())
24
+ expect(host.querySelector('fieldset')?.disabled).toBe(false)
25
+ expect(streamButton.disabled).toBe(false)
26
+ expect(host.textContent).not.toContain('The first instance cannot start')
27
+ await act(async () => root.unmount())
28
+ })
29
+
30
+ it('keeps streaming available for hosts that wait for Pods to appear', async () => {
31
+ const host = document.createElement('div'); const root = createRoot(host)
32
+ await act(async () => root.render(<WorkloadLogsViewer name="pending-job" fetchAll={async () => ({ pods: [], logs: [] })} createStream={vi.fn()} />))
33
+ expect([...host.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === 'Stream')?.disabled).toBe(false)
34
+ expect(host.querySelector('fieldset')?.disabled).toBe(false)
35
+ await act(async () => root.unmount())
36
+ })
37
+
38
+
39
+ it('keeps the all-container choice available by default', async () => {
40
+ const host = document.createElement('div'); const root = createRoot(host)
41
+ const fetchAll = vi.fn(async () => ({ pods: [{ name: 'analytics-1', containers: ['postgres', 'plugin-barman-cloud'], ready: true }], logs: [] }))
42
+ await act(async () => root.render(<WorkloadLogsViewer name="analytics" fetchAll={fetchAll} />))
43
+ const select = host.querySelector<HTMLSelectElement>('select')!
44
+ expect([...select.options].map((option) => option.value)).toEqual(['', 'postgres', 'plugin-barman-cloud'])
45
+ expect(select.options[0].text).toBe('All containers')
46
+ expect(fetchAll).toHaveBeenCalledWith(expect.objectContaining({ container: undefined }))
47
+ await act(async () => root.unmount())
48
+ })
49
+
50
+ it('offers only named containers when the backend cannot read all containers', async () => {
51
+ const host = document.createElement('div'); const root = createRoot(host)
52
+ const fetchAll = vi.fn(async (_params: unknown) => ({ pods: [{ name: 'analytics-1', containers: ['postgres', 'plugin-barman-cloud'], ready: true }], logs: [] }))
53
+ await act(async () => root.render(<WorkloadLogsViewer name="analytics" fetchAll={fetchAll} initialContainer="postgres" allowAllContainers={false} />))
54
+ const select = host.querySelector<HTMLSelectElement>('select')!
55
+ expect([...select.options].map((option) => option.value)).toEqual(['postgres', 'plugin-barman-cloud'])
56
+ expect(host.textContent).not.toContain('All containers')
57
+ expect(select.value).toBe('postgres')
58
+ expect(fetchAll).toHaveBeenCalledWith(expect.objectContaining({ container: 'postgres' }))
59
+ await act(async () => {
60
+ select.value = 'plugin-barman-cloud'
61
+ select.dispatchEvent(new Event('change', { bubbles: true }))
62
+ })
63
+ expect(fetchAll).toHaveBeenLastCalledWith(expect.objectContaining({ container: 'plugin-barman-cloud' }))
64
+ await act(async () => root.unmount())
65
+ })
@@ -1,5 +1,5 @@
1
- import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
2
- import { Filter, ChevronDown } from 'lucide-react'
1
+ import { useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from 'react'
2
+ import { Filter, ChevronDown, AlertTriangle } from 'lucide-react'
3
3
  import { parseLogRange } from '../../utils/log-format'
4
4
  import { triggerDownload } from '../../utils/download'
5
5
  import { useLogBuffer } from './useLogBuffer'
@@ -10,6 +10,7 @@ import type { LogExportPayload } from '../../utils/log-export'
10
10
  import type { LogPalette } from './log-palette'
11
11
  import type { WorkloadPodInfo } from '../../types'
12
12
  import { useToast } from '../ui/Toast'
13
+ import { toneTextClass } from '../ui/status-tone'
13
14
 
14
15
  export interface WorkloadRawLog {
15
16
  pod: string
@@ -38,6 +39,8 @@ export interface WorkloadLogsResult {
38
39
  }
39
40
 
40
41
  export interface WorkloadLogsViewerProps {
42
+ /** Disable streaming and the source filters while no log source exists. Off by default: streams can wait for Pods to appear. */
43
+ disableSourceControlsWithoutSource?: boolean
41
44
  /** Workload name — used for the download filename */
42
45
  name: string
43
46
  /**
@@ -65,14 +68,20 @@ export interface WorkloadLogsViewerProps {
65
68
  autoStream?: boolean
66
69
  /** Pods selected when the pod list first loads; all pods when empty or none match. */
67
70
  initialPods?: string[]
71
+ /** Container selected on mount; all containers when unset. */
72
+ initialContainer?: string
73
+ /** Whether the backend supports reading all containers. Defaults to true; set initialContainer when false. */
74
+ allowAllContainers?: boolean
75
+ /** Rendered only when the loaded source list is empty. */
76
+ emptySourceState?: ReactNode
68
77
  }
69
78
 
70
- export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, defaultDark, autoStream = false, initialPods }: WorkloadLogsViewerProps) {
79
+ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, defaultDark, autoStream = false, initialPods, initialContainer, allowAllContainers = true, emptySourceState, disableSourceControlsWithoutSource = false }: WorkloadLogsViewerProps) {
71
80
  const initialSelection = (names: string[]) => {
72
81
  const wanted = names.filter((n) => initialPods?.includes(n))
73
82
  return new Set(wanted.length > 0 ? wanted : names)
74
83
  }
75
- const [selectedContainer, setSelectedContainer] = useState<string>('')
84
+ const [selectedContainer, setSelectedContainer] = useState<string>(initialContainer ?? '')
76
85
  const [pods, setPods] = useState<WorkloadPodInfo[]>([])
77
86
  const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
78
87
  const [isLoading, setIsLoading] = useState(false)
@@ -92,6 +101,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
92
101
  const { isStreaming, streamError, connecting, startStreaming, stopStreaming } = useLogStream()
93
102
 
94
103
  const willAutoStream = autoStream && !!createStream
104
+ const sourceUnavailable = disableSourceControlsWithoutSource && pods.length === 0
95
105
  // null sentinel so the initial selectedContainer ('' = all) still arms once.
96
106
  const autoStartedForRef = useRef<string | null>(null)
97
107
  const userStoppedRef = useRef(false)
@@ -246,7 +256,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
246
256
  // while new pod logs start flowing in
247
257
  }
248
258
  },
259
+ onNotice: (data: unknown) => {
260
+ const notice = typeof data === 'object' && data !== null && 'notice' in data ? data.notice : ''
261
+ setNotice(typeof notice === 'string' ? notice : '')
262
+ },
249
263
  onEnd: (data: any) => {
264
+ setNotice('')
250
265
  if (data?.emptyMessage) setEmptyMessage(data.emptyMessage)
251
266
  if (data?.command) setEmptyCommand(data.command)
252
267
  },
@@ -312,10 +327,11 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
312
327
  const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
313
328
  <>
314
329
  {/* Pod filter */}
330
+ <fieldset disabled={sourceUnavailable} className="flex items-center gap-2 disabled:opacity-50">
315
331
  <div className="relative">
316
332
  <button
317
333
  onClick={() => setShowPodFilter(v => !v)}
318
- className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors ${
334
+ className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded whitespace-nowrap transition-colors ${
319
335
  showPodFilter ? palette.toolbarActive : `${palette.elevatedBg} ${palette.textSecondary} ${palette.hoverBg}`
320
336
  }`}
321
337
  >
@@ -371,7 +387,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
371
387
  containers={allContainers}
372
388
  value={selectedContainer}
373
389
  onChange={setSelectedContainer}
374
- includeAll
390
+ includeAll={allowAllContainers}
375
391
  isDark={isDark}
376
392
  />
377
393
 
@@ -381,8 +397,10 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
381
397
  lineOptions={[50, 100, 500, 1000]}
382
398
  tooltip="How many logs to load per pod — by line count or time range"
383
399
  isDark={isDark}
384
- disabled={isStreaming}
400
+ disabled={isStreaming || sourceUnavailable}
401
+ disabledReason={sourceUnavailable ? 'No log source is available yet' : undefined}
385
402
  />
403
+ </fieldset>
386
404
  </>
387
405
  )
388
406
 
@@ -392,8 +410,23 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
392
410
 
393
411
  return (
394
412
  <div className="flex h-full min-h-0 flex-col">
395
- {notice && <div role="status" className="shrink-0 border-b border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">{notice}</div>}
396
- {capturedAt && <div className="shrink-0 border-b border-theme-border px-3 py-1 text-xs text-theme-text-secondary">Snapshot captured {new Date(capturedAt).toLocaleTimeString()}</div>}
413
+ {/* One status line. A notice says what the snapshot could not show, so it
414
+ stays marked; the capture time alone is quiet. */}
415
+ {(notice || capturedAt) && (
416
+ <div
417
+ role="status"
418
+ className={`flex shrink-0 flex-wrap items-baseline gap-x-2 border-b border-theme-border px-3 py-1 text-xs ${notice ? 'bg-theme-elevated text-theme-text-secondary' : 'text-theme-text-tertiary'}`}
419
+ >
420
+ {capturedAt && <span className="whitespace-nowrap">Snapshot {new Date(capturedAt).toLocaleTimeString()}</span>}
421
+ {capturedAt && notice && <span aria-hidden>·</span>}
422
+ {notice && (
423
+ <span className="inline-flex min-w-0 items-baseline gap-1.5">
424
+ <AlertTriangle className={`h-3 w-3 shrink-0 self-center ${toneTextClass('degraded')}`} aria-hidden />
425
+ <span>{notice}</span>
426
+ </span>
427
+ )}
428
+ </div>
429
+ )}
397
430
  {(fetchError || streamError) && entries.length > 0 && <div role="alert" className="shrink-0 border-b border-theme-border bg-theme-surface px-3 py-2 text-xs text-theme-text-secondary">{fetchError || streamError} · Previously loaded logs remain below.</div>}
398
431
  <div className="min-h-0 flex-1"><LogCore
399
432
  entries={filteredEntries}
@@ -401,13 +434,14 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
401
434
  isLoading={isLoading || isConnecting}
402
435
  isStreaming={isStreaming}
403
436
  onStartStream={createStream ? handleStartStreaming : undefined}
437
+ sourceUnavailable={sourceUnavailable}
404
438
  onStopStream={handleStopStreaming}
405
439
  onRefresh={loadLogs}
406
440
  onDownload={downloadLogs}
407
441
  onClear={clear}
408
442
  toolbarExtra={renderToolbarExtra}
409
443
  showPodName
410
- emptyMessage={emptyMessage || (pods.length === 0 ? 'No pods found' : 'No logs available')}
444
+ emptyMessage={pods.length === 0 && emptySourceState ? emptySourceState : emptyMessage || (pods.length === 0 ? 'No pods found' : 'No logs available')}
411
445
  emptyCommand={emptyCommand}
412
446
  errorMessage={entries.length === 0 ? fetchError || streamError : null}
413
447
  forceDark={forceDark}
@@ -14,4 +14,12 @@ describe('detectLogLevel with CloudNativePG records', () => {
14
14
  expect(detectLogLevel(JSON.stringify({ level: 'error', msg: 'request failed', record: { error_severity: 20 } }))).toBe('error')
15
15
  expect(detectLogLevel(JSON.stringify({ level: 'error', msg: 'x', record: { error_severity: 'minor' } }))).toBe('error')
16
16
  })
17
+
18
+ it.each([
19
+ { msg: 'record' },
20
+ { logger: 'worker', msg: 'record' },
21
+ { logger: 'postgres', msg: 'wrapper message' },
22
+ ])('does not override an unrelated workload severity: %j', (fields) => {
23
+ expect(detectLogLevel(JSON.stringify({ level: 'info', ...fields, record: { error_severity: 'FATAL', message: 'unrelated nested text' } }))).toBe('info')
24
+ })
17
25
  })
@@ -10,10 +10,23 @@ export interface LogStreamHandlers {
10
10
  onPodAdded?: (data: unknown) => void
11
11
  /** Called when pods are terminated during streaming (workload logs only) */
12
12
  onPodRemoved?: (data: unknown) => void
13
+ /** Called when the set of sources the stream cannot read changes (workload logs only) */
14
+ onNotice?: (data: unknown) => void
13
15
  /** Called when the server ends the stream cleanly */
14
16
  onEnd?: (data: unknown) => void
15
17
  }
16
18
 
19
+ function errorCodeOf(event: Event): string | null {
20
+ if (!(event instanceof MessageEvent) || typeof event.data !== 'string') return null
21
+ try {
22
+ const data: unknown = JSON.parse(event.data)
23
+ if (typeof data === 'object' && data !== null && 'error_code' in data && typeof data.error_code === 'string') return data.error_code
24
+ } catch {
25
+ // Not JSON: no code.
26
+ }
27
+ return null
28
+ }
29
+
17
30
  /**
18
31
  * Manages an SSE log stream: EventSource lifecycle, isStreaming state, cleanup.
19
32
  * Callers provide a factory function that creates the EventSource with current params.
@@ -22,6 +35,8 @@ export function useLogStream() {
22
35
  const [isStreaming, setIsStreaming] = useState(false)
23
36
  // Set when the connection fails (and not on a clean end — see endedRef).
24
37
  const [streamError, setStreamError] = useState<string | null>(null)
38
+ // The server's error_code for that failure, when it sent one.
39
+ const [streamErrorCode, setStreamErrorCode] = useState<string | null>(null)
25
40
  // True from a start attempt until the stream first settles (connected / end /
26
41
  // error / stop). Lets callers show a connecting spinner that won't reappear
27
42
  // after a clean end. Starts true so an auto-stream viewer paints the spinner
@@ -38,6 +53,7 @@ export function useLogStream() {
38
53
  setIsStreaming(false)
39
54
  setConnecting(false)
40
55
  setStreamError(null)
56
+ setStreamErrorCode(null)
41
57
  }, [])
42
58
 
43
59
  const startStreaming = useCallback((
@@ -48,6 +64,7 @@ export function useLogStream() {
48
64
  eventSourceRef.current?.close()
49
65
  endedRef.current = false
50
66
  setStreamError(null)
67
+ setStreamErrorCode(null)
51
68
  setConnecting(true)
52
69
  const es = create()
53
70
  // Ignore events from a superseded source: closing/replacing an EventSource
@@ -92,6 +109,15 @@ export function useLogStream() {
92
109
  }
93
110
  })
94
111
 
112
+ es.addEventListener('notice', (event) => {
113
+ if (!isCurrent()) return
114
+ if (handlers.onNotice) {
115
+ try { handlers.onNotice(JSON.parse((event as MessageEvent).data)) } catch (e) {
116
+ console.error('Failed to parse notice event:', e)
117
+ }
118
+ }
119
+ })
120
+
95
121
  es.addEventListener('end', (event) => {
96
122
  if (!isCurrent()) return
97
123
  endedRef.current = true
@@ -113,6 +139,7 @@ export function useLogStream() {
113
139
  // 'end'; that's not a failure, so don't log it or surface it.
114
140
  if (endedRef.current) return
115
141
  setStreamError(handleSSEError(event, errorContext, () => {}))
142
+ setStreamErrorCode(errorCodeOf(event))
116
143
  })
117
144
 
118
145
  eventSourceRef.current = es
@@ -121,5 +148,5 @@ export function useLogStream() {
121
148
  // Cleanup on unmount
122
149
  useEffect(() => () => { eventSourceRef.current?.close() }, [])
123
150
 
124
- return { isStreaming, streamError, connecting, startStreaming, stopStreaming }
151
+ return { isStreaming, streamError, streamErrorCode, connecting, startStreaming, stopStreaming }
125
152
  }
@@ -0,0 +1 @@
1
+ export * from './problems'
@@ -0,0 +1,63 @@
1
+ import { describe, expect, expectTypeOf, it } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { ProblemMeta, WorkspaceProblemCallout, ProblemList, problemOriginLabel, type WorkspaceProblem } from './problems'
4
+
5
+ const details = (kind: string) => ({
6
+ id: 'p',
7
+ tone: 'degraded' as const,
8
+ category: 'availability',
9
+ title: 'Something needs a look',
10
+ subject: { kind, group: 'example.io', namespace: 'ns', name: 'child-1' },
11
+ })
12
+ const problem = (kind: string): Extract<WorkspaceProblem, { source: 'issue' }> => ({ ...details(kind), source: 'issue' })
13
+
14
+ describe('ProblemMeta', () => {
15
+ it('names the subject only when it is not the workspace root kind', () => {
16
+ expect(renderToStaticMarkup(<ProblemMeta problem={problem('Backup')} rootKind="Cluster" />)).toContain('Backup')
17
+ expect(renderToStaticMarkup(<ProblemMeta problem={problem('Cluster')} rootKind="Cluster" />)).not.toContain('child-1')
18
+ })
19
+ })
20
+
21
+ describe('problemOriginLabel', () => {
22
+ it('says who measured a measurement, and never calls an issue generic', () => {
23
+ expect(problemOriginLabel({ ...details('Cluster'), source: 'measurement', measuredBy: 'Prometheus' }).label).toBe('Measured by Prometheus')
24
+ expect(problemOriginLabel(problem('Cluster')).label).toBe('Detected by Radar')
25
+ })
26
+ })
27
+
28
+ it('keeps original scheduler evidence behind a closed disclosure in callouts and lists', () => {
29
+ const p = { ...problem('Pod'), detail: 'both nodes have reached their Pod limit', rawDetailLabel: 'Scheduler message', rawDetail: '0/2 nodes are available: 2 Too many pods.' }
30
+ for (const node of [<WorkspaceProblemCallout problem={p} rootKind="Cluster" />, <ProblemList problems={[p]} rootKind="Cluster" />]) {
31
+ const html = renderToStaticMarkup(node)
32
+ expect(html).toContain(p.detail)
33
+ expect(html).toContain(p.rawDetail)
34
+ expect(html).toContain('Scheduler message')
35
+ expect(html).toContain('aria-expanded="false"')
36
+ }
37
+ expect(renderToStaticMarkup(<WorkspaceProblemCallout problem={problem('Pod')} rootKind="Cluster" />)).not.toContain('Scheduler message')
38
+ })
39
+
40
+ it('uses a generic label for raw evidence from another controller', () => {
41
+ const p = { ...problem('Deployment'), rawDetail: 'Progress deadline exceeded.' }
42
+ expect(renderToStaticMarkup(<WorkspaceProblemCallout problem={p} rootKind="Application" />)).toContain('Original evidence')
43
+ })
44
+
45
+ it('keeps source-specific provenance fields exclusive in the public type', () => {
46
+ type Details = ReturnType<typeof details>
47
+ expectTypeOf<Details & { source: 'issue'; measuredBy: string }>().not.toMatchTypeOf<WorkspaceProblem>()
48
+ expectTypeOf<Details & { source: 'measurement'; origin: { label: string } }>().not.toMatchTypeOf<WorkspaceProblem>()
49
+ expectTypeOf<Details & { source: 'audit'; origin: { label: string } }>().not.toMatchTypeOf<WorkspaceProblem>()
50
+ })
51
+
52
+ it('preserves alert and unknown tones in callouts and compact lists', () => {
53
+ for (const tone of ['alert', 'unknown'] as const) {
54
+ const p = { ...problem('Pod'), tone }
55
+ const callout = renderToStaticMarkup(<WorkspaceProblemCallout problem={p} rootKind="Cluster" />)
56
+ expect(callout).toContain(`status-${tone}`)
57
+ const list = renderToStaticMarkup(<ProblemList problems={[p]} rootKind="Cluster" />)
58
+ expect(list).toContain(tone === 'alert' ? 'bg-orange-500' : 'bg-slate-400')
59
+ expect(list).toContain(tone === 'alert' ? 'text-orange-600' : 'text-theme-text-tertiary')
60
+ }
61
+ type HealthyProblem = Omit<ReturnType<typeof details>, 'tone'> & { source: 'issue'; tone: 'healthy' }
62
+ expectTypeOf<HealthyProblem>().not.toMatchTypeOf<WorkspaceProblem>()
63
+ })
@@ -0,0 +1,165 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import type { HealthLevel } from '../resources/resource-utils'
4
+ import type { ResourceRef } from '../../types/core'
5
+ import { healthToSeverity } from '../../utils/badge-colors'
6
+ import { StatusDot, toneTextClass } from '../ui/status-tone'
7
+ import { Tooltip } from '../ui/Tooltip'
8
+ import { AlertBanner } from '../ui/drawer-components'
9
+ import { FoldSection } from '../ui/FoldSection'
10
+ import { RefLink, type NavigateToRef } from '../ui/RefLink'
11
+
12
+ /** Where a problem's evidence comes from, in user terms. */
13
+ export interface ProblemOrigin {
14
+ label: string
15
+ /** The exact field or condition, shown on hover. */
16
+ detail?: string
17
+ }
18
+
19
+ /**
20
+ * Something about a workspace object that needs a look. `C` is the
21
+ * integration's own category set.
22
+ */
23
+ export interface ProblemDetails<C extends string = string> {
24
+ /** Stable identity for keys. */
25
+ id: string
26
+ category: C
27
+ title: string
28
+ detail?: string
29
+ rawDetail?: string
30
+ rawDetailLabel?: string
31
+ /** The object the evidence is about: the workspace's root object or one of its children. */
32
+ subject: ResourceRef & { group: string }
33
+ /** A shorter headline for tight places (a fleet cell); `title` stays the precise one. */
34
+ shortTitle?: string
35
+ /** Other objects the same problem is about, e.g. earlier runs that failed the same way. */
36
+ alsoAbout?: Pick<ResourceRef, 'kind' | 'name'>[]
37
+ }
38
+
39
+ export type ProblemProvenance =
40
+ | { source: 'issue'; origin?: ProblemOrigin; measuredBy?: never; sourceDetail?: never }
41
+ | { source: 'audit'; sourceDetail?: string; origin?: never; measuredBy?: never }
42
+ | { source: 'measurement'; measuredBy?: string; sourceDetail?: string; origin?: never }
43
+
44
+ export type WorkspaceProblem<C extends string = string> = ProblemDetails<C> & ProblemProvenance & { tone: Exclude<HealthLevel, 'healthy'> }
45
+
46
+ /** A problem's provenance label: where its evidence comes from, never a generic "Radar issue". */
47
+ export function problemOriginLabel(problem: WorkspaceProblem): ProblemOrigin {
48
+ switch (problem.source) {
49
+ case 'audit':
50
+ return { label: 'Best-practice check', detail: problem.sourceDetail }
51
+ case 'measurement':
52
+ return { label: problem.measuredBy ? `Measured by ${problem.measuredBy}` : 'Measured', detail: problem.sourceDetail }
53
+ }
54
+ return problem.origin ?? { label: 'Detected by Radar' }
55
+ }
56
+
57
+ /**
58
+ * How a host opens a problem on its Issues page. Supplied by context so every
59
+ * summary and drawer gets the link without threading a prop through each.
60
+ */
61
+ export const OpenIssueContext = createContext<((problem: WorkspaceProblem) => void) | undefined>(undefined)
62
+
63
+ export function ProblemMeta({
64
+ problem,
65
+ rootKind,
66
+ onNavigate,
67
+ subjectIsSelf,
68
+ children,
69
+ }: {
70
+ problem: WorkspaceProblem
71
+ /** The workspace's root kind: a problem about another kind names its subject. */
72
+ rootKind: string
73
+ onNavigate?: NavigateToRef
74
+ subjectIsSelf?: boolean
75
+ children?: ReactNode
76
+ }) {
77
+ const openIssue = useContext(OpenIssueContext)
78
+ const origin = problemOriginLabel(problem)
79
+ const aboutChild = !subjectIsSelf && problem.subject.kind !== rootKind
80
+ return (
81
+ <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
82
+ {aboutChild && (
83
+ <span>
84
+ {problem.subject.kind}{' '}
85
+ <RefLink refTo={problem.subject} onNavigate={onNavigate} mono />
86
+ {problem.alsoAbout && problem.alsoAbout.length > 0 && ' '}
87
+ {problem.alsoAbout && problem.alsoAbout.length > 0 && (
88
+ <Tooltip
89
+ content={
90
+ <ul>
91
+ {problem.alsoAbout.map((o) => (
92
+ <li key={`${o.kind}/${o.name}`} className="font-mono">
93
+ {o.kind} {o.name}
94
+ </li>
95
+ ))}
96
+ </ul>
97
+ }
98
+ >
99
+ <span>and {problem.alsoAbout.length} more</span>
100
+ </Tooltip>
101
+ )}
102
+ </span>
103
+ )}
104
+ <Tooltip content={origin.detail} disabled={!origin.detail}>
105
+ <span>{origin.label}</span>
106
+ </Tooltip>
107
+ {openIssue && problem.source === 'issue' && (
108
+ <button type="button" onClick={() => openIssue(problem)} className="whitespace-nowrap text-accent-text hover:underline">
109
+ See in Issues →
110
+ </button>
111
+ )}
112
+ {children}
113
+ </div>
114
+ )
115
+ }
116
+
117
+ export function WorkspaceProblemCallout({
118
+ problem,
119
+ rootKind,
120
+ more,
121
+ onNavigate,
122
+ action,
123
+ subjectIsSelf,
124
+ }: {
125
+ problem: WorkspaceProblem
126
+ rootKind: string
127
+ more?: ReactNode
128
+ onNavigate?: NavigateToRef
129
+ action?: ReactNode
130
+ /** The callout sits on the subject's own page, so linking to it would loop. */
131
+ subjectIsSelf?: boolean
132
+ }) {
133
+ const severity = healthToSeverity(problem.tone)
134
+ const variant = severity === 'neutral' ? 'unknown' : severity
135
+ return (
136
+ <AlertBanner variant={variant} title={problem.title} message={problem.detail}>
137
+ {problem.rawDetail && <FoldSection title={problem.rawDetailLabel ?? "Original evidence"} summary="" attention={false}><div className="break-words text-xs text-theme-text-secondary">{problem.rawDetail}</div></FoldSection>}
138
+ <ProblemMeta problem={problem} rootKind={rootKind} onNavigate={onNavigate} subjectIsSelf={subjectIsSelf}>
139
+ {action}
140
+ {more}
141
+ </ProblemMeta>
142
+ </AlertBanner>
143
+ )
144
+ }
145
+
146
+ /** The problems a callout does not show, as a compact list with the callout's tone, title and source. */
147
+ export function ProblemList({ problems, rootKind, onNavigate }: { problems: WorkspaceProblem[]; rootKind: string; onNavigate?: NavigateToRef }) {
148
+ return (
149
+ <ul className="space-y-2">
150
+ {problems.map((p) => (
151
+ <li key={p.id} className="flex items-start gap-2 text-sm">
152
+ <span className="mt-1.5 shrink-0">
153
+ <StatusDot tone={p.tone} size="sm" />
154
+ </span>
155
+ <div className="min-w-0">
156
+ <div className={clsx('font-medium break-words', toneTextClass(p.tone))}>{p.title}</div>
157
+ {p.detail && <div className="text-xs text-theme-text-secondary break-words">{p.detail}</div>}
158
+ {p.rawDetail && <FoldSection title={p.rawDetailLabel ?? "Original evidence"} summary="" attention={false}><div className="break-words text-xs text-theme-text-secondary">{p.rawDetail}</div></FoldSection>}
159
+ <ProblemMeta problem={p} rootKind={rootKind} onNavigate={onNavigate} />
160
+ </div>
161
+ </li>
162
+ ))}
163
+ </ul>
164
+ )
165
+ }