@skyhook-io/k8s-ui 1.17.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
  67. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  68. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
  69. package/src/components/gitops/tree/layout.test.ts +111 -0
  70. package/src/components/gitops/tree/layout.ts +128 -0
  71. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  72. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  73. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  74. package/src/components/issues/IssuesView.tsx +40 -5
  75. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  76. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  77. package/src/components/issues/index.ts +3 -1
  78. package/src/components/issues/issue-timing.ts +12 -1
  79. package/src/components/issues/issues.test.ts +49 -1
  80. package/src/components/issues/severity.ts +21 -0
  81. package/src/components/issues/types.ts +23 -0
  82. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  83. package/src/components/logs/LogCore.tsx +25 -17
  84. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  85. package/src/components/logs/LogsViewer.test.tsx +291 -0
  86. package/src/components/logs/LogsViewer.tsx +72 -11
  87. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  88. package/src/components/logs/StructuredLogLine.tsx +22 -30
  89. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  90. package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
  91. package/src/components/logs/useLogBuffer.test.ts +8 -0
  92. package/src/components/logs/useLogStream.ts +17 -1
  93. package/src/components/problems/index.ts +1 -0
  94. package/src/components/problems/problems.test.tsx +63 -0
  95. package/src/components/problems/problems.tsx +165 -0
  96. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  97. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  98. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  99. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  100. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  101. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  102. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  103. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  104. package/src/components/resources/ResourcesView.tsx +302 -109
  105. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  106. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  107. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  108. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  109. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  110. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  111. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  112. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  113. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  114. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  115. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  116. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  117. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  118. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  119. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  120. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  121. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  122. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  123. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  124. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  125. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  126. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  127. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  128. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  129. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  130. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  131. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  132. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  133. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  134. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  135. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  136. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  137. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  138. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  139. package/src/components/resources/renderers/PodRenderer.tsx +41 -29
  140. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  141. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  142. package/src/components/resources/resource-utils-capi.ts +1 -1
  143. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  144. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  145. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  146. package/src/components/resources/resource-utils-contour.ts +16 -0
  147. package/src/components/resources/resource-utils-istio.ts +12 -8
  148. package/src/components/resources/resource-utils-knative.ts +18 -0
  149. package/src/components/resources/resource-utils.ts +82 -6
  150. package/src/components/resources/rules-column-width.test.ts +1 -1
  151. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  152. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  153. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  154. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  155. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  156. package/src/components/shared/DetailShell.test.tsx +80 -0
  157. package/src/components/shared/DetailShell.tsx +85 -10
  158. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  159. package/src/components/shared/EditableYamlView.tsx +1 -1
  160. package/src/components/shared/ResourceActionsBar.tsx +11 -4
  161. package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
  162. package/src/components/shared/index.ts +2 -0
  163. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  164. package/src/components/timeline/timeline-filters.ts +1 -0
  165. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  166. package/src/components/topology/K8sResourceNode.tsx +44 -2
  167. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  168. package/src/components/topology/TopologyGraph.tsx +3 -71
  169. package/src/components/topology/TopologySearch.tsx +1 -0
  170. package/src/components/topology/internet-groups.test.ts +35 -0
  171. package/src/components/topology/internet-groups.ts +55 -0
  172. package/src/components/topology/layout.ts +1 -1
  173. package/src/components/ui/Badge.tsx +1 -0
  174. package/src/components/ui/FoldSection.tsx +68 -0
  175. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  176. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  177. package/src/components/ui/RefLink.tsx +20 -0
  178. package/src/components/ui/RowActionMenu.tsx +8 -3
  179. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  180. package/src/components/ui/drawer-components.test.tsx +110 -1
  181. package/src/components/ui/drawer-components.tsx +62 -9
  182. package/src/components/ui/index.ts +3 -1
  183. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  184. package/src/components/ui/monacoRuntime.ts +14 -19
  185. package/src/components/ui/status-tone.test.ts +10 -1
  186. package/src/components/ui/status-tone.tsx +29 -0
  187. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  188. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  189. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  190. package/src/components/workload/WorkloadView.tsx +180 -64
  191. package/src/index.ts +14 -1
  192. package/src/monaco-deep.d.ts +19 -12
  193. package/src/theme/components.css +13 -0
  194. package/src/types/capacity.ts +11 -2
  195. package/src/types/core.ts +29 -2
  196. package/src/types/gitops-flux-status.test.ts +32 -1
  197. package/src/types/gitops-insights.ts +19 -2
  198. package/src/types/gitops-tree.ts +10 -0
  199. package/src/types/gitops.ts +26 -6
  200. package/src/utils/api-resources.ts +7 -0
  201. package/src/utils/application-history.ts +1 -0
  202. package/src/utils/cnpg-backup.test.ts +26 -0
  203. package/src/utils/cnpg-backup.ts +70 -0
  204. package/src/utils/format.test.ts +9 -0
  205. package/src/utils/format.ts +4 -0
  206. package/src/utils/gitops-owner.ts +3 -2
  207. package/src/utils/grant.test.ts +19 -0
  208. package/src/utils/grant.ts +35 -0
  209. package/src/utils/index.ts +4 -0
  210. package/src/utils/k8s-errors.ts +7 -7
  211. package/src/utils/log-format.test.ts +41 -0
  212. package/src/utils/log-format.ts +71 -15
  213. package/src/utils/log-level.ts +12 -8
  214. package/src/utils/navigation.test.ts +57 -0
  215. package/src/utils/navigation.ts +86 -8
  216. package/src/utils/object-reference-navigation.test.ts +43 -0
  217. package/src/utils/resource-hierarchy.test.ts +37 -0
  218. package/src/utils/resource-hierarchy.ts +15 -7
  219. package/src/utils/resource-icons.ts +2 -0
  220. package/src/utils/skeleton-yaml.test.ts +21 -0
  221. package/src/utils/skeleton-yaml.ts +16 -0
  222. package/src/utils/url-path.test.ts +35 -0
  223. package/src/utils/url-path.ts +21 -0
  224. package/src/utils/validators.test.ts +4 -13
  225. package/src/utils/validators.ts +5 -19
  226. package/src/utils/view-transition.test.ts +35 -1
  227. package/src/utils/view-transition.ts +16 -1
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { GitOpsResourceTree, GitOpsTreeEdge, GitOpsTreeNode } from '../../../types'
4
+ import { rankTree, renderedEdge, shortcutEdges, upstreamClosure, upstreamEdges } from './layout'
5
+
6
+ function node(id: string, kind = 'Deployment', role: GitOpsTreeNode['role'] = 'declared'): GitOpsTreeNode {
7
+ return { id, ref: { kind, namespace: 'flux-system', name: id }, role, tool: 'fluxcd' }
8
+ }
9
+
10
+ function tree(nodes: GitOpsTreeNode[], edges: GitOpsTreeEdge[]): GitOpsResourceTree {
11
+ return { root: nodes[0], nodes, edges }
12
+ }
13
+
14
+ describe('rankTree', () => {
15
+ it('draws a release\'s generated chart and repository upstream, repository first', () => {
16
+ // The release names the repository directly and through its chart; the
17
+ // longer path wins so the chain reads repository → chart → release.
18
+ const t = tree(
19
+ [node('release', 'HelmRelease', 'root'), node('chart', 'HelmChart'), node('repo', 'HelmRepository'), node('deploy')],
20
+ [
21
+ { source: 'release', target: 'repo', type: 'source' },
22
+ { source: 'release', target: 'chart', type: 'source' },
23
+ { source: 'chart', target: 'repo', type: 'source' },
24
+ { source: 'release', target: 'deploy', type: 'owns' },
25
+ ],
26
+ )
27
+ const rank = rankTree(t)
28
+ expect(rank.get('release')).toBe(0)
29
+ expect(rank.get('chart')).toBe(-1)
30
+ expect(rank.get('repo')).toBe(-2)
31
+ expect(rank.get('deploy')).toBe(1)
32
+ })
33
+
34
+ it('keeps managed resources downstream even when they are also a source (bootstrap)', () => {
35
+ const t = tree(
36
+ [node('flux-system', 'Kustomization', 'root'), node('repo', 'GitRepository'), node('controller')],
37
+ [
38
+ { source: 'flux-system', target: 'repo', type: 'owns' },
39
+ { source: 'flux-system', target: 'repo', type: 'source' },
40
+ { source: 'flux-system', target: 'controller', type: 'owns' },
41
+ ],
42
+ )
43
+ expect(upstreamEdges(t)).toEqual([])
44
+ expect(rankTree(t).get('repo')).toBe(1)
45
+ })
46
+
47
+ it('places dependsOn targets upstream and keeps the owned subtree depth', () => {
48
+ const t = tree(
49
+ [node('overlay', 'Kustomization', 'root'), node('base', 'Kustomization'), node('repo', 'GitRepository'), node('deploy'), node('rs', 'ReplicaSet', 'generated'), node('pod', 'Pod', 'generated')],
50
+ [
51
+ { source: 'overlay', target: 'repo', type: 'source' },
52
+ { source: 'overlay', target: 'base', type: 'dependsOn' },
53
+ { source: 'overlay', target: 'deploy', type: 'owns' },
54
+ { source: 'deploy', target: 'rs', type: 'owns' },
55
+ { source: 'rs', target: 'pod', type: 'owns' },
56
+ ],
57
+ )
58
+ const rank = rankTree(t)
59
+ expect([rank.get('repo'), rank.get('base')]).toEqual([-1, -1])
60
+ expect([rank.get('deploy'), rank.get('rs'), rank.get('pod')]).toEqual([1, 2, 3])
61
+ })
62
+
63
+ it('terminates on a dependency cycle and never moves the root', () => {
64
+ const t = tree(
65
+ [node('a', 'Kustomization', 'root'), node('b', 'Kustomization'), node('c', 'Kustomization')],
66
+ [
67
+ { source: 'a', target: 'b', type: 'dependsOn' },
68
+ { source: 'b', target: 'c', type: 'dependsOn' },
69
+ { source: 'c', target: 'b', type: 'dependsOn' },
70
+ { source: 'c', target: 'a', type: 'dependsOn' },
71
+ ],
72
+ )
73
+ const rank = rankTree(t)
74
+ expect(rank.get('a')).toBe(0)
75
+ expect(rank.get('b')).toBeLessThan(0)
76
+ expect(rank.get('c')).toBeLessThan(0)
77
+ })
78
+ })
79
+
80
+ describe('renderedEdge', () => {
81
+ it('points a dependency edge into its consumer and leaves ownership alone', () => {
82
+ const upstream = new Set(['repo'])
83
+ expect(renderedEdge({ source: 'ks', target: 'repo', type: 'source' }, upstream)).toEqual({ source: 'repo', target: 'ks' })
84
+ expect(renderedEdge({ source: 'ks', target: 'deploy', type: 'owns' }, upstream)).toEqual({ source: 'ks', target: 'deploy' })
85
+ })
86
+ })
87
+
88
+ describe('upstreamClosure', () => {
89
+ it('follows the chart hop so every preset keeps the whole source chain', () => {
90
+ const t = tree(
91
+ [node('release', 'HelmRelease', 'root'), node('chart', 'HelmChart'), node('repo', 'HelmRepository'), node('deploy')],
92
+ [
93
+ { source: 'release', target: 'chart', type: 'source' },
94
+ { source: 'chart', target: 'repo', type: 'source' },
95
+ { source: 'release', target: 'deploy', type: 'owns' },
96
+ ],
97
+ )
98
+ expect([...upstreamClosure(t)].sort()).toEqual(['chart', 'repo'])
99
+ })
100
+ })
101
+
102
+ describe('shortcutEdges', () => {
103
+ it('drops the direct release → repository edge only when the chart path covers it', () => {
104
+ const direct: GitOpsTreeEdge = { source: 'release', target: 'repo', type: 'source' }
105
+ const viaChart: GitOpsTreeEdge[] = [{ source: 'release', target: 'chart', type: 'source' }, { source: 'chart', target: 'repo', type: 'source' }]
106
+ const nodes = [node('release', 'HelmRelease', 'root'), node('chart', 'HelmChart'), node('repo', 'HelmRepository')]
107
+ expect([...shortcutEdges(tree(nodes, [direct, ...viaChart]))]).toEqual([direct])
108
+ // Before the chart is generated the direct edge is the only path.
109
+ expect(shortcutEdges(tree(nodes, [direct])).size).toBe(0)
110
+ })
111
+ })
@@ -0,0 +1,128 @@
1
+ import type { GitOpsResourceTree, GitOpsTreeEdge } from '../../../types'
2
+
3
+ // Flux source, chart and dependsOn edges travel consumer → dependency on the
4
+ // wire. A dependency feeds its consumer, so the graph draws it upstream (left
5
+ // of the consumer) with the arrow pointing into the consumer, as Flux's own
6
+ // tooling does. An object the root also manages (a bootstrap Kustomization
7
+ // that owns its GitRepository) keeps its place among the managed resources:
8
+ // ownership wins.
9
+ export function isUpstreamEdgeType(type: GitOpsTreeEdge['type']): boolean {
10
+ return type === 'source' || type === 'dependsOn'
11
+ }
12
+
13
+ export function upstreamEdges(tree: Pick<GitOpsResourceTree, 'edges'>): GitOpsTreeEdge[] {
14
+ const owned = new Set(tree.edges.filter(edge => edge.type === 'owns').map(edge => edge.target))
15
+ return tree.edges.filter(edge => isUpstreamEdgeType(edge.type) && !owned.has(edge.target))
16
+ }
17
+
18
+ // rankTree places the root at 0, managed resources at positive ranks by
19
+ // ownership depth, and dependencies at negative ranks by their longest
20
+ // dependency path from the root, so a repository that a release names
21
+ // directly and through its generated chart still sits left of the chart.
22
+ // Cycles are bounded: a rank never moves past the node count.
23
+ export function rankTree(tree: GitOpsResourceTree): Map<string, number> {
24
+ const ids = new Set(tree.nodes.map(node => node.id))
25
+ ids.add(tree.root.id)
26
+ const limit = ids.size
27
+ const upstream = upstreamEdges(tree).filter(edge => ids.has(edge.source) && ids.has(edge.target) && edge.target !== tree.root.id)
28
+
29
+ const depth = new Map<string, number>([[tree.root.id, 0]])
30
+ for (let pass = 0; pass < limit; pass++) {
31
+ let changed = false
32
+ for (const edge of upstream) {
33
+ const from = depth.get(edge.source)
34
+ if (from === undefined) continue
35
+ const next = Math.min(from + 1, limit)
36
+ if ((depth.get(edge.target) ?? 0) < next) {
37
+ depth.set(edge.target, next)
38
+ changed = true
39
+ }
40
+ }
41
+ if (!changed) break
42
+ }
43
+
44
+ const rank = new Map<string, number>([[tree.root.id, 0]])
45
+ for (const [id, d] of depth) {
46
+ if (id !== tree.root.id) rank.set(id, -d)
47
+ }
48
+
49
+ const children = new Map<string, string[]>()
50
+ for (const edge of tree.edges) {
51
+ if (!ids.has(edge.source) || !ids.has(edge.target)) continue
52
+ if (rank.has(edge.target) && rank.get(edge.target)! < 0) continue
53
+ if (rank.has(edge.source) && rank.get(edge.source)! < 0) continue
54
+ children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
55
+ }
56
+ const downstream = new Map<string, number>()
57
+ const queue = [{ id: tree.root.id, rank: 0 }]
58
+ let iterations = 0
59
+ const maxIterations = limit * 8 + 64
60
+ while (queue.length > 0 && ++iterations <= maxIterations) {
61
+ const current = queue.shift()!
62
+ const previous = downstream.get(current.id)
63
+ if (previous !== undefined && previous >= current.rank) continue
64
+ downstream.set(current.id, current.rank)
65
+ for (const child of children.get(current.id) ?? []) {
66
+ queue.push({ id: child, rank: current.rank + 1 })
67
+ }
68
+ }
69
+ for (const [id, r] of downstream) {
70
+ if (id !== tree.root.id) rank.set(id, r)
71
+ }
72
+
73
+ const incoming = new Set(tree.edges.map(edge => edge.target))
74
+ for (const id of ids) {
75
+ if (!rank.has(id)) rank.set(id, incoming.has(id) ? 1 : 0)
76
+ }
77
+ return rank
78
+ }
79
+
80
+ // renderedEdge returns the edge as drawn: dependency edges point from the
81
+ // dependency into its consumer.
82
+ export function renderedEdge(edge: GitOpsTreeEdge, upstreamTargets: Set<string>): { source: string; target: string } {
83
+ if (isUpstreamEdgeType(edge.type) && upstreamTargets.has(edge.target)) {
84
+ return { source: edge.target, target: edge.source }
85
+ }
86
+ return { source: edge.source, target: edge.target }
87
+ }
88
+
89
+ // upstreamClosure returns every dependency reachable from the root, for
90
+ // presets that must keep "where does this come from" in view.
91
+ export function upstreamClosure(tree: GitOpsResourceTree): Set<string> {
92
+ const edges = upstreamEdges(tree)
93
+ const out = new Set<string>()
94
+ const queue = [tree.root.id]
95
+ while (queue.length > 0) {
96
+ const id = queue.shift()!
97
+ for (const edge of edges) {
98
+ if (edge.source !== id || out.has(edge.target) || edge.target === tree.root.id) continue
99
+ out.add(edge.target)
100
+ queue.push(edge.target)
101
+ }
102
+ }
103
+ return out
104
+ }
105
+
106
+ // shortcutEdges returns the dependency edges that a longer dependency path
107
+ // already covers: an inline HelmRelease names its repository directly and
108
+ // through its generated chart, and drawing both puts the shortcut behind the
109
+ // chart card. The data keeps the edge; the drawing doesn't need it.
110
+ export function shortcutEdges(tree: Pick<GitOpsResourceTree, 'edges'>): Set<GitOpsTreeEdge> {
111
+ const edges = upstreamEdges(tree)
112
+ const out = new Set<GitOpsTreeEdge>()
113
+ for (const edge of edges) {
114
+ const seen = new Set<string>([edge.source])
115
+ const queue = edges.filter(e => e !== edge && e.source === edge.source).map(e => e.target)
116
+ while (queue.length > 0) {
117
+ const id = queue.shift()!
118
+ if (id === edge.target) {
119
+ out.add(edge)
120
+ break
121
+ }
122
+ if (seen.has(id)) continue
123
+ seen.add(id)
124
+ for (const next of edges) if (next.source === id) queue.push(next.target)
125
+ }
126
+ }
127
+ return out
128
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildTreeFacets } from '../GitOpsGraphFilterRail'
3
+ import { matchesGitOpsTreeFilters } from './tree-helpers'
4
+ import type { GitOpsResourceTree, GitOpsTreeNode } from '../../../types'
5
+
6
+ const node = (group: string): GitOpsTreeNode => ({ id: group, ref: { kind: 'Service', group, name: 'same', namespace: 'team' }, role: 'generated', tool: 'fluxcd' })
7
+
8
+ describe('GitOps kind filter contract', () => {
9
+ it('uses bare Kubernetes kinds across API groups', () => {
10
+ const nodes = [node(''), node('serving.knative.dev')]
11
+ expect(buildTreeFacets({ nodes } as GitOpsResourceTree).kinds).toEqual([{ name: 'Service', count: 2 }])
12
+ for (const n of nodes) {
13
+ expect(matchesGitOpsTreeFilters(n, { kinds: new Set(['Service']) })).toBe(true)
14
+ expect(matchesGitOpsTreeFilters(n, { kinds: new Set(['Deployment']) })).toBe(false)
15
+ }
16
+ })
17
+ })
@@ -14,6 +14,12 @@ export function gitOpsFilterSet(values?: Set<string> | string[]): Set<string> |
14
14
  return set.size > 0 ? set : undefined
15
15
  }
16
16
 
17
+ // treeNodeFilterRole is the node's role as the filter rail presents it: a
18
+ // dependency the root consumes but doesn't manage is not "Declared".
19
+ export function treeNodeFilterRole(node: GitOpsTreeNode): string {
20
+ return node.dependency ? 'dependency' : node.role
21
+ }
22
+
17
23
  export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsTreeFilters): boolean {
18
24
  if (!filters) return true
19
25
  const kinds = gitOpsFilterSet(filters.kinds)
@@ -26,7 +32,7 @@ export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsT
26
32
  if (namespaces && !namespaces.has(node.ref.namespace || '(cluster)')) return false
27
33
  if (sync && !sync.has(node.sync || 'Unknown')) return false
28
34
  if (health && !health.has(node.health || 'Unknown')) return false
29
- if (roles && !roles.has(node.role)) return false
35
+ if (roles && !roles.has(treeNodeFilterRole(node))) return false
30
36
  return true
31
37
  }
32
38
 
@@ -0,0 +1,82 @@
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 { IssueRow } from './IssuesView'
6
+ import type { Issue } from './types'
7
+
8
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
9
+
10
+ const condition =
11
+ 'If victoria-metrics-operator was uninstalled on purpose (and nothing else should finalize this object), remove the finalizer'
12
+ const kubectl = `kubectl patch 'vmagents.operator.victoriametrics.com' 'stuck' -n 'team' --type=json -p '[]' --dry-run=server`
13
+
14
+ const issue: Issue = {
15
+ id: 'vmagent-stuck',
16
+ severity: 'warning',
17
+ source: 'problem',
18
+ category: 'termination_stuck',
19
+ category_group: 'control_plane',
20
+ grouping_scope: 'unknown',
21
+ group: 'operator.victoriametrics.com',
22
+ kind: 'VMAgent',
23
+ namespace: 'team',
24
+ name: 'stuck',
25
+ reason: 'Terminating stuck',
26
+ cause: 'Deletion has been pending for 2h.',
27
+ action:
28
+ 'No recognized controller found for finalizer "apps.victoriametrics.com/finalizer". If victoria-metrics-operator should be running, restore it so it can resume cleanup.',
29
+ conditional_cleanup: {
30
+ condition,
31
+ agent_guidance: 'Not the next step.',
32
+ caution: 'Removing a finalizer skips the cleanup its controller would have done.',
33
+ removals: [
34
+ {
35
+ finalizer: 'apps.victoriametrics.com/finalizer',
36
+ patch_resource: { kind: 'VMAgent', name: 'stuck', dry_run: true },
37
+ kubectl,
38
+ },
39
+ ],
40
+ },
41
+ }
42
+
43
+ it('keeps a conditional cleanup closed behind its condition, apart from the next step', async () => {
44
+ const element = document.createElement('div')
45
+ const root = createRoot(element)
46
+ await act(async () => {
47
+ root.render(<IssueRow issue={issue} open onToggle={() => {}} />)
48
+ })
49
+
50
+ const toggle = [...element.querySelectorAll('button')].find((button) => button.textContent?.includes(condition))
51
+ expect(toggle, 'the toggle is labeled with the condition').toBeTruthy()
52
+ expect(toggle!.getAttribute('aria-expanded')).toBe('false')
53
+ const panel = [...element.querySelectorAll<HTMLElement>('[id]')].find((node) => node.id === toggle!.getAttribute('aria-controls'))!
54
+ expect(panel.style.gridTemplateRows).toBe('0fr')
55
+ expect(panel.firstElementChild!.hasAttribute('inert')).toBe(true)
56
+ expect(panel.textContent).toContain(kubectl)
57
+
58
+ const nextStep = [...element.querySelectorAll('section')].find((section) => section.querySelector('h4')?.textContent === 'Next step')
59
+ expect(nextStep?.textContent).toContain('restore it')
60
+ expect(nextStep?.textContent).not.toContain('kubectl')
61
+ expect(nextStep?.textContent).not.toContain('patch_resource')
62
+ expect(nextStep?.contains(toggle!)).toBe(false)
63
+
64
+ await act(async () => {
65
+ toggle!.click()
66
+ })
67
+ expect(toggle!.getAttribute('aria-expanded')).toBe('true')
68
+ expect(panel.style.gridTemplateRows).toBe('1fr')
69
+ expect(panel.firstElementChild!.hasAttribute('inert')).toBe(false)
70
+
71
+ await act(async () => root.unmount())
72
+ })
73
+
74
+ it('renders no cleanup section when the issue has none', async () => {
75
+ const element = document.createElement('div')
76
+ const root = createRoot(element)
77
+ await act(async () => {
78
+ root.render(<IssueRow issue={{ ...issue, conditional_cleanup: undefined }} open onToggle={() => {}} />)
79
+ })
80
+ expect(element.textContent).not.toContain('Conditional cleanup')
81
+ await act(async () => root.unmount())
82
+ })
@@ -1,6 +1,7 @@
1
+ import { summarizeSchedulerMessage } from '../resources/resource-utils'
1
2
  import { useMemo, useState, type ComponentType, type ReactNode } from 'react';
2
- import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
3
- import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
3
+ import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, Eraser, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
4
+ import { CardBody, CardSection, ClusterName, Disclosure, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
4
5
  import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse';
5
6
  import { Tooltip } from '../ui/Tooltip';
6
7
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
@@ -13,7 +14,7 @@ import {
13
14
  ISSUE_SEVERITY_RAIL_CLASS,
14
15
  ISSUE_SEVERITY_SOLID_CLASS,
15
16
  ISSUE_SEVERITY_TEXT_CLASS,
16
- categoryLabel,
17
+ issueTitle,
17
18
  groupBadgeClass,
18
19
  groupLabel,
19
20
  } from './severity';
@@ -24,6 +25,7 @@ import {
24
25
  subjectRef,
25
26
  type Issue,
26
27
  type IssueAffected,
28
+ type IssueConditionalCleanup,
27
29
  type IssueResourceRef,
28
30
  type IssueSeverity,
29
31
  } from './types';
@@ -168,6 +170,7 @@ export interface IssueRowProps {
168
170
  resourceHref?: (ref: IssueResourceRef) => string;
169
171
  onResourceClick?: (ref: IssueResourceRef) => void;
170
172
  as?: 'li' | 'div';
173
+ compact?: boolean;
171
174
  className?: string;
172
175
  dimmed?: boolean;
173
176
  /** Suppress the "Subject" deep-link in the expanded body — set by hosts that
@@ -195,6 +198,7 @@ export function IssueRow({
195
198
  resourceHref,
196
199
  onResourceClick,
197
200
  as = 'li',
201
+ compact = false,
198
202
  className,
199
203
  dimmed,
200
204
  hideSubject,
@@ -208,6 +212,7 @@ export function IssueRow({
208
212
  const cluster = clusterLabel?.(issue);
209
213
  const affected = affectedSummary(issue.affected);
210
214
  const { headline } = issueMessageParts(issue);
215
+ const schedulingCause = compact && issue.reason === 'Unschedulable' ? summarizeSchedulerMessage(issue.cause || issue.message || headline || '', { plain: true }) : undefined;
211
216
  const { panelId, buttonProps } = useDisclosure(open);
212
217
  const Container = as;
213
218
  const severity = normalizeIssueSeverity(issue.severity);
@@ -289,7 +294,7 @@ export function IssueRow({
289
294
 
290
295
  <div className="flex min-w-0 flex-1 flex-col gap-1">
291
296
  <div className="flex min-w-0 items-baseline gap-2">
292
- <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
297
+ <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{issueTitle(issue)}</span>
293
298
  <span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
294
299
  {renderBadges?.(slotCtx)}
295
300
  {/* The detector reason rides the title row while COLLAPSED so the
@@ -297,7 +302,7 @@ export function IssueRow({
297
302
  cause lives in the WHAT'S WRONG section below, so it fades out
298
303
  here (stays mounted — it's the flex-1 filler, so unmounting
299
304
  wouldn't reflow anything, but fading avoids the pop). */}
300
- {issue.reason ? (
305
+ {issue.reason && !schedulingCause ? (
301
306
  <span
302
307
  aria-hidden={open || undefined}
303
308
  className={`min-w-0 flex-1 truncate text-xs text-theme-text-tertiary transition-opacity duration-200 ${open ? 'opacity-0' : 'opacity-100'}`}
@@ -307,6 +312,7 @@ export function IssueRow({
307
312
  </span>
308
313
  ) : null}
309
314
  </div>
315
+ {schedulingCause && <div className="break-words text-xs text-theme-text-secondary">{schedulingCause}</div>}
310
316
  <div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
311
317
  <span className={KIND_CHIP_CLASS}>{issue.kind}</span>
312
318
  <span className="min-w-0 truncate font-medium text-theme-text-secondary">
@@ -544,6 +550,8 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
544
550
  </CardSection>
545
551
  ) : null}
546
552
 
553
+ {issue.conditional_cleanup ? <ConditionalCleanup cleanup={issue.conditional_cleanup} /> : null}
554
+
547
555
  {rawError ? (
548
556
  <CardSection icon={Terminal} label="Raw error">
549
557
  <TerminalBlock>{rawError}</TerminalBlock>
@@ -553,6 +561,33 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
553
561
  );
554
562
  }
555
563
 
564
+ // A step that is right only under its condition. It stays closed behind the
565
+ // condition so the row's next step remains the default path, and it is not
566
+ // toned as a fix.
567
+ function ConditionalCleanup({ cleanup }: { cleanup: IssueConditionalCleanup }) {
568
+ return (
569
+ <CardSection icon={Eraser} label="Conditional cleanup">
570
+ <Disclosure
571
+ summary={<span className="text-sm leading-relaxed">{cleanup.condition}</span>}
572
+ summaryClassName="items-baseline text-theme-text-primary hover:text-theme-text-secondary"
573
+ >
574
+ <div className="flex flex-col gap-2 pt-2">
575
+ {cleanup.caution ? <p className="text-xs leading-relaxed text-theme-text-secondary">{cleanup.caution}</p> : null}
576
+ {cleanup.removals.map((removal) => (
577
+ <div key={removal.finalizer} className="flex flex-col gap-1.5">
578
+ <p className="text-xs text-theme-text-tertiary">
579
+ Finalizer <code className="inline-code">{removal.finalizer}</code>
580
+ </p>
581
+ <TerminalBlock label="patch_resource · dry run">{JSON.stringify(removal.patch_resource)}</TerminalBlock>
582
+ <TerminalBlock label="kubectl · server dry run">{removal.kubectl}</TerminalBlock>
583
+ </div>
584
+ ))}
585
+ </div>
586
+ </Disclosure>
587
+ </CardSection>
588
+ );
589
+ }
590
+
556
591
  function changeContextText(change: NonNullable<Issue['change_context']>): string {
557
592
  const parts = [change.when ? `Changed ${change.when} ago` : 'Changed', change.what ? change.what.replace(/_/g, ' ') : null, change.evidence].filter(Boolean);
558
593
  return parts.join(' · ');
@@ -106,3 +106,31 @@ it('keeps pod/template evidence in expanded neutral context with navigable witne
106
106
  expect(expanded).not.toContain('confidence')
107
107
  expect(expanded).not.toContain('Caused by')
108
108
  })
109
+
110
+ it('wraps a compact Pod scheduling cause separately and retains the full message in details', () => {
111
+ const raw = '0/2 nodes are available: 2 Too many pods. preemption: 0/2 nodes are available: 2 No preemption victims found for incoming pod.'
112
+ const pod: Issue = { ...issue, kind: 'Pod', reason: 'Unschedulable', cause: raw, message: raw }
113
+ const html = renderToString(<IssueRow issue={pod} compact open onToggle={() => {}} />)
114
+ expect(html).toContain('break-words text-xs text-theme-text-secondary')
115
+ expect(html).toContain('both nodes have reached their Pod limit')
116
+ expect(html).toContain(raw)
117
+ const producerMessage = '2 node(s) insufficient pods (0/2 nodes available)'
118
+ const reported = renderToString(<IssueRow issue={{ ...pod, cause: undefined, message: producerMessage }} compact open onToggle={() => {}} />)
119
+ expect(reported).toContain('both nodes have reached their Pod limit')
120
+ expect(reported).toContain(producerMessage)
121
+ const regular = renderToString(<IssueRow issue={pod} open={false} onToggle={() => {}} />)
122
+ expect(regular).not.toContain('both nodes have reached their Pod limit')
123
+ })
124
+
125
+ it.each(['Cluster', 'Deployment'])('wraps compact scheduling causes aggregated under %s', (kind) => {
126
+ const raw = '2 node(s) insufficient pods (0/2 nodes available)'
127
+ const aggregated: Issue = { ...issue, kind, reason: 'Unschedulable', cause: raw, message: raw }
128
+ const collapsed = renderToString(<IssueRow issue={aggregated} compact open={false} onToggle={() => {}} />)
129
+ expect(collapsed).toContain('<div class="break-words text-xs text-theme-text-secondary">both nodes have reached their Pod limit</div>')
130
+ expect(collapsed).not.toContain(raw)
131
+ const expanded = renderToString(<IssueRow issue={aggregated} compact open onToggle={() => {}} />)
132
+ expect(expanded).toContain(raw)
133
+ const regular = renderToString(<IssueRow issue={aggregated} open={false} onToggle={() => {}} />)
134
+ expect(regular).toContain('Unschedulable')
135
+ expect(regular).not.toContain('both nodes have reached their Pod limit')
136
+ })
@@ -8,7 +8,9 @@ export function ResourceIssuesSection({
8
8
  issues,
9
9
  onResourceClick,
10
10
  subjectResource,
11
+ compact,
11
12
  }: {
13
+ compact?: boolean
12
14
  issues: Issue[] | undefined
13
15
  /** When provided, related resources in a causal link become clickable. */
14
16
  onResourceClick?: (ref: IssueResourceRef) => void
@@ -36,6 +38,7 @@ export function ResourceIssuesSection({
36
38
  <IssueRow
37
39
  key={key}
38
40
  issue={issue}
41
+ compact={compact}
39
42
  open={openId === key}
40
43
  onToggle={() => setOpenId((cur) => (cur === key ? null : key))}
41
44
  onResourceClick={onResourceClick}
@@ -16,7 +16,7 @@ export {
16
16
  issueSortAnchor,
17
17
  compareIssueSortAnchors,
18
18
  } from './types';
19
- export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
19
+ export type { Issue, IssueConditionalCleanup, IssueFinalizerRemoval, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
20
20
  export {
21
21
  ISSUE_SEVERITY_LABEL,
22
22
  ISSUE_SEVERITY_BADGE_CLASS,
@@ -24,5 +24,7 @@ export {
24
24
  ISSUE_SEVERITY_FILL_CLASS,
25
25
  ISSUE_SEVERITY_RAIL_CLASS,
26
26
  categoryLabel,
27
+ issueTitle,
28
+ issueReasonTitle,
27
29
  groupLabel,
28
30
  } from './severity';
@@ -1,7 +1,7 @@
1
1
  import { isDeploymentLikeWorkloadKind } from '../../types';
2
2
  import type { Issue } from './types';
3
3
 
4
- export type IssueTimingDisplayKind = 'creation' | 'regression';
4
+ export type IssueTimingDisplayKind = 'creation' | 'regression' | 'deletion';
5
5
 
6
6
  export interface IssueTimingDisplay {
7
7
  kind: IssueTimingDisplayKind;
@@ -77,6 +77,17 @@ export function issueTiming(issue: Issue): IssueTimingDisplay | null {
77
77
  const grouped = (coverage?.known ?? 0) + (coverage?.unknown ?? 0) > 1;
78
78
  const uncertaintyMeta = onsetUncertaintyMeta(issue);
79
79
  const uncertaintyTooltip = onsetUncertaintyTooltip(issue);
80
+ // A stuck deletion is timed from its deletionTimestamp. The creation-vs-healthy
81
+ // classification only says whether the object was deleted soon after it was
82
+ // created, which is not what the age beside this chip measures.
83
+ if (issue.issue_timing_basis === 'deletion' && issue.issue_timing) {
84
+ return {
85
+ kind: 'deletion',
86
+ chip: 'since deletion',
87
+ meta: 'pending since deletion was requested',
88
+ tooltip: 'Timed from when deletion was requested; the object is still present.',
89
+ };
90
+ }
80
91
  switch (issue.issue_timing) {
81
92
  case 'started_after_resource_was_healthy': {
82
93
  if (issue.issue_timing_basis === 'owner_condition') {
@@ -2,7 +2,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest'
2
2
  import { createElement } from 'react'
3
3
  import { renderToString } from 'react-dom/server'
4
4
  import { compareIssues, issueSortAnchor, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
5
- import { categoryLabel, groupBadgeClass, groupLabel } from './severity'
5
+ import { categoryLabel, groupBadgeClass, groupLabel, issueTitle } from './severity'
6
6
  import { IssueRow } from './IssuesView'
7
7
  import { issueFirstSeenTitle, issueResourceCreatedTitle, issueTiming } from './issue-timing'
8
8
 
@@ -83,6 +83,8 @@ describe('category/group label fallbacks', () => {
83
83
  it('returns the mapped label, else humanizes (server-added category needs no frontend deploy)', () => {
84
84
  expect(categoryLabel('crashloop')).toBe('Crash loop')
85
85
  expect(categoryLabel('some_new_future_category')).toBe('Some new future category')
86
+ expect(issueTitle({ category: 'backup_failed', reason: 'CNPGWALArchivingFailing' })).toBe('WAL archiving failing')
87
+ expect(issueTitle({ category: 'backup_failed', reason: 'BackupFailed' })).toBe('Backup failed')
86
88
  })
87
89
  it('humanizes an unmapped group', () => {
88
90
  expect(groupLabel('runtime')).toBe('Runtime')
@@ -137,6 +139,33 @@ describe('IssueRow', () => {
137
139
  expect(html).toContain('after healthy')
138
140
  })
139
141
 
142
+ it('labels a stuck deletion by how long deletion has been pending', () => {
143
+ vi.useFakeTimers()
144
+ vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
145
+
146
+ const html = renderToString(createElement(IssueRow, {
147
+ issue: mk({
148
+ id: 'terminating',
149
+ kind: 'VMAgent',
150
+ group: 'operator.victoriametrics.com',
151
+ category: 'termination_stuck',
152
+ category_group: 'control_plane',
153
+ reason: 'Terminating stuck',
154
+ first_seen: '2026-06-30T11:30:00Z',
155
+ issue_timing: 'started_at_resource_creation',
156
+ issue_timing_basis: 'deletion',
157
+ }),
158
+ open: true,
159
+ onToggle: () => undefined,
160
+ }))
161
+
162
+ expect(html).toContain('30m')
163
+ expect(html).toContain('since deletion')
164
+ expect(html).toContain('pending since deletion was requested')
165
+ expect(html).not.toContain('since creation')
166
+ expect(html).not.toContain('first reconciliation')
167
+ })
168
+
140
169
  it('uses the category-group chip class in the queue row', () => {
141
170
  const html = renderToString(createElement(IssueRow, {
142
171
  issue: mk({ category_group: 'control_plane', kind: 'Node' }),
@@ -394,6 +423,25 @@ describe('issueTiming', () => {
394
423
  })
395
424
  })
396
425
 
426
+ it('times stuck deletions from the deletion request, however soon after creation it came', () => {
427
+ for (const timing of ['started_at_resource_creation', 'started_after_resource_was_healthy'] as const) {
428
+ for (const kind of ['Pod', 'VMAgent']) {
429
+ const display = issueTiming(mk({
430
+ kind,
431
+ category: 'termination_stuck',
432
+ issue_timing: timing,
433
+ issue_timing_basis: 'deletion',
434
+ }))
435
+ expect(display).toMatchObject({
436
+ kind: 'deletion',
437
+ chip: 'since deletion',
438
+ meta: 'pending since deletion was requested',
439
+ })
440
+ expect(`${display?.chip} ${display?.meta} ${display?.tooltip}`).not.toMatch(/creation|healthy|deploy/i)
441
+ }
442
+ }
443
+ })
444
+
397
445
  it('describes direct after-healthy timing without coupling it to first_seen', () => {
398
446
  const display = issueTiming(mk({
399
447
  first_seen: '2026-06-30T10:00:00Z',