@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,41 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { highlightJson, tokenizeJson, unescapeJsonStrings } from './log-format'
3
+
4
+ describe('unescapeJsonStrings', () => {
5
+ it('expands whitespace inside strings while leaving escapes outside them untouched', () => {
6
+ expect(unescapeJsonStrings('{"msg":"first\\r\\nsecond\\nthird\\tend"}')).toBe('{"msg":"first\r\nsecond\nthird\tend"}')
7
+ expect(unescapeJsonStrings('outside\\n {"msg":"inside\\n"} outside\\t')).toBe('outside\\n {"msg":"inside\n"} outside\\t')
8
+ })
9
+
10
+ it('keeps escaped quotes and backslashes in their original string', () => {
11
+ expect(unescapeJsonStrings('{"msg":"say \\"hello\\"\\nnext","path":"C:\\\\docs"}')).toBe('{"msg":"say \\"hello\\"\nnext","path":"C:\\\\docs"}')
12
+ expect(unescapeJsonStrings(JSON.stringify({ empty: '', nested: ['a\nb', 'c\td'], number: 42, yes: true }))).toBe('{"empty":"","nested":["a\nb","c\td"],"number":42,"yes":true}')
13
+ })
14
+
15
+ it('stops once at an unterminated string with many escaped quotes', () => {
16
+ const text = '"' + '\\"'.repeat(100_000) + '\\n'
17
+ expect(unescapeJsonStrings(text)).toBe(text)
18
+ })
19
+ })
20
+
21
+ describe('JSON highlighting', () => {
22
+ it('keeps primitive-looking words inside strings and preserves JSON token colors', () => {
23
+ const tokens = tokenizeJson('{ "text" : "true null 123 \\"quoted\\"", "values": [-1.2e+3, true, false, null, ""] }')
24
+ expect(tokens.filter((token) => token.type === 'string').map((token) => token.value)).toEqual(['"true null 123 \\"quoted\\""', '""'])
25
+ expect(tokens.filter((token) => token.type === 'number').map((token) => token.value)).toEqual(['-1.2e+3'])
26
+ expect(highlightJson('{"a":true,"b":null,"n":12}')).toBe('{<span style="color:#7cacf8">"a"</span>:<span style="color:#c678dd">true</span>,<span style="color:#7cacf8">"b"</span>:<span style="color:#808080">null</span>,<span style="color:#7cacf8">"n"</span>:<span style="color:#e5c07b">12</span>}')
27
+ })
28
+
29
+ it('escapes HTML in log values before inserting highlighting markup', () => {
30
+ const html = highlightJson(JSON.stringify({ msg: '<script>alert("x")</script>&' }))
31
+ expect(html).toContain('&lt;script&gt;')
32
+ expect(html).toContain('&lt;/script&gt;&amp;')
33
+ expect(html).not.toContain('<script>')
34
+ })
35
+
36
+ it('leaves a long unterminated string as plain text without rescanning escaped quotes', () => {
37
+ const text = '"' + '\\"'.repeat(100_000)
38
+ expect(tokenizeJson(text)).toEqual([{ type: 'text', value: text }])
39
+ expect(highlightJson(text)).toBe(text)
40
+ })
41
+ })
@@ -363,24 +363,71 @@ export const SYNTAX_COLOR_NUMBER = '#e5c07b'
363
363
  export const SYNTAX_COLOR_BOOLEAN = '#c678dd'
364
364
  export const SYNTAX_COLOR_NULL = '#808080'
365
365
 
366
+ export interface JsonToken {
367
+ type: 'text' | 'key' | 'string' | 'number' | 'boolean' | 'null'
368
+ value: string
369
+ }
370
+
371
+ function jsonStringEnd(text: string, start: number): number {
372
+ let offset = start + 1
373
+ while (offset < text.length) {
374
+ const char = text[offset++]
375
+ if (char === '\\') offset++
376
+ else if (char === '"') return offset
377
+ }
378
+ return -1
379
+ }
380
+
381
+ export function tokenizeJson(text: string): JsonToken[] {
382
+ const tokens: JsonToken[] = []
383
+ const primitiveRe = /(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|\b(true|false)\b|\b(null)\b/y
384
+ let copied = 0
385
+ let offset = 0
386
+ while (offset < text.length) {
387
+ const start = offset
388
+ let type: JsonToken['type']
389
+ let value: string
390
+ if (text[offset] === '"') {
391
+ const end = jsonStringEnd(text, offset)
392
+ if (end < 0) break
393
+ value = text.slice(start, end)
394
+ let colon = end
395
+ while (colon < text.length && /\s/.test(text[colon])) colon++
396
+ type = text[colon] === ':' ? 'key' : 'string'
397
+ offset = type === 'key' ? colon + 1 : end
398
+ } else {
399
+ primitiveRe.lastIndex = offset
400
+ const match = primitiveRe.exec(text)
401
+ if (!match) {
402
+ offset++
403
+ continue
404
+ }
405
+ type = match[1] !== undefined ? 'number' : match[2] !== undefined ? 'boolean' : 'null'
406
+ value = match[0]
407
+ offset = primitiveRe.lastIndex
408
+ }
409
+ if (start > copied) tokens.push({ type: 'text', value: text.slice(copied, start) })
410
+ tokens.push({ type, value })
411
+ copied = offset
412
+ }
413
+ if (copied < text.length) tokens.push({ type: 'text', value: text.slice(copied) })
414
+ return tokens
415
+ }
416
+
366
417
  /**
367
418
  * Syntax-highlight a pretty-printed JSON string for HTML display.
368
419
  * Wraps keys, string values, numbers, booleans, and null in colored spans.
369
420
  * Applies HTML escaping internally — do not pre-escape the input.
370
421
  */
371
422
  export function highlightJson(json: string): string {
372
- const escaped = escapeHtml(json)
373
- // Single-pass tokenizer: matches all JSON tokens at once so content inside strings
374
- // is consumed and can't be re-matched by number/boolean/null patterns.
375
- const tokenRe = /("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|\b(true|false)\b|\b(null)\b/g
376
- return escaped.replace(tokenRe, (match, key, str, num, bool, nil) => {
377
- if (key) return `<span style="color:${SYNTAX_COLOR_KEY}">${key}</span>:`
378
- if (str) return `<span style="color:${SYNTAX_COLOR_STRING}">${str}</span>`
379
- if (num) return `<span style="color:${SYNTAX_COLOR_NUMBER}">${num}</span>`
380
- if (bool) return `<span style="color:${SYNTAX_COLOR_BOOLEAN}">${bool}</span>`
381
- if (nil) return `<span style="color:${SYNTAX_COLOR_NULL}">${nil}</span>`
382
- return match
383
- })
423
+ return tokenizeJson(escapeHtml(json)).map(({ type, value }) => {
424
+ if (type === 'key') return `<span style="color:${SYNTAX_COLOR_KEY}">${value}</span>:`
425
+ if (type === 'string') return `<span style="color:${SYNTAX_COLOR_STRING}">${value}</span>`
426
+ if (type === 'number') return `<span style="color:${SYNTAX_COLOR_NUMBER}">${value}</span>`
427
+ if (type === 'boolean') return `<span style="color:${SYNTAX_COLOR_BOOLEAN}">${value}</span>`
428
+ if (type === 'null') return `<span style="color:${SYNTAX_COLOR_NULL}">${value}</span>`
429
+ return value
430
+ }).join('')
384
431
  }
385
432
 
386
433
  /**
@@ -389,12 +436,21 @@ export function highlightJson(json: string): string {
389
436
  * making stack traces and multi-line messages readable in the expanded view.
390
437
  */
391
438
  export function unescapeJsonStrings(text: string): string {
392
- return text.replace(/"(?:[^"\\]|\\.)*"/g, (match) => {
393
- return match
439
+ let result = ''
440
+ let copied = 0
441
+ let offset = 0
442
+ while (offset < text.length) {
443
+ if (text[offset++] !== '"') continue
444
+ const start = offset - 1
445
+ offset = jsonStringEnd(text, start)
446
+ if (offset < 0) break
447
+ result += text.slice(copied, start) + text.slice(start, offset)
394
448
  .replace(/\\r\\n/g, '\r\n')
395
449
  .replace(/\\n/g, '\n')
396
450
  .replace(/\\t/g, '\t')
397
- })
451
+ copied = offset
452
+ }
453
+ return result + text.slice(copied)
398
454
  }
399
455
 
400
456
  // logfmt detection and parsing
@@ -49,14 +49,22 @@ export function normalizeLevel(raw: unknown): LogLevel | null {
49
49
  return 'unknown'
50
50
  }
51
51
 
52
- // PostgreSQL's error_severity values (every DEBUGn is written as DEBUG);
53
- // anything else under `record` isn't PostgreSQL's.
52
+ // PostgreSQL writes every DEBUGn severity as DEBUG.
54
53
  const POSTGRES_SEVERITIES: Record<string, LogLevel> = {
55
54
  DEBUG: 'debug', LOG: 'info', INFO: 'info', NOTICE: 'info', WARNING: 'warn', ERROR: 'error', FATAL: 'error', PANIC: 'error',
56
55
  }
57
56
 
58
57
  const LEVEL_FIELD_KEYS = ['level', 'lvl', 'severity', 'levelname', 'log.level'] as const
59
58
 
59
+ export function selectPostgresRecord(obj: Record<string, unknown>): { record: Record<string, unknown>; raw: string; level: LogLevel } | null {
60
+ const record = obj.record
61
+ if (obj.logger !== 'postgres' || obj.msg !== 'record' || !record || typeof record !== 'object' || Array.isArray(record)) return null
62
+ const fields = record as Record<string, unknown>
63
+ const raw = fields.error_severity
64
+ const level = typeof raw === 'string' ? POSTGRES_SEVERITIES[raw.trim().toUpperCase()] : undefined
65
+ return typeof raw === 'string' && level ? { record: fields, raw, level } : null
66
+ }
67
+
60
68
  /**
61
69
  * Pick the level field of a structured record. Shared by detection and the
62
70
  * structured-line badge so the badge text and the line's resolved level always
@@ -65,12 +73,8 @@ const LEVEL_FIELD_KEYS = ['level', 'lvl', 'severity', 'levelname', 'log.level']
65
73
  export function selectLevelField(obj: Record<string, unknown>): { raw: unknown; level: LogLevel } | null {
66
74
  // CloudNativePG wraps each PostgreSQL line in an instance-manager record
67
75
  // whose own level is usually info; the database's severity is nested.
68
- const pgRecord = obj.record
69
- if (pgRecord && typeof pgRecord === 'object' && !Array.isArray(pgRecord)) {
70
- const raw = (pgRecord as Record<string, unknown>).error_severity
71
- const level = typeof raw === 'string' ? POSTGRES_SEVERITIES[raw.trim().toUpperCase()] : undefined
72
- if (level) return { raw, level }
73
- }
76
+ const pgRecord = selectPostgresRecord(obj)
77
+ if (pgRecord) return { raw: pgRecord.raw, level: pgRecord.level }
74
78
  for (const key of LEVEL_FIELD_KEYS) {
75
79
  const level = normalizeLevel(obj[key])
76
80
  if (level) return { raw: obj[key], level }
@@ -193,6 +193,63 @@ describe('pluralToKind', () => {
193
193
  })
194
194
 
195
195
  describe('initNavigationMap', () => {
196
+ const coreNode = { group: '', version: 'v1', kind: 'Node', name: 'nodes', namespaced: false, isCrd: false, verbs: ['get'] }
197
+ const customNode = { group: 'collision.example.io', version: 'v1', kind: 'Node', name: 'workerhosts', namespaced: false, isCrd: true, verbs: ['get'] }
198
+
199
+ test.each([
200
+ ['core listed first', [coreNode, customNode]],
201
+ ['custom listed first', [customNode, coreNode]],
202
+ ])('keeps the core plural for a Kind a CRD reuses under another plural (%s)', (_, resources) => {
203
+ initNavigationMap(resources)
204
+ expect(kindToPlural('Node')).toBe('nodes')
205
+ expect(kindToPluralWithGroup('Node', '')).toBe('nodes')
206
+ expect(refToSelectedResource({ kind: 'Node', group: '', name: 'worker' }).kind).toBe('nodes')
207
+ expect(kindToPluralWithGroup('Node', 'collision.example.io')).toBe('workerhosts')
208
+ })
209
+
210
+ test('keeps the core plural when partial discovery omits the core group', () => {
211
+ initNavigationMap([customNode])
212
+ expect(refToSelectedResource({ kind: 'Node', group: '', name: 'worker' }).kind).toBe('nodes')
213
+ expect(kindToPluralWithGroup('Node', 'collision.example.io')).toBe('workerhosts')
214
+ })
215
+
216
+ test.each([
217
+ ['partial discovery', [{ ...customNode, name: 'node' }]],
218
+ ['full discovery', [coreNode, { ...customNode, name: 'node' }]],
219
+ ])('keeps the core plural when a CRD registers the core Kind word as its plural (%s)', (_, resources) => {
220
+ initNavigationMap(resources)
221
+ expect(refToSelectedResource({ kind: 'Node', group: '', name: 'worker' }).kind).toBe('nodes')
222
+ expect(kindToPlural('node')).toBe('node')
223
+ expect(kindToPluralWithGroup('Node', 'collision.example.io')).toBe('node')
224
+ })
225
+
226
+ test('resolves a group-qualified Kind spelled like a core plural to its own resource', () => {
227
+ initNavigationMap([
228
+ { group: '', version: 'v1', kind: 'Endpoints', name: 'endpoints', namespaced: true, isCrd: false, verbs: ['get'] },
229
+ { group: 'custom.example.io', version: 'v1', kind: 'Endpoints', name: 'virtualendpoints', namespaced: true, isCrd: true, verbs: ['get'] },
230
+ ])
231
+ expect(kindToPluralWithGroup('Endpoints', 'custom.example.io')).toBe('virtualendpoints')
232
+ expect(kindToPluralWithGroup('Endpoints', '')).toBe('endpoints')
233
+ })
234
+
235
+ test('keeps a lowercase group plural rather than reading it as another Kind', () => {
236
+ initNavigationMap([
237
+ { group: 'shapes.example.io', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get'] },
238
+ { group: 'shapes.example.io', version: 'v1', kind: 'Widgets', name: 'widgetsets', namespaced: true, isCrd: true, verbs: ['get'] },
239
+ ])
240
+ expect(kindToPluralWithGroup('widgets', 'shapes.example.io')).toBe('widgets')
241
+ expect(kindToPluralWithGroup('Widgets', 'shapes.example.io')).toBe('widgetsets')
242
+ expect(kindToPluralWithGroup('Widget', 'shapes.example.io')).toBe('widgets')
243
+ })
244
+
245
+ test('keeps last-wins for Kinds that only CRDs declare', () => {
246
+ initNavigationMap([
247
+ { group: 'a.example.io', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: [] },
248
+ { group: 'b.example.io', version: 'v1', kind: 'Widget', name: 'gadgets', namespaced: true, isCrd: true, verbs: [] },
249
+ ])
250
+ expect(kindToPlural('Widget')).toBe('gadgets')
251
+ })
252
+
196
253
  test('discovered API resources override heuristic pluralization', () => {
197
254
  // Before init, an unknown CRD would hit the heuristic fallback
198
255
  // After init, it uses the discovered plural name
@@ -305,6 +362,19 @@ describe('refToSelectedResource', () => {
305
362
  })
306
363
  })
307
364
 
365
+ test('resolves AWS IngressClassParams without discovery', () => {
366
+ expect(refToSelectedResource({ kind: 'IngressClassParams', name: 'alb', group: 'elbv2.k8s.aws' })).toEqual({
367
+ kind: 'ingressclassparams',
368
+ name: 'alb',
369
+ namespace: '',
370
+ group: 'elbv2.k8s.aws',
371
+ })
372
+ expect(knownKindForPluralWithGroup('ingressclassparams', 'elbv2.k8s.aws')).toBe('IngressClassParams')
373
+ // Hosts without discovery fold the plural back to a Kind and re-pluralize it.
374
+ expect(pluralToKind('ingressclassparams')).toBe('IngressClassParams')
375
+ expect(kindToPlural(pluralToKind('ingressclassparams'))).toBe('ingressclassparams')
376
+ })
377
+
308
378
  test('normalizes an omitted namespace for cluster-scoped references', () => {
309
379
  expect(refToSelectedResource({ kind: 'NodePool', name: 'spot' })).toEqual({
310
380
  kind: 'nodepools',
@@ -1,6 +1,6 @@
1
1
  import type { SelectedResource, ResourceRef, APIResource } from '../types/core'
2
2
  import { englishPlural, englishSingular, isEnglishPlural } from './pluralize'
3
- import { CORE_RESOURCES } from './api-resources'
3
+ import { CORE_RESOURCES, isBuiltinCoreKind } from './api-resources'
4
4
 
5
5
  /**
6
6
  * Canonical callback type for navigating to a resource.
@@ -41,6 +41,10 @@ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
41
41
  clusterrolebindings: 'ClusterRoleBinding',
42
42
  serviceaccounts: 'ServiceAccount',
43
43
  networkpolicies: 'NetworkPolicy',
44
+ // AWS Load Balancer Controller's IngressClass parameters. The Kind is
45
+ // singular and plural at once, so English rules cannot recover it in hosts
46
+ // that never load discovery.
47
+ ingressclassparams: 'IngressClassParams',
44
48
  }
45
49
 
46
50
  const BUILTIN_GROUP_KIND_TO_PLURAL: Record<string, string> = {
@@ -54,6 +58,7 @@ const BUILTIN_GROUP_PLURAL_TO_KIND: Record<string, string> = {
54
58
  'metrics.k8s.io/pods': 'PodMetrics',
55
59
  'metrics.k8s.io/nodes': 'NodeMetrics',
56
60
  'scheduling.k8s.io/podgroups': 'PodGroup',
61
+ 'elbv2.k8s.aws/ingressclassparams': 'IngressClassParams',
57
62
  }
58
63
 
59
64
  // Dynamic map built from API discovery — populated by initNavigationMap().
@@ -62,6 +67,18 @@ let discoveredPluralToKind: Record<string, string> | null = null
62
67
  let discoveredKindToPlural: Record<string, string> | null = null
63
68
  let discoveredGroupKindToPlural: Record<string, string> | null = null
64
69
  let discoveredGroupPluralToKind: Record<string, string> | null = null
70
+ let discoveredGroupKindNamespaced: Record<string, boolean> | null = null
71
+ let discoveredCRDNames: Record<string, string> | null = null
72
+ const crdDefinitionListeners = new Set<() => void>()
73
+
74
+ export function subscribeCRDDefinitions(listener: () => void): () => void {
75
+ crdDefinitionListeners.add(listener)
76
+ return () => { crdDefinitionListeners.delete(listener) }
77
+ }
78
+
79
+ export function getCRDDefinitionsSnapshot(): Readonly<Record<string, string>> | null {
80
+ return discoveredCRDNames
81
+ }
65
82
 
66
83
  /**
67
84
  * Initialize navigation maps from discovered API resources.
@@ -70,17 +87,35 @@ let discoveredGroupPluralToKind: Record<string, string> | null = null
70
87
  */
71
88
  export function initNavigationMap(resources: APIResource[]) {
72
89
  const p2k: Record<string, string> = { ...BUILTIN_PLURAL_TO_KIND }
73
- const k2p: Record<string, string> = {}
90
+ // Seeded from the built-in table so core protection survives partial
91
+ // discovery (the server forwards whatever groups it could list).
92
+ const k2p: Record<string, string> = Object.fromEntries(
93
+ CORE_RESOURCES.filter(r => r.group === '').map(r => [r.kind.toLowerCase(), r.name]),
94
+ )
74
95
  const gk2p: Record<string, string> = {}
75
96
  const gp2k: Record<string, string> = { ...BUILTIN_GROUP_PLURAL_TO_KIND }
97
+ const scopes: Record<string, boolean> = {}
98
+ const crdNames: Record<string, string> = {}
99
+ const coreKinds = new Set(Object.keys(k2p))
76
100
  for (const r of resources) {
77
101
  const plural = r.name.toLowerCase()
102
+ const kindLower = r.kind.toLowerCase()
103
+ if (r.definitionName) crdNames[`${r.group}/${r.kind}`] = r.definitionName
78
104
  // First-wins on plurals: BUILTIN_PLURAL_TO_KIND seeds canonical core mappings
79
105
  // (e.g. "pods" → "Pod") so a colliding API resource (metrics.k8s.io exposes
80
106
  // "pods" with kind "PodMetrics") cannot hijack the core mapping.
81
107
  if (!(plural in p2k)) p2k[plural] = r.kind
82
- k2p[r.kind.toLowerCase()] = plural
83
- gk2p[`${r.group}/${r.kind.toLowerCase()}`] = plural
108
+ // Kind-only lookups (core links carry an empty group) likewise keep the core
109
+ // plural: a CRD that reuses a core Kind under another plural must not win
110
+ // just because discovery happened to list it later.
111
+ if (r.group === '') {
112
+ k2p[kindLower] = plural
113
+ coreKinds.add(kindLower)
114
+ } else if (!coreKinds.has(kindLower)) {
115
+ k2p[kindLower] = plural
116
+ }
117
+ gk2p[`${r.group}/${kindLower}`] = plural
118
+ scopes[`${r.group}/${kindLower}`] = r.namespaced
84
119
  const groupPlural = `${r.group}/${plural}`
85
120
  if (!(groupPlural in gp2k)) gp2k[groupPlural] = r.kind
86
121
  }
@@ -88,14 +123,20 @@ export function initNavigationMap(resources: APIResource[]) {
88
123
  discoveredKindToPlural = k2p
89
124
  discoveredGroupKindToPlural = gk2p
90
125
  discoveredGroupPluralToKind = gp2k
126
+ discoveredGroupKindNamespaced = scopes
127
+ discoveredCRDNames = crdNames
128
+ crdDefinitionListeners.forEach(listener => listener())
91
129
  }
92
130
 
93
- /** Reset navigation maps to builtin-only state. For testing. */
131
+ /** Reset navigation maps to builtin-only state on cluster changes. */
94
132
  export function resetNavigationMap() {
95
133
  discoveredPluralToKind = null
96
134
  discoveredKindToPlural = null
97
135
  discoveredGroupKindToPlural = null
98
136
  discoveredGroupPluralToKind = null
137
+ discoveredGroupKindNamespaced = null
138
+ discoveredCRDNames = null
139
+ crdDefinitionListeners.forEach(listener => listener())
99
140
  }
100
141
 
101
142
  function getPluralToKind(): Record<string, string> {
@@ -112,6 +153,11 @@ export function kindToPlural(kind: string): string {
112
153
  const kindLower = kind.toLowerCase()
113
154
  const pluralToKindMap = getPluralToKind()
114
155
 
156
+ // An exact core Kind (PascalCase, unlike plural slugs) keeps its core plural
157
+ // even when a CRD registered that word as its own plural.
158
+ const core = kind !== kindLower ? CORE_RESOURCES.find(r => r.group === '' && r.kind === kind) : undefined
159
+ if (core) return core.name
160
+
115
161
  // Already a known plural — return as-is to prevent double-pluralization
116
162
  if (kindLower in pluralToKindMap) return kindLower
117
163
 
@@ -151,10 +197,15 @@ export function kindToPlural(kind: string): string {
151
197
  export function kindToPluralWithGroup(kind: string, group: string): string {
152
198
  if (!group) return kindToPlural(kind)
153
199
  const kindLower = kind.toLowerCase()
154
- const pluralToKindMap = getPluralToKind()
155
- if (kindLower in pluralToKindMap) return kindLower
200
+ // The group's own plural, then its exact Kind, before the group-blind plural
201
+ // shortcut: a custom Kind spelled like a core plural (Endpoints) has its own
202
+ // resource, while a lowercase plural must not be read as another Kind.
203
+ if (kind === kindLower && discoveredGroupPluralToKind?.[`${group}/${kindLower}`]) return kindLower
156
204
  const groupKind = `${group}/${kindLower}`
157
- return discoveredGroupKindToPlural?.[groupKind] ?? BUILTIN_GROUP_KIND_TO_PLURAL[groupKind] ?? kindToPlural(kind)
205
+ const exact = discoveredGroupKindToPlural?.[groupKind] ?? BUILTIN_GROUP_KIND_TO_PLURAL[groupKind]
206
+ if (exact) return exact
207
+ if (kindLower in getPluralToKind()) return kindLower
208
+ return kindToPlural(kind)
158
209
  }
159
210
 
160
211
  /**
@@ -191,6 +242,13 @@ export function knownKindForPluralWithGroup(plural: string, group: string): stri
191
242
  ?? BUILTIN_GROUP_PLURAL_TO_KIND[groupPlural]
192
243
  }
193
244
 
245
+ /** Return a CRD definition only when exact group/kind discovery identifies a CRD. */
246
+ export function customResourceDefinitionRef(apiVersion: string | undefined, kind: string | undefined): ResourceRef | null {
247
+ if (!apiVersion || !kind) return null
248
+ const name = discoveredCRDNames?.[`${apiVersionToGroup(apiVersion)}/${kind}`]
249
+ return name ? { kind: 'CustomResourceDefinition', group: 'apiextensions.k8s.io', namespace: '', name } : null
250
+ }
251
+
194
252
  /**
195
253
  * Convert a ResourceRef (from backend relationships) to a SelectedResource (for navigation).
196
254
  * Handles kind singular→plural conversion.
@@ -220,6 +278,31 @@ export function apiVersionToGroup(apiVersion?: string | null): string {
220
278
  return i === -1 ? '' : apiVersion.slice(0, i)
221
279
  }
222
280
 
281
+ /** A Kubernetes ObjectReference retains its own API identity and namespace.
282
+ * Cluster scope comes from exact group/kind discovery (or known built-ins).
283
+ * Core controllers omit apiVersion for core objects (the EndpointSlice
284
+ * controller writes Pod targets as kind/namespace/name/uid), so an omitted
285
+ * apiVersion means the core group only for a built-in core Kind. Any other
286
+ * missing identity or scope stays non-navigable rather than guessing an API
287
+ * or borrowing the referring object's namespace. UID is not a drawer locator.
288
+ */
289
+ export function objectReferenceToResourceRef(ref: {
290
+ apiVersion?: string
291
+ kind?: string
292
+ namespace?: string
293
+ name?: string
294
+ } | null | undefined): ResourceRef | null {
295
+ if (!ref?.kind || !ref.name) return null
296
+ const kindLower = ref.kind.toLowerCase()
297
+ if (!ref.apiVersion && !isBuiltinCoreKind(ref.kind)) return null
298
+ const group = apiVersionToGroup(ref.apiVersion)
299
+ const key = `${group}/${kindLower}`
300
+ const namespaced = discoveredGroupKindNamespaced?.[key]
301
+ ?? CORE_RESOURCES.find(r => r.group === group && r.kind.toLowerCase() === kindLower)?.namespaced
302
+ if (namespaced !== false && !ref.namespace) return null
303
+ return { kind: ref.kind, group, namespace: namespaced === false ? '' : ref.namespace!, name: ref.name }
304
+ }
305
+
223
306
  // -----------------------------------------------------------------------------
224
307
  // Timeline lane identity. A lane's id includes the API group so two CRDs that
225
308
  // share a kind name across vendors (CAPI `Cluster` in cluster.x-k8s.io vs CNPG
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { getNodeLifecycle } from './node-lifecycle'
5
+ import { getNodeConditions, getNodeStatus } from '../components/resources/resource-utils'
6
+
7
+ const fixture = JSON.parse(readFileSync(fileURLToPath(new URL('../../../../pkg/health/testdata/node_lifecycle.json', import.meta.url)), 'utf8'))
8
+
9
+ describe('node lifecycle shared Go/TS scenarios', () => {
10
+ for (const vector of fixture.vectors) {
11
+ it(vector.name, () => {
12
+ expect(getNodeLifecycle(vector.node, Date.parse(fixture.now))).toEqual({
13
+ label: vector.label, level: vector.level, readinessFailed: vector.readinessFailed, delayed: vector.delayed,
14
+ removing: vector.removing, actor: vector.actor, startedAt: vector.startedAt ? Date.parse(vector.startedAt) : undefined, problems: vector.problems,
15
+ })
16
+ })
17
+ }
18
+ it('handles the actions bar loading before node data arrives', () => {
19
+ expect(getNodeLifecycle(undefined)).toMatchObject({ level: 'unknown', removing: false })
20
+ })
21
+ it('keeps badges and table warning icons calm for expected readiness loss', () => {
22
+ vi.useFakeTimers()
23
+ vi.setSystemTime(new Date(fixture.now))
24
+ try {
25
+ const node = fixture.vectors.find((vector: any) => vector.name === 'autoscaler-Unknown').node
26
+ expect(getNodeStatus(node)).toMatchObject({ text: 'Removing (cluster autoscaler)', level: 'neutral' })
27
+ expect(getNodeConditions(node).problems).toEqual([])
28
+ expect(getNodeConditions(node).readinessLabel).toBe('Readiness unknown during removal')
29
+ const shutdown = fixture.vectors.find((vector: any) => vector.name === 'autoscaler-False').node
30
+ expect(getNodeConditions(shutdown).readinessLabel).toBe('Not ready (expected during removal)')
31
+ expect(getNodeConditions({ status: { conditions: [] } })).toMatchObject({ healthy: false, readinessLabel: 'Readiness unknown' })
32
+ const failed = fixture.vectors.find((vector: any) => vector.name === 'preexisting-failure').node
33
+ expect(getNodeConditions(failed).problems).toContain('NotReady')
34
+ } finally { vi.useRealTimers() }
35
+ })
36
+ })
@@ -0,0 +1,77 @@
1
+ import type { HealthLevel } from '../components/resources/resource-utils'
2
+
3
+ const REMOVAL_ACTORS: Record<string, string> = {
4
+ ToBeDeletedByClusterAutoscaler: 'cluster autoscaler',
5
+ 'karpenter.sh/disrupted': 'Karpenter',
6
+ }
7
+
8
+ const NETWORK_UNAVAILABLE_GRACE_MS = 2 * 60_000
9
+
10
+ export const NODE_REMOVAL_WARNING_MS = 10 * 60_000
11
+ export const NODE_REMOVAL_CRITICAL_MS = 30 * 60_000
12
+
13
+ export interface NodeLifecycleState {
14
+ label: string
15
+ level: HealthLevel
16
+ removing: boolean
17
+ actor?: string
18
+ startedAt?: number
19
+ readinessFailed: boolean
20
+ delayed: boolean
21
+ problems: string[]
22
+ }
23
+
24
+ export type NodeLifecycleObservation = Omit<NodeLifecycleState, 'startedAt' | 'problems'> & {
25
+ startedAt?: string
26
+ problems?: string[]
27
+ }
28
+
29
+ export function getNodeLifecycle(node: any, now = Date.now()): NodeLifecycleState {
30
+ const ready = node?.status?.conditions?.find((condition: any) => condition.type === 'Ready')
31
+ const createdAt = Date.parse(node?.metadata?.creationTimestamp)
32
+ const validTime = (timestamp: number) => Number.isFinite(timestamp) && timestamp > 0 && timestamp <= now && (!Number.isFinite(createdAt) || timestamp >= createdAt) ? timestamp : undefined
33
+ let startedAt = validTime(Date.parse(node?.metadata?.deletionTimestamp))
34
+ let removing = Boolean(node?.metadata?.deletionTimestamp)
35
+ let actor: string | undefined
36
+ let candidate = false
37
+ for (const taint of node?.spec?.taints ?? []) {
38
+ if (taint.key === 'DeletionCandidateOfClusterAutoscaler' && taint.effect === 'PreferNoSchedule') candidate = true
39
+ if (taint.effect !== 'NoSchedule') continue
40
+ const source = REMOVAL_ACTORS[taint.key]
41
+ if (!source) continue
42
+ removing = true
43
+ actor = source
44
+ if (taint.key === 'ToBeDeletedByClusterAutoscaler' && /^\d+$/.test(taint.value ?? '')) {
45
+ const markerTime = validTime(Number(taint.value) * 1000)
46
+ if (markerTime !== undefined && (startedAt === undefined || markerTime < startedAt)) startedAt = markerTime
47
+ }
48
+ }
49
+ const readyTime = Date.parse(ready?.lastTransitionTime)
50
+ const readinessFailed = Boolean(ready && ready.status !== 'True' && !(removing && startedAt !== undefined && Number.isFinite(readyTime) && readyTime >= startedAt && readyTime <= now))
51
+ const delayed = removing && startedAt !== undefined && now - startedAt >= NODE_REMOVAL_WARNING_MS
52
+ let label = 'Unknown'
53
+ let level: HealthLevel = 'unknown'
54
+ if (removing) {
55
+ label = actor ? `Removing (${actor})` : 'Removing'
56
+ level = readinessFailed || (delayed && now - startedAt! >= NODE_REMOVAL_CRITICAL_MS) ? 'unhealthy' : delayed ? 'degraded' : 'neutral'
57
+ } else if (readinessFailed) {
58
+ label = 'NotReady'
59
+ level = 'unhealthy'
60
+ } else if (ready?.status === 'True') {
61
+ label = node?.spec?.unschedulable ? 'Cordoned' : candidate ? 'Scale-down candidate' : 'Ready'
62
+ level = node?.spec?.unschedulable ? 'degraded' : candidate ? 'neutral' : 'healthy'
63
+ }
64
+ const problems: string[] = []
65
+ if (removing && readinessFailed) label += ' · NotReady'
66
+ const problemLabels: Record<string, string> = { MemoryPressure: 'Memory pressure', DiskPressure: 'Disk pressure', PIDPressure: 'PID pressure', NetworkUnavailable: 'Network unavailable' }
67
+ for (const condition of node?.status?.conditions ?? []) {
68
+ if (condition.status !== 'True' || !problemLabels[condition.type]) continue
69
+ const conditionTime = Date.parse(condition.lastTransitionTime)
70
+ if (condition.type === 'NetworkUnavailable' && Number.isFinite(conditionTime) && conditionTime <= now && now - conditionTime < NETWORK_UNAVAILABLE_GRACE_MS) continue
71
+ problems.push(condition.type)
72
+ label += ` · ${problemLabels[condition.type]}`
73
+ if (condition.type !== 'NetworkUnavailable') level = 'unhealthy'
74
+ else if (level !== 'unhealthy') level = 'degraded'
75
+ }
76
+ return { label, level, removing, actor, startedAt, readinessFailed, delayed, problems }
77
+ }
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { initNavigationMap, objectReferenceToResourceRef, resetNavigationMap } from './navigation'
3
+
4
+ afterEach(resetNavigationMap)
5
+
6
+ describe('objectReferenceToResourceRef', () => {
7
+ it('keeps the reference namespace and drops it for cluster-scoped core kinds', () => {
8
+ expect(objectReferenceToResourceRef({ apiVersion: 'v1', kind: 'Pod', namespace: 'other', name: 'backend' }))
9
+ .toEqual({ kind: 'Pod', group: '', namespace: 'other', name: 'backend' })
10
+ expect(objectReferenceToResourceRef({ apiVersion: 'v1', kind: 'Node', namespace: 'irrelevant', name: 'worker' }))
11
+ .toEqual({ kind: 'Node', group: '', namespace: '', name: 'worker' })
12
+ })
13
+
14
+ it('resolves a core Kind without apiVersion, the shape the EndpointSlice controller writes', () => {
15
+ expect(objectReferenceToResourceRef({ kind: 'Pod', namespace: 'team', name: 'backend' }))
16
+ .toEqual({ kind: 'Pod', group: '', namespace: 'team', name: 'backend' })
17
+ expect(objectReferenceToResourceRef({ kind: 'Endpoints', namespace: 'team', name: 'backend' }))
18
+ .toEqual({ kind: 'Endpoints', group: '', namespace: 'team', name: 'backend' })
19
+ expect(objectReferenceToResourceRef({ kind: 'Deployment', namespace: 'team', name: 'backend' })).toBeNull()
20
+ expect(objectReferenceToResourceRef({ kind: 'Services', namespace: 'team', name: 'backend' })).toBeNull()
21
+ expect(objectReferenceToResourceRef({ kind: 'Nodes', namespace: 'team', name: 'worker' })).toBeNull()
22
+ })
23
+
24
+ it('uses exact discovered scope for colliding API kinds', () => {
25
+ initNavigationMap([
26
+ { group: 'cluster.example.io', version: 'v1', kind: 'Node', name: 'nodes', namespaced: false, isCrd: true, verbs: ['get'] },
27
+ { group: 'team.example.io', version: 'v1', kind: 'Node', name: 'nodes', namespaced: true, isCrd: true, verbs: ['get'] },
28
+ ])
29
+ expect(objectReferenceToResourceRef({ apiVersion: 'cluster.example.io/v1', kind: 'Node', name: 'worker' }))
30
+ .toEqual({ kind: 'Node', group: 'cluster.example.io', namespace: '', name: 'worker' })
31
+ expect(objectReferenceToResourceRef({ apiVersion: 'team.example.io/v1', kind: 'Node', namespace: 'team', name: 'worker' }))
32
+ .toEqual({ kind: 'Node', group: 'team.example.io', namespace: 'team', name: 'worker' })
33
+ expect(objectReferenceToResourceRef({ apiVersion: 'team.example.io/v1', kind: 'Node', name: 'worker' })).toBeNull()
34
+ })
35
+
36
+ it('does not invent a custom API group, a namespace, or an unknown cluster scope', () => {
37
+ expect(objectReferenceToResourceRef({ kind: 'Widget', namespace: 'team', name: 'backend' })).toBeNull()
38
+ expect(objectReferenceToResourceRef({ apiVersion: 'v1', kind: 'Pod', name: 'backend' })).toBeNull()
39
+ expect(objectReferenceToResourceRef({ apiVersion: 'unknown.example.io/v1', kind: 'Node', name: 'worker' })).toBeNull()
40
+ expect(objectReferenceToResourceRef({ apiVersion: 'unknown.example.io/v1', kind: 'Widget', namespace: 'team', name: 'backend' }))
41
+ .toEqual({ kind: 'Widget', group: 'unknown.example.io', namespace: 'team', name: 'backend' })
42
+ })
43
+ })