@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
@@ -108,8 +108,8 @@ describe('curated column ownership', () => {
108
108
  // Brace-counting, not parsing: assert the extraction actually saw the whole
109
109
  // table, or a silently-missed key would let the guard above pass vacuously.
110
110
  it('extracted every curated key', () => {
111
- expect(CURATED.length).toBe(239)
112
- expect(OWNED.size).toBe(201)
111
+ expect(CURATED.length).toBe(240)
112
+ expect(OWNED.size).toBe(202)
113
113
  expect(CURATED).toContain('pods')
114
114
  expect(CURATED).toContain('crossplanemanagedresources')
115
115
  expect([...OWNED]).toContain('awsmanagedcontrolplanes')
@@ -166,7 +166,7 @@ describe('every declaration resolves', () => {
166
166
  .map(m => [m[1], m[2].split(',').map(g => g.trim().replace(/'/g, ''))] as const)
167
167
 
168
168
  it('covers the whole ownership table', () => {
169
- expect(DECLARED.length).toBe(201)
169
+ expect(DECLARED.length).toBe(202)
170
170
  })
171
171
 
172
172
  it.each(DECLARED)('%s is curated under each group it claims', (key, groups) => {
@@ -0,0 +1,286 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { renderToStaticMarkup } from "react-dom/server";
5
+ import { expect, it, vi } from "vitest";
6
+ import { HPARenderer } from "./HPARenderer";
7
+ import { VPARenderer } from "./VPARenderer";
8
+ import { KedaScaledObjectRenderer } from "./KedaScaledObjectRenderer";
9
+ import {
10
+ initNavigationMap,
11
+ resetNavigationMap,
12
+ } from "../../../utils/navigation";
13
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
14
+
15
+ it.each(["HPA", "VPA"])(
16
+ "retains a %s custom scale target API group",
17
+ async (kind) => {
18
+ initNavigationMap([
19
+ {
20
+ group: "custom.example.io",
21
+ version: "v1",
22
+ kind: "Deployment",
23
+ name: "deployments",
24
+ namespaced: true,
25
+ isCrd: true,
26
+ verbs: ["get"],
27
+ },
28
+ ]);
29
+ const element = document.createElement("div");
30
+ const root = createRoot(element);
31
+ const onNavigate = vi.fn();
32
+ const target = {
33
+ apiVersion: "custom.example.io/v1",
34
+ kind: "Deployment",
35
+ name: "same-name",
36
+ };
37
+ const data = {
38
+ metadata: { namespace: "team" },
39
+ spec: kind === "HPA" ? { scaleTargetRef: target } : { targetRef: target },
40
+ };
41
+ try {
42
+ await act(async () =>
43
+ root.render(
44
+ kind === "HPA" ? (
45
+ <HPARenderer data={data} onNavigate={onNavigate} />
46
+ ) : (
47
+ <VPARenderer data={data} onNavigate={onNavigate} />
48
+ ),
49
+ ),
50
+ );
51
+ const button = [...element.querySelectorAll("button")].find(
52
+ (b) => b.textContent === "Deployment/same-name",
53
+ );
54
+ expect(button).toBeDefined();
55
+ expect([...element.querySelectorAll("button")].filter(b => b.textContent?.startsWith("Deployment/same-name"))).toHaveLength(1);
56
+ await act(async () => button!.click());
57
+ expect(onNavigate).toHaveBeenCalledWith({
58
+ kind: "Deployment",
59
+ namespace: "team",
60
+ name: "same-name",
61
+ group: "custom.example.io",
62
+ });
63
+ } finally {
64
+ await act(async () => root.unmount());
65
+ resetNavigationMap();
66
+ }
67
+ },
68
+ );
69
+
70
+ it.each(["v1", "", undefined])("keeps Object metric resources separate from the scale target and measures its own Namespace (apiVersion %s)", async (namespaceAPIVersion) => {
71
+ const element = document.createElement("div");
72
+ const root = createRoot(element);
73
+ const onNavigate = vi.fn();
74
+ const data = {
75
+ metadata: { namespace: "team" },
76
+ spec: {
77
+ scaleTargetRef: {
78
+ apiVersion: "apps/v1",
79
+ kind: "Deployment",
80
+ name: "workload",
81
+ },
82
+ metrics: [
83
+ {
84
+ type: "Object",
85
+ object: {
86
+ metric: { name: "requests" },
87
+ describedObject: {
88
+ apiVersion: "networking.k8s.io/v1",
89
+ kind: "Ingress",
90
+ name: "frontend",
91
+ },
92
+ },
93
+ },
94
+ {
95
+ type: "Object",
96
+ object: {
97
+ metric: { name: "queue" },
98
+ describedObject: {
99
+ apiVersion: "custom.example.io/v1",
100
+ kind: "Widget",
101
+ name: "input",
102
+ },
103
+ },
104
+ },
105
+ {
106
+ type: "Object",
107
+ object: {
108
+ metric: { name: "namespace-metric" },
109
+ describedObject: {
110
+ apiVersion: namespaceAPIVersion,
111
+ kind: "Namespace",
112
+ name: "cannot-escape",
113
+ },
114
+ },
115
+ },
116
+ ],
117
+ },
118
+ };
119
+ try {
120
+ await act(async () =>
121
+ root.render(<HPARenderer data={data} onNavigate={onNavigate} />),
122
+ );
123
+ for (const [label, expected] of [
124
+ [
125
+ "Ingress/frontend",
126
+ { kind: "Ingress", group: "networking.k8s.io", namespace: "team", name: "frontend" },
127
+ ],
128
+ [
129
+ "Widget/input",
130
+ {
131
+ kind: "Widget",
132
+ group: "custom.example.io",
133
+ namespace: "team",
134
+ name: "input",
135
+ },
136
+ ],
137
+ [
138
+ "Namespace/team",
139
+ { kind: "Namespace", group: "", namespace: "", name: "team" },
140
+ ],
141
+ ] as const) {
142
+ const button = [...element.querySelectorAll("button")].find(
143
+ (b) => b.textContent === label,
144
+ );
145
+ expect(button).toBeDefined();
146
+ await act(async () => button!.click());
147
+ expect(onNavigate).toHaveBeenLastCalledWith(expected);
148
+ }
149
+ expect(element.textContent).toContain("Object metric sources");
150
+ expect(element.textContent).toContain("This HPA's namespace");
151
+ expect(element.textContent).not.toContain("Namespace/cannot-escape");
152
+ } finally {
153
+ await act(async () => root.unmount());
154
+ }
155
+ });
156
+
157
+ it.each(["HPA", "VPA"])("does not infer missing %s target identity", (kind) => {
158
+ const target = { name: "ambiguous" };
159
+ const data = {
160
+ metadata: { namespace: "team" },
161
+ spec: kind === "HPA" ? { scaleTargetRef: target } : { targetRef: target },
162
+ };
163
+ const html = renderToStaticMarkup(
164
+ kind === "HPA" ? (
165
+ <HPARenderer data={data} onNavigate={() => {}} />
166
+ ) : (
167
+ <VPARenderer data={data} onNavigate={() => {}} />
168
+ ),
169
+ );
170
+ const doc = new DOMParser().parseFromString(html, "text/html");
171
+ expect(
172
+ [...doc.querySelectorAll("button")].some((b) =>
173
+ b.textContent?.includes("ambiguous"),
174
+ ),
175
+ ).toBe(false);
176
+ expect(html).not.toContain("Deployment/ambiguous");
177
+ });
178
+
179
+ it.each([
180
+ [
181
+ { name: "worker" },
182
+ { kind: "Deployment", group: "apps", namespace: "team", name: "worker" },
183
+ ],
184
+ [
185
+ { name: "worker", apiVersion: "", kind: "" },
186
+ { kind: "Deployment", group: "apps", namespace: "team", name: "worker" },
187
+ ],
188
+ [
189
+ { apiVersion: "custom.example.io/v1", kind: "Deployment", name: "worker" },
190
+ {
191
+ kind: "Deployment",
192
+ group: "custom.example.io",
193
+ namespace: "team",
194
+ name: "worker",
195
+ },
196
+ ],
197
+ ])(
198
+ "preserves KEDA documented defaults and explicit custom API identity",
199
+ async (target, expected) => {
200
+ const element = document.createElement("div");
201
+ const root = createRoot(element);
202
+ const onNavigate = vi.fn();
203
+ try {
204
+ await act(async () =>
205
+ root.render(
206
+ <KedaScaledObjectRenderer
207
+ data={{
208
+ metadata: { namespace: "team" },
209
+ spec: { scaleTargetRef: target },
210
+ }}
211
+ onNavigate={onNavigate}
212
+ />,
213
+ ),
214
+ );
215
+ const button = [...element.querySelectorAll("button")].find(
216
+ (b) => b.textContent === "Deployment/worker",
217
+ );
218
+ expect(button).toBeDefined();
219
+ await act(async () => button!.click());
220
+ expect(onNavigate).toHaveBeenCalledWith(expected);
221
+ } finally {
222
+ await act(async () => root.unmount());
223
+ }
224
+ },
225
+ );
226
+
227
+ it.each([
228
+ ["Deployment", "apps"], ["StatefulSet", "apps"], ["DaemonSet", "apps"], ["ReplicaSet", "apps"],
229
+ ["Job", "batch"], ["CronJob", "batch"], ["ReplicationController", ""],
230
+ ])("keeps versionless VPA %s target navigation", async (kind, group) => {
231
+ const element = document.createElement("div");
232
+ const root = createRoot(element);
233
+ const onNavigate = vi.fn();
234
+ try {
235
+ await act(async () => root.render(<VPARenderer data={{ metadata: { namespace: "team" }, spec: { targetRef: { kind, name: "workload" } } }} onNavigate={onNavigate} />));
236
+ const link = [...element.querySelectorAll("button")].find(button => button.textContent === `${kind}/workload`);
237
+ expect(link).toBeDefined();
238
+ await act(async () => link!.click());
239
+ expect(onNavigate).toHaveBeenLastCalledWith({ kind, group, namespace: "team", name: "workload" });
240
+ } finally {
241
+ await act(async () => root.unmount());
242
+ }
243
+ });
244
+
245
+ it("pairs each diagnosis metric card with its declared Object source once", async () => {
246
+ const element = document.createElement("div");
247
+ const root = createRoot(element);
248
+ const onNavigate = vi.fn();
249
+ const objects = [
250
+ { metric: { name: "requests" }, describedObject: { apiVersion: "v1", kind: "Endpoints", name: "input" } },
251
+ { metric: { name: "requests" }, describedObject: { apiVersion: "custom.example.io/v1", kind: "Endpoints", name: "input" } },
252
+ { metric: { name: "queue_depth" }, describedObject: { kind: "Namespace", name: "ignored" } },
253
+ ];
254
+ try {
255
+ await act(async () => root.render(<HPARenderer
256
+ data={{ metadata: { namespace: "team" }, spec: { metrics: objects.map(object => ({ type: "Object", object })) } }}
257
+ hpaDiagnosis={{ state: "stabilized", summary: "Stabilized", target: {}, bounds: { min: 1, max: 3, current: 2, desired: 2 }, metrics: [
258
+ { type: "Object", name: "Endpoints/input/requests", current: "10", status: "ok" },
259
+ { type: "Object", name: "Endpoints (custom.example.io)/input/requests", current: "20", status: "ok" },
260
+ { type: "Object", name: "Namespace/team/queue_depth", current: "7", status: "ok" },
261
+ { type: "Object", name: "Service/stale/requests", current: "3", status: "status_only" },
262
+ ] }} onNavigate={onNavigate} />));
263
+ expect(element.textContent).not.toContain("Object metric sources");
264
+ expect(element.textContent).not.toContain("ignored");
265
+ const endpoints = [...element.querySelectorAll("button")].filter(button => button.textContent === "Endpoints/input");
266
+ expect(endpoints).toHaveLength(2);
267
+ await act(async () => endpoints[0].click());
268
+ expect(onNavigate).toHaveBeenLastCalledWith({ kind: "Endpoints", group: "", namespace: "team", name: "input" });
269
+ await act(async () => endpoints[1].click());
270
+ expect(onNavigate).toHaveBeenLastCalledWith({ kind: "Endpoints", group: "custom.example.io", namespace: "team", name: "input" });
271
+ const namespace = [...element.querySelectorAll("button")].find(button => button.textContent === "Namespace/team");
272
+ expect(namespace).toBeDefined();
273
+ await act(async () => namespace!.click());
274
+ expect(onNavigate).toHaveBeenLastCalledWith({ kind: "Namespace", group: "", namespace: "", name: "team" });
275
+ expect([...element.querySelectorAll("button")].some(button => button.textContent === "Service/stale")).toBe(false);
276
+ for (const value of ["Current 10", "Current 20", "Current 7"]) expect(element.textContent).toContain(value);
277
+ } finally {
278
+ await act(async () => root.unmount());
279
+ }
280
+ });
281
+
282
+ it("shows the documented default Target API for KEDA", () => {
283
+ const html = renderToStaticMarkup(<KedaScaledObjectRenderer data={{ metadata: { namespace: "team" }, spec: { scaleTargetRef: { name: "worker" } } }} />);
284
+ expect(html).toContain("Target API");
285
+ expect(html).toContain("apps/v1");
286
+ });
@@ -27,6 +27,7 @@ export function CAPIClusterRenderer({ data, onNavigate, apiBase = '', canConnect
27
27
  const className = getClusterClass(data)
28
28
  const version = getClusterVersion(data)
29
29
  const topology = spec.topology || {}
30
+ const classNamespace = (topology.classRef?.name ? topology.classRef.namespace : topology.classNamespace) || data.metadata?.namespace || ''
30
31
 
31
32
  // v1beta2 replica fields
32
33
  const cpReady = status.controlPlane?.readyReplicas ?? status.controlPlane?.replicas
@@ -201,10 +202,10 @@ export function CAPIClusterRenderer({ data, onNavigate, apiBase = '', canConnect
201
202
  </Section>
202
203
 
203
204
  {/* Topology (ClusterClass-based) */}
204
- {topology.class && (
205
+ {className !== '-' && (
205
206
  <Section title="Topology" icon={Layers}>
206
207
  <PropertyList>
207
- <Property label="Class" value={topology.class} />
208
+ <Property label="Class" value={<ResourceLink name={className} kind="clusterclasses" group="cluster.x-k8s.io" namespace={classNamespace} onNavigate={onNavigate} />} />
208
209
  {topology.version && <Property label="Version" value={topology.version} />}
209
210
  {topology.controlPlane?.replicas != null && (
210
211
  <Property label="CP Replicas" value={String(topology.controlPlane.replicas)} />
@@ -0,0 +1,27 @@
1
+ // @vitest-environment jsdom
2
+ import { act, type ReactNode } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { CAPIClusterRenderer } from './CAPIClusterRenderer'
6
+
7
+ async function rendered(element: ReactNode, check: (container: HTMLDivElement) => Promise<void> | void) {
8
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
9
+ const container = document.createElement('div')
10
+ const root = createRoot(container)
11
+ try { await act(async () => root.render(element)); await check(container) }
12
+ finally { await act(async () => root.unmount()); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false }) }
13
+ }
14
+ async function click(container: HTMLDivElement, text: string) {
15
+ const button = [...container.querySelectorAll('button')].find(button => button.textContent === text)
16
+ expect(button, `missing link ${text}`).toBeDefined()
17
+ await act(async () => button!.click())
18
+ }
19
+
20
+ describe('exact reference navigation', () => {
21
+ it('uses the v1beta2 ClusterClass reference namespace',async()=>{
22
+ const navigate=vi.fn()
23
+ await rendered(<CAPIClusterRenderer data={{metadata:{namespace:'app'},spec:{topology:{classRef:{name:'standard',namespace:'classes'}}}}} onNavigate={navigate}/>,async container=>{
24
+ await click(container,'standard');expect(navigate).toHaveBeenCalledWith({kind:'clusterclasses',group:'cluster.x-k8s.io',namespace:'classes',name:'standard'})
25
+ })
26
+ })
27
+ })
@@ -27,3 +27,26 @@ describe('CNPGBackupRenderer — spec.target is instance selection, not a restor
27
27
  expect(html(backup())).not.toContain('Backup Target')
28
28
  })
29
29
  })
30
+
31
+
32
+ describe('CNPGBackupRenderer plugin destination', () => {
33
+ it.each([undefined, { barmanObjectName: 'ignored-store', serverName: 'ignored-server' }, { serverName: 'ignored-server' }])('explains the Cluster destination and ignored parameters: %j', (parameters) => {
34
+ const out = renderToString(<CNPGBackupRenderer data={backup({ spec: { method: 'plugin', pluginConfiguration: { name: 'barman-cloud.cloudnative-pg.io', parameters } } })} onNavigate={() => {}} />)
35
+ expect(out).toContain('From the Cluster')
36
+ expect(out).toContain('barman-cloud plugin')
37
+ expect(out).not.toContain('Object Store')
38
+ expect(out).not.toContain('ignored-store')
39
+ expect(out).not.toContain('ignored-server')
40
+ expect(out.match(/<button[^>]*>(?:pg|ignored-store|ignored-server)<\/button>/g)).toHaveLength(1)
41
+ expect(out.includes('Ignored by the barman-cloud plugin')).toBe(!!parameters)
42
+ })
43
+
44
+ it('does not assert a third-party parameter is an ObjectStore destination', () => {
45
+ const out = html(backup({ spec: { method: 'plugin', pluginConfiguration: { name: 'other.example.com', parameters: { barmanObjectName: 'custom-store' } } } }))
46
+ expect(out).toContain('other.example.com')
47
+ expect(out).toContain('Unknown: Radar does not model this plugin')
48
+ expect(out).not.toContain('custom-store')
49
+ expect(out).not.toContain('Object Store')
50
+ expect(out).not.toContain('Ignored by')
51
+ })
52
+ })
@@ -12,7 +12,7 @@ import {
12
12
  getCNPGBackupServerName,
13
13
  getCNPGBackupError,
14
14
  getCNPGBackupTarget,
15
- CNPG_BARMAN_OBJECTSTORE_GROUP,
15
+ CNPG_BARMAN_PLUGIN_NAME,
16
16
  } from '../resource-utils-cnpg'
17
17
 
18
18
  interface CNPGBackupRendererProps {
@@ -51,24 +51,16 @@ export function CNPGBackupRenderer({ data, onNavigate }: CNPGBackupRendererProps
51
51
  <PropertyList>
52
52
  <Property label="Phase" value={phase} />
53
53
  <Property label="Method" value={getCNPGBackupMethod(data)} />
54
- {/* A plugin-taken backup lands in an ObjectStore rather than in the
55
- Cluster's own barmanObjectStore, so naming the plugin is what
56
- tells an operator where to go looking. */}
57
54
  {backupPlugin && <Property label="Plugin" value={backupPlugin.name} />}
58
- {backupPlugin?.parameters?.barmanObjectName && (
55
+ {backupPlugin && (
59
56
  <Property
60
- label="Object Store"
61
- value={
62
- <ResourceLink
63
- name={backupPlugin.parameters.barmanObjectName}
64
- kind="objectstores"
65
- group={CNPG_BARMAN_OBJECTSTORE_GROUP}
66
- namespace={data.metadata?.namespace || ''}
67
- onNavigate={onNavigate}
68
- />
69
- }
57
+ label="Destination"
58
+ value={backupPlugin.name === CNPG_BARMAN_PLUGIN_NAME ? "From the Cluster's barman-cloud plugin" : 'Unknown: Radar does not model this plugin’s destination'}
70
59
  />
71
60
  )}
61
+ {backupPlugin?.name === CNPG_BARMAN_PLUGIN_NAME && backupPlugin.parameters && Object.keys(backupPlugin.parameters).length > 0 && (
62
+ <Property label="Plugin parameters" value="Ignored by the barman-cloud plugin; the destination comes from the Cluster" />
63
+ )}
72
64
  <Property label="Duration" value={getCNPGBackupDuration(data)} />
73
65
  <Property label="Backup Name" value={getCNPGBackupName(data)} />
74
66
  {data.status?.instanceID?.podName && (
@@ -102,10 +94,7 @@ export function CNPGBackupRenderer({ data, onNavigate }: CNPGBackupRendererProps
102
94
  }
103
95
  return clusterName
104
96
  })()} />
105
- {/* Destination and server name are in-tree barmanObjectStore fields.
106
- Under the plugin method they are never populated because both live
107
- on the ObjectStore, and rendering them as "-" reads as "not
108
- configured" rather than "recorded elsewhere". */}
97
+ {/* Plugin backups do not report the in-tree destination fields. */}
109
98
  {!backupPlugin && (
110
99
  <>
111
100
  <Property label="Destination" value={getCNPGBackupDestinationPath(data)} />
@@ -23,6 +23,7 @@ import {
23
23
  getCNPGWALArchivingFailure,
24
24
  getCNPGLastBackupFailure,
25
25
  classifyCNPGClusterPhase,
26
+ cnpgBlockedPhaseExplanation,
26
27
  getCNPGClusterAvailability,
27
28
  CNPG_BARMAN_OBJECTSTORE_GROUP,
28
29
  } from '../resource-utils-cnpg'
@@ -100,6 +101,7 @@ export function CNPGClusterRenderer({ data, onNavigate, declared}: CNPGClusterRe
100
101
  const isFailover = phaseBucket === 'failing'
101
102
  const isSwitchover = phase === 'Switchover in progress'
102
103
  const isTerminal = phaseBucket === 'terminal'
104
+ const blocked = cnpgBlockedPhaseExplanation(phase, data.status?.phaseReason)
103
105
  // "The cluster is otherwise serving normally, so nothing else here will look
104
106
  // wrong" is a claim about the REST OF THIS DRAWER, so any other banner
105
107
  // falsifies it. It can no longer lean on isDegraded now that a
@@ -113,11 +115,7 @@ export function CNPGClusterRenderer({ data, onNavigate, declared}: CNPGClusterRe
113
115
  <>
114
116
  {/* Problem alerts */}
115
117
  {isTerminal && (
116
- <AlertBanner
117
- variant="error"
118
- title="Reconciliation has stopped"
119
- message={`${phase}. This state does not resolve on its own — the operator has stopped reconciling this cluster and it needs manual intervention.`}
120
- />
118
+ <AlertBanner variant="error" title={blocked.title} message={blocked.message} />
121
119
  )}
122
120
  {hasSplitBrain && (
123
121
  <AlertBanner
@@ -343,7 +341,7 @@ export function CNPGClusterRenderer({ data, onNavigate, declared}: CNPGClusterRe
343
341
  {/* Storage */}
344
342
  <Section title="Storage" icon={HardDrive} defaultExpanded>
345
343
  <PropertyList>
346
- <Property label="Data Size" value={getCNPGClusterStorage(data)} />
344
+ <Property label="Requested size" value={getCNPGClusterStorage(data)} />
347
345
  <Property label="Storage Class" value={getCNPGClusterStorageClass(data)} />
348
346
  {/* Requested size and class are spec-side intentions. These are what the
349
347
  operator reports about the volumes that actually exist. */}
@@ -364,7 +362,7 @@ export function CNPGClusterRenderer({ data, onNavigate, declared}: CNPGClusterRe
364
362
  <div className="mt-2 pt-2 border-t border-theme-border">
365
363
  <div className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-1">WAL Storage</div>
366
364
  <PropertyList>
367
- {walStorage.size && <Property label="Size" value={walStorage.size} />}
365
+ {walStorage.size && <Property label="Requested size" value={walStorage.size} />}
368
366
  {walStorage.storageClass && <Property label="Storage Class" value={walStorage.storageClass} />}
369
367
  </PropertyList>
370
368
  </div>
@@ -195,6 +195,31 @@ export function CNPGSubscriptionRenderer({
195
195
  )
196
196
  }
197
197
 
198
+ /**
199
+ * DatabaseRole (CNPG 1.30+) — one PostgreSQL role as its own object. The
200
+ * Cluster's spec.managed.roles wins for the same name; the operator then
201
+ * reports this object not applied, and that message renders verbatim above.
202
+ */
203
+ export function CNPGDatabaseRoleRenderer({ data, onNavigate }: { data: any; onNavigate?: Nav }) {
204
+ const spec = data?.spec ?? {}
205
+ const details: Array<{ label: string; value: React.ReactNode }> = [
206
+ { label: 'Role', value: spec.name ?? '-' },
207
+ { label: 'Login', value: spec.login === true ? 'Allowed' : 'Not allowed' },
208
+ ]
209
+ if (spec.superuser === true) details.push({ label: 'Superuser', value: 'Yes' })
210
+ if (spec.disablePassword === true) details.push({ label: 'Password', value: 'Disabled' })
211
+ else if (spec.passwordSecret?.name) details.push({ label: 'Password Secret', value: spec.passwordSecret.name })
212
+ if (spec.validUntil) details.push({ label: 'Valid Until', value: spec.validUntil })
213
+ if (Array.isArray(spec.inRoles) && spec.inRoles.length > 0) details.push({ label: 'Member Of', value: spec.inRoles.join(', ') })
214
+ if (spec.connectionLimit != null && spec.connectionLimit !== -1) details.push({ label: 'Connection Limit', value: String(spec.connectionLimit) })
215
+ const cc = spec.clientCertificate
216
+ if (cc && cc.enabled !== false) {
217
+ const exp = data?.status?.clientCertificate?.expiration
218
+ details.push({ label: 'Client Certificate', value: `${data?.metadata?.name}-client-cert · ${exp ? `expires ${exp}` : 'expiry not reported'}` })
219
+ }
220
+ return <CNPGDeclarativeRenderer data={data} onNavigate={onNavigate} details={details} />
221
+ }
222
+
198
223
  /**
199
224
  * ImageCatalog and ClusterImageCatalog — the PostgreSQL images a Cluster may
200
225
  * use, pinned per major version.
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { CNPGScheduledBackupRenderer } from './CNPGScheduledBackupRenderer'
4
+
5
+ const schedule = (name: string, parameters?: { barmanObjectName?: string; serverName?: string }) => ({
6
+ apiVersion: 'postgresql.cnpg.io/v1',
7
+ kind: 'ScheduledBackup',
8
+ metadata: { name: 'nightly', namespace: 'db' },
9
+ spec: { cluster: { name: 'pg' }, schedule: '0 0 0 * * *', method: 'plugin', pluginConfiguration: { name, parameters } },
10
+ })
11
+
12
+ describe('CNPGScheduledBackupRenderer plugin destination', () => {
13
+ it.each([undefined, { barmanObjectName: 'ignored-store', serverName: 'ignored-server' }, { serverName: 'ignored-server' }])('explains the Cluster destination and ignored parameters: %j', (parameters) => {
14
+ const out = renderToString(<CNPGScheduledBackupRenderer data={schedule('barman-cloud.cloudnative-pg.io', parameters)} onNavigate={() => {}} />)
15
+ expect(out).toContain('From the Cluster')
16
+ expect(out).toContain('barman-cloud plugin')
17
+ expect(out).not.toContain('Object Store')
18
+ expect(out).not.toContain('ignored-store')
19
+ expect(out).not.toContain('ignored-server')
20
+ expect(out.match(/<button[^>]*>(?:pg|ignored-store|ignored-server)<\/button>/g)).toHaveLength(1)
21
+ expect(out.includes('Ignored by the barman-cloud plugin')).toBe(!!parameters)
22
+ })
23
+
24
+ it('does not assert a third-party parameter is an ObjectStore destination', () => {
25
+ const out = renderToString(<CNPGScheduledBackupRenderer data={schedule('other.example.com', { barmanObjectName: 'custom-store' })} />)
26
+ expect(out).toContain('other.example.com')
27
+ expect(out).toContain('Unknown: Radar does not model this plugin')
28
+ expect(out).not.toContain('custom-store')
29
+ expect(out).not.toContain('Object Store')
30
+ expect(out).not.toContain('Ignored by')
31
+ })
32
+ })
@@ -11,7 +11,7 @@ import {
11
11
  getCNPGScheduledBackupIsImmediate,
12
12
  getCNPGBackupPlugin,
13
13
  getCNPGScheduledBackupOwnerRef,
14
- CNPG_BARMAN_OBJECTSTORE_GROUP,
14
+ CNPG_BARMAN_PLUGIN_NAME,
15
15
  } from '../resource-utils-cnpg'
16
16
 
17
17
  interface CNPGScheduledBackupRendererProps {
@@ -64,24 +64,16 @@ export function CNPGScheduledBackupRenderer({ data, onNavigate }: CNPGScheduledB
64
64
  return clusterName
65
65
  })()} />
66
66
  <Property label="Method" value={getCNPGScheduledBackupMethod(data)} />
67
- {/* Under the plugin method the destination lives on an ObjectStore in
68
- another API group, so naming it is the only route from here to
69
- where these backups will actually land. */}
70
67
  {schedulePlugin && <Property label="Plugin" value={schedulePlugin.name} />}
71
- {schedulePlugin?.parameters?.barmanObjectName && (
68
+ {schedulePlugin && (
72
69
  <Property
73
- label="Object Store"
74
- value={
75
- <ResourceLink
76
- name={schedulePlugin.parameters.barmanObjectName}
77
- kind="objectstores"
78
- group={CNPG_BARMAN_OBJECTSTORE_GROUP}
79
- namespace={data.metadata?.namespace || ''}
80
- onNavigate={onNavigate}
81
- />
82
- }
70
+ label="Destination"
71
+ value={schedulePlugin.name === CNPG_BARMAN_PLUGIN_NAME ? "From the Cluster's barman-cloud plugin" : 'Unknown: Radar does not model this plugin’s destination'}
83
72
  />
84
73
  )}
74
+ {schedulePlugin?.name === CNPG_BARMAN_PLUGIN_NAME && schedulePlugin.parameters && Object.keys(schedulePlugin.parameters).length > 0 && (
75
+ <Property label="Plugin parameters" value="Ignored by the barman-cloud plugin; the destination comes from the Cluster" />
76
+ )}
85
77
  <Property label="Owner Reference" value={getCNPGScheduledBackupOwnerRef(data)} />
86
78
  </PropertyList>
87
79
  </Section>
@@ -0,0 +1,35 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { CompositeRenderer } from './CompositeRenderer'
6
+ import { initNavigationMap, resetNavigationMap } from '../../../utils/navigation'
7
+
8
+ describe('CompositeRenderer composed-resource links', () => {
9
+ it('resolves a composed Kind within its own API group, not the core Kind it reuses', async () => {
10
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
11
+ initNavigationMap([
12
+ { group: '', version: 'v1', kind: 'Node', name: 'nodes', namespaced: false, isCrd: false, verbs: ['get'] },
13
+ { group: 'relationships.radar.test', version: 'v1', kind: 'Node', name: 'workerhosts', namespaced: false, isCrd: true, verbs: ['get'] },
14
+ ])
15
+ const container = document.createElement('div')
16
+ const root = createRoot(container)
17
+ const onNavigate = vi.fn()
18
+ try {
19
+ await act(async () => root.render(<CompositeRenderer onNavigate={onNavigate} data={{
20
+ apiVersion: 'platform.example.org/v1alpha1',
21
+ kind: 'Database',
22
+ metadata: { name: 'example' },
23
+ spec: { resourceRefs: [{ apiVersion: 'relationships.radar.test/v1', kind: 'Node', name: 'worker' }] },
24
+ }} />))
25
+ const link = [...container.querySelectorAll('button')].find(button => button.textContent === 'worker')
26
+ expect(link).toBeDefined()
27
+ await act(async () => link!.click())
28
+ expect(onNavigate).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'workerhosts', group: 'relationships.radar.test', name: 'worker' }))
29
+ } finally {
30
+ await act(async () => root.unmount())
31
+ resetNavigationMap()
32
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
33
+ }
34
+ })
35
+ })
@@ -14,7 +14,7 @@ import {
14
14
  isCrossplanePaused,
15
15
  type CrossplaneResourceRef,
16
16
  } from '../resource-utils-crossplane'
17
- import { kindToPlural } from '../../../utils/navigation'
17
+ import { kindToPluralWithGroup } from '../../../utils/navigation'
18
18
  import type { StatusBadge } from '../resource-utils'
19
19
 
20
20
  /**
@@ -158,7 +158,7 @@ export function CompositeRenderer({ data, onNavigate, composedRefStatuses, compo
158
158
  value={
159
159
  <ResourceLink
160
160
  name={boundXR.name}
161
- kind={kindToPlural(boundXR.kind)}
161
+ kind={kindToPluralWithGroup(boundXR.kind, groupFromApiVersion(boundXR.apiVersion))}
162
162
  namespace={boundXR.namespace || ''}
163
163
  group={groupFromApiVersion(boundXR.apiVersion) || undefined}
164
164
  onNavigate={onNavigate}
@@ -242,8 +242,8 @@ function ComposedRefRow({
242
242
  statusEntry?: ComposedRefStatus
243
243
  onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
244
244
  }) {
245
- const kindPlural = kindToPlural(ref_.kind)
246
245
  const group = groupFromApiVersion(ref_.apiVersion)
246
+ const kindPlural = kindToPluralWithGroup(ref_.kind, group)
247
247
  return (
248
248
  <div className="flex items-center gap-2 py-1 px-2 rounded hover:bg-theme-hover text-sm">
249
249
  <Box className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" />