@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,62 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
6
+
7
+ let root: Root
8
+ const base = { open: true, onClose: vi.fn(), onConfirm: vi.fn(), resourceName: 'web', resourceKind: 'Deployment', namespaceName: 'demo', isLoading: false }
9
+ beforeEach(() => {
10
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
11
+ const host = document.createElement('div')
12
+ document.body.appendChild(host)
13
+ root = createRoot(host)
14
+ })
15
+ afterEach(async () => {
16
+ await act(async () => root.unmount())
17
+ document.body.replaceChildren()
18
+ vi.clearAllMocks()
19
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
20
+ })
21
+
22
+ describe('Deletion preview states', () => {
23
+ it('states the namespace-wide deletion consequence without relying on topology', async () => {
24
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} resourceKind="Namespace" resourceName="demo" namespaceName="" cascadeRootResolved={false} />))
25
+ expect(document.body.textContent).toContain('delete the namespace "demo" and the resources in it')
26
+ expect(document.body.textContent).not.toContain("not in Radar's topology")
27
+ })
28
+ it('shows one advisory statement for a resolved preview', async () => {
29
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved cascadeDependents={[{ kind: 'Pod', namespace: 'demo', name: 'web-pod' }]} />))
30
+ const toggle = [...document.querySelectorAll('button')].find(b => b.textContent?.includes('May affect'))!
31
+ await act(async () => toggle.click())
32
+ expect(document.body.textContent).toContain('web-pod')
33
+ expect(document.body.textContent?.match(/does not predict what will be deleted/g)).toHaveLength(1)
34
+ expect(document.body.textContent).not.toContain('Other resources may be affected')
35
+ })
36
+ it.each([[], undefined])('omits the advisory statement for an empty resolved preview (%j)', async (dependents) => {
37
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved cascadeDependents={dependents} />))
38
+ expect(document.body.textContent).not.toContain('May affect')
39
+ expect(document.body.textContent).not.toContain('does not predict')
40
+ expect(document.body.textContent).not.toContain('unavailable')
41
+ expect(document.body.textContent).not.toContain('Other resources may be affected')
42
+ })
43
+ it('distinguishes an observed unresolved root from a fetch failure', async () => {
44
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved={false} />))
45
+ expect(document.body.textContent).toContain("This resource is not in Radar's topology")
46
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved={false} cascadePreviewFailed cascadeDependents={[{ kind: 'Pod', namespace: 'demo', name: 'stale' }]} />))
47
+ expect(document.body.textContent).toContain('Related-resource preview unavailable.')
48
+ expect(document.body.textContent).not.toContain('This resource is not in')
49
+ expect(document.body.textContent).not.toContain('Other resources may be affected')
50
+ expect(document.body.textContent).not.toContain('May affect')
51
+ const confirm = [...document.querySelectorAll('button')].find(b => b.textContent === 'Delete')!
52
+ expect(confirm.disabled).toBe(false)
53
+ await act(async () => confirm.click())
54
+ expect(base.onConfirm).toHaveBeenCalledWith(false)
55
+ })
56
+ it('shows loading without presenting stale failure or success as a result', async () => {
57
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeLoading cascadePreviewFailed cascadeRootResolved />))
58
+ expect(document.body.textContent).toContain('Checking related resources')
59
+ expect(document.body.textContent).not.toContain('unavailable')
60
+ expect(document.body.textContent).not.toContain('does not predict')
61
+ })
62
+ })
@@ -1,5 +1,5 @@
1
1
  import { useState, useMemo } from 'react'
2
- import { AlertTriangle, Loader2 } from 'lucide-react'
2
+ import { Loader2 } from 'lucide-react'
3
3
  import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
4
4
  import { ConfirmDialog } from './ConfirmDialog'
5
5
  import { pluralize } from '../../utils/pluralize'
@@ -22,6 +22,7 @@ interface ForceDeleteConfirmDialogProps {
22
22
  cascadeDependents?: CascadeDependent[]
23
23
  cascadeLoading?: boolean
24
24
  cascadeRootResolved?: boolean
25
+ cascadePreviewFailed?: boolean
25
26
  }
26
27
 
27
28
  export function ForceDeleteConfirmDialog({
@@ -35,8 +36,10 @@ export function ForceDeleteConfirmDialog({
35
36
  cascadeDependents,
36
37
  cascadeLoading,
37
38
  cascadeRootResolved,
39
+ cascadePreviewFailed,
38
40
  }: ForceDeleteConfirmDialogProps) {
39
41
  const [forceDelete, setForceDelete] = useState(false)
42
+ const isNamespace = resourceKind.toLowerCase() === 'namespace'
40
43
 
41
44
  function handleClose() {
42
45
  onClose()
@@ -54,7 +57,7 @@ export function ForceDeleteConfirmDialog({
54
57
  onConfirm={handleConfirm}
55
58
  title="Delete Resource"
56
59
  message={`Are you sure you want to delete "${resourceName}"?`}
57
- details={`This will permanently delete the ${resourceKind} "${resourceName}" from the "${namespaceName}" namespace.`}
60
+ details={isNamespace ? `This will permanently delete the namespace "${resourceName}" and the resources in it.` : `This will permanently delete the ${resourceKind} "${resourceName}" from the "${namespaceName}" namespace.`}
58
61
  confirmLabel={forceDelete ? 'Force Delete' : 'Delete'}
59
62
  variant="danger"
60
63
  isLoading={isLoading}
@@ -63,21 +66,30 @@ export function ForceDeleteConfirmDialog({
63
66
  {cascadeLoading && (
64
67
  <div className="flex items-center gap-2 text-xs text-theme-text-tertiary">
65
68
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
66
- Checking for dependent resources...
69
+ Checking related resources...
67
70
  </div>
68
71
  )}
69
72
 
70
- {!cascadeLoading && cascadeDependents && cascadeDependents.length > 0 && (
73
+ {!cascadeLoading && !cascadePreviewFailed && cascadeDependents && cascadeDependents.length > 0 && (
71
74
  <CascadeDependentsList dependents={cascadeDependents} />
72
75
  )}
73
76
 
74
- {!cascadeLoading && cascadeRootResolved === false && (
75
- <div className="flex items-start gap-2 rounded border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-warning-text">
76
- <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
77
- <span>
78
- Radar couldn&apos;t verify which dependent resources Kubernetes will also delete. Additional resources may be deleted but aren&apos;t shown here.
79
- </span>
80
- </div>
77
+ {!cascadeLoading && cascadePreviewFailed && (
78
+ <p className="text-xs text-theme-text-tertiary">
79
+ Related-resource preview unavailable.
80
+ </p>
81
+ )}
82
+
83
+ {!isNamespace && !cascadeLoading && !cascadePreviewFailed && cascadeRootResolved === false && (
84
+ <p className="text-xs text-theme-text-tertiary">
85
+ This resource is not in Radar&apos;s topology, so its related resources cannot be previewed.
86
+ </p>
87
+ )}
88
+
89
+ {!cascadeLoading && !cascadePreviewFailed && cascadeRootResolved === true && cascadeDependents && cascadeDependents.length > 0 && (
90
+ <p className="text-xs text-theme-text-secondary">
91
+ This list shows visible related resources; it does not predict what will be deleted.
92
+ </p>
81
93
  )}
82
94
 
83
95
  <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
@@ -120,7 +132,7 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
120
132
  >
121
133
  <CollapseChevron open={expanded} inheritColor className="w-3.5 h-3.5" />
122
134
  <span>
123
- Will also delete {pluralize(dependents.length, 'dependent resource')}
135
+ May affect {pluralize(dependents.length, 'related resource')}
124
136
  </span>
125
137
  </button>
126
138
 
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import type { ResourceRef } from '../../types/core'
4
+
5
+ export type NavigateToRef = (ref: ResourceRef) => void
6
+
7
+ /** A reference to another object: a link when the host can navigate, plain text otherwise. */
8
+ export function RefLink({ refTo, onNavigate, children, mono }: { refTo: ResourceRef; onNavigate?: NavigateToRef; children?: ReactNode; mono?: boolean }) {
9
+ const label = children ?? refTo.name
10
+ if (!onNavigate) return <span className={clsx(mono && 'font-mono')}>{label}</span>
11
+ return (
12
+ <button
13
+ type="button"
14
+ onClick={() => onNavigate(refTo)}
15
+ className={clsx('text-accent-text hover:underline text-left break-all', mono && 'font-mono')}
16
+ >
17
+ {label}
18
+ </button>
19
+ )
20
+ }
@@ -56,14 +56,19 @@ export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true
56
56
  const onDown = (e: MouseEvent) => {
57
57
  if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
58
58
  }
59
+ // Capture phase, consumed: Escape that closes the menu must not also run
60
+ // the page's own Escape (close the drawer, leave the full view).
59
61
  const onKey = (e: KeyboardEvent) => {
60
- if (e.key === 'Escape') setOpen(false)
62
+ if (e.key !== 'Escape') return
63
+ e.preventDefault()
64
+ e.stopPropagation()
65
+ setOpen(false)
61
66
  }
62
67
  document.addEventListener('mousedown', onDown)
63
- document.addEventListener('keydown', onKey)
68
+ document.addEventListener('keydown', onKey, true)
64
69
  return () => {
65
70
  document.removeEventListener('mousedown', onDown)
66
- document.removeEventListener('keydown', onKey)
71
+ document.removeEventListener('keydown', onKey, true)
67
72
  }
68
73
  }, [open])
69
74
 
@@ -0,0 +1,160 @@
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 { afterEach, expect, it, vi } from "vitest";
6
+ import { MetadataSection } from "./drawer-components";
7
+ import {
8
+ customResourceDefinitionRef,
9
+ initNavigationMap,
10
+ resetNavigationMap,
11
+ } from "../../utils/navigation";
12
+
13
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
14
+ afterEach(resetNavigationMap);
15
+ const resources = [
16
+ {
17
+ group: "plants.example.io",
18
+ version: "v1",
19
+ kind: "Cactus",
20
+ name: "cacti",
21
+ definitionName: "cacti.plants.example.io",
22
+ namespaced: true,
23
+ isCrd: true,
24
+ verbs: ["get"],
25
+ },
26
+ {
27
+ group: "other.example.io",
28
+ version: "v1",
29
+ kind: "Cactus",
30
+ name: "cactuses",
31
+ definitionName: "cactuses.other.example.io",
32
+ namespaced: false,
33
+ isCrd: true,
34
+ verbs: ["get"],
35
+ },
36
+ {
37
+ group: "apps",
38
+ version: "v1",
39
+ kind: "Deployment",
40
+ name: "deployments",
41
+ namespaced: true,
42
+ isCrd: false,
43
+ verbs: ["get"],
44
+ },
45
+ ];
46
+
47
+ it("uses exact discovered CRD group and plural instead of guessing from the Kind", () => {
48
+ expect(
49
+ customResourceDefinitionRef("plants.example.io/v1", "Cactus"),
50
+ ).toBeNull();
51
+ initNavigationMap(resources);
52
+ expect(customResourceDefinitionRef("plants.example.io/v1", "Cactus")).toEqual(
53
+ {
54
+ kind: "CustomResourceDefinition",
55
+ group: "apiextensions.k8s.io",
56
+ namespace: "",
57
+ name: "cacti.plants.example.io",
58
+ },
59
+ );
60
+ expect(
61
+ customResourceDefinitionRef("other.example.io/v1", "Cactus")?.name,
62
+ ).toBe("cactuses.other.example.io");
63
+ expect(customResourceDefinitionRef("apps/v1", "Deployment")).toBeNull();
64
+ initNavigationMap([
65
+ ...resources,
66
+ {
67
+ group: "metrics.k8s.io",
68
+ version: "v1beta1",
69
+ kind: "PodMetrics",
70
+ name: "pods",
71
+ namespaced: true,
72
+ isCrd: true,
73
+ verbs: ["get"],
74
+ },
75
+ ]);
76
+ expect(
77
+ customResourceDefinitionRef("metrics.k8s.io/v1beta1", "PodMetrics"),
78
+ ).toBeNull();
79
+ resetNavigationMap();
80
+ expect(
81
+ customResourceDefinitionRef("plants.example.io/v1", "Cactus"),
82
+ ).toBeNull();
83
+ initNavigationMap([
84
+ {
85
+ ...resources[0],
86
+ name: "cactuses",
87
+ definitionName: "cactuses.plants.example.io",
88
+ },
89
+ ]);
90
+ expect(
91
+ customResourceDefinitionRef("plants.example.io/v1", "Cactus")?.name,
92
+ ).toBe("cactuses.plants.example.io");
93
+ expect(
94
+ customResourceDefinitionRef("unknown.example.io/v1", "Cactus"),
95
+ ).toBeNull();
96
+ expect(customResourceDefinitionRef(undefined, "Cactus")).toBeNull();
97
+ initNavigationMap([]);
98
+ expect(
99
+ customResourceDefinitionRef("plants.example.io/v1", "Cactus"),
100
+ ).toBeNull();
101
+ });
102
+
103
+ it("navigates from instance metadata to its cluster-scoped definition", async () => {
104
+ initNavigationMap(resources);
105
+ const element = document.createElement("div");
106
+ const root = createRoot(element);
107
+ const onNavigate = vi.fn();
108
+ try {
109
+ await act(async () =>
110
+ root.render(
111
+ <MetadataSection
112
+ data={{
113
+ apiVersion: "plants.example.io/v1",
114
+ kind: "Cactus",
115
+ metadata: { name: "live", namespace: "team" },
116
+ }}
117
+ onNavigate={onNavigate}
118
+ />,
119
+ ),
120
+ );
121
+ const button = [...element.querySelectorAll("button")].find(
122
+ (b) => b.textContent === "cacti.plants.example.io",
123
+ );
124
+ expect(button).toBeDefined();
125
+ await act(async () => button!.click());
126
+ expect(onNavigate).toHaveBeenCalledWith({
127
+ kind: "CustomResourceDefinition",
128
+ group: "apiextensions.k8s.io",
129
+ namespace: "",
130
+ name: "cacti.plants.example.io",
131
+ });
132
+ } finally {
133
+ await act(async () => root.unmount());
134
+ }
135
+ });
136
+
137
+ it("keeps exact Kind casing within one API group", () => {
138
+ initNavigationMap([
139
+ ...resources,
140
+ { ...resources[0], kind: "CACTUS", name: "uppercacti", definitionName: "uppercacti.plants.example.io" },
141
+ ]);
142
+ expect(customResourceDefinitionRef("plants.example.io/v1", "Cactus")?.name).toBe("cacti.plants.example.io");
143
+ expect(customResourceDefinitionRef("plants.example.io/v1", "CACTUS")?.name).toBe("uppercacti.plants.example.io");
144
+ expect(customResourceDefinitionRef("plants.example.io/v1", "cactus")).toBeNull();
145
+ });
146
+
147
+ it("does not add a guessed definition to built-in or undiscovered metadata", () => {
148
+ initNavigationMap(resources);
149
+ const html = renderToStaticMarkup(
150
+ <MetadataSection
151
+ data={{
152
+ apiVersion: "apps/v1",
153
+ kind: "Deployment",
154
+ metadata: { name: "live", uid: "native" },
155
+ }}
156
+ />,
157
+ );
158
+ expect(html).toContain("native");
159
+ expect(html).not.toContain("Definition");
160
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
- import { EventsSection, ProblemAlerts, OperationalIssuesShownContext, RelatedResourcesSection, defaultConditionTone } from './drawer-components'
3
+ import { AlertBanner, EventsSection, ProblemAlerts, OperationalIssuesShownContext, RelatedResourcesSection, defaultConditionTone } from './drawer-components'
4
4
 
5
5
  const problems = [
6
6
  { color: 'red' as const, message: 'Application is Degraded' },
@@ -127,3 +127,112 @@ describe('namespace deletion condition polarity', () => {
127
127
  expect(defaultConditionTone({ type, status: 'Unknown' })).toBe('unknown')
128
128
  })
129
129
  })
130
+
131
+ describe('monitors projection', () => {
132
+ it('renders Monitored By alone and deduplicates by API group', () => {
133
+ const ref = { kind: 'Widget', group: 'one.example', namespace: 'demo', name: 'shared' }
134
+ const html = renderToString(<RelatedResourcesSection relationships={{ monitors: [ref, ref, { ...ref, group: 'two.example' }] }} onNavigate={() => {}} />)
135
+ expect(html).toContain('Related Resources')
136
+ expect(html).toContain('Monitored By')
137
+ expect(html.match(/<button/g)).toHaveLength(3)
138
+ })
139
+ })
140
+
141
+
142
+ describe('monitorTargets projection', () => {
143
+ it('renders Monitors alone and deduplicates by API group', () => {
144
+ const ref = { kind: 'Widget', group: 'one.example', namespace: 'demo', name: 'shared' }
145
+ const html = renderToString(<RelatedResourcesSection relationships={{ monitorTargets: [ref, ref, { ...ref, group: 'two.example' }] }} onNavigate={() => {}} />)
146
+ expect(html).toContain('Related Resources')
147
+ expect(html).toContain('Monitors')
148
+ expect(html.match(/<button/g)).toHaveLength(3)
149
+ })
150
+ })
151
+
152
+
153
+ describe('backends projection', () => {
154
+ it('renders Routes To alone and deduplicates by API group', () => {
155
+ const ref = { kind: 'Widget', group: 'one.example', namespace: 'demo', name: 'shared' }
156
+ const html = renderToString(<RelatedResourcesSection relationships={{ backends: [ref, ref, { ...ref, group: 'two.example' }] }} onNavigate={() => {}} />)
157
+ expect(html).toContain('Related Resources')
158
+ expect(html).toContain('Routes To')
159
+ expect(html.match(/<button/g)).toHaveLength(3)
160
+ })
161
+ })
162
+
163
+
164
+ describe('routedFrom projection', () => {
165
+ it('renders Routed From alone and deduplicates by API group', () => {
166
+ const ref = { kind: 'Widget', group: 'one.example', namespace: 'demo', name: 'shared' }
167
+ const html = renderToString(<RelatedResourcesSection relationships={{ routedFrom: [ref, ref, { ...ref, group: 'two.example' }] }} onNavigate={() => {}} />)
168
+ expect(html).toContain('Related Resources')
169
+ expect(html).toContain('Routed From')
170
+ expect(html.match(/<button/g)).toHaveLength(3)
171
+ })
172
+ })
173
+
174
+
175
+ describe('protects projection', () => {
176
+ it('renders Selects alone and deduplicates by API group', () => {
177
+ const ref = { kind: 'Widget', group: 'one.example', namespace: 'demo', name: 'shared' }
178
+ const html = renderToString(<RelatedResourcesSection relationships={{ protects: [ref, ref, { ...ref, group: 'two.example' }] }} onNavigate={() => {}} />)
179
+ expect(html).toContain('Related Resources')
180
+ expect(html).toContain('Selects')
181
+ expect(html.match(/<button/g)).toHaveLength(3)
182
+ })
183
+ })
184
+
185
+
186
+ describe('healthChecks projection', () => {
187
+ it('renders Health Checks alone and deduplicates by API group', () => {
188
+ const ref = { kind: 'Widget', group: 'one.example', namespace: 'demo', name: 'shared' }
189
+ const html = renderToString(<RelatedResourcesSection relationships={{ healthChecks: [ref, ref, { ...ref, group: 'two.example' }] }} onNavigate={() => {}} />)
190
+ expect(html).toContain('Related Resources')
191
+ expect(html).toContain('Health Checks')
192
+ expect(html.match(/<button/g)).toHaveLength(3)
193
+ })
194
+ })
195
+
196
+
197
+ describe('stagedPolicies projection', () => {
198
+ it('renders Staged Policies alone and deduplicates by API group', () => {
199
+ const ref = { kind: 'Widget', group: 'one.example', namespace: 'demo', name: 'shared' }
200
+ const html = renderToString(<RelatedResourcesSection relationships={{ stagedPolicies: [ref, ref, { ...ref, group: 'two.example' }] }} onNavigate={() => {}} />)
201
+ expect(html).toContain('Related Resources')
202
+ expect(html).toContain('Staged Policies')
203
+ expect(html.match(/<button/g)).toHaveLength(3)
204
+ })
205
+ })
206
+
207
+
208
+ describe('routing version skew', () => {
209
+ it('keeps older Services, Gateways, Routes and Pods fields visible', () => {
210
+ const html = renderToString(<RelatedResourcesSection relationships={{ services: [{kind: 'Service', namespace: 'demo', name: 'web'}], gateways: [{kind: 'HTTPRoute', group: 'gateway.networking.k8s.io', namespace: 'demo', name: 'old-route'}], routes: [{kind: 'IngressRoute', group: 'traefik.io', namespace: 'demo', name: 'route'}], pods: [{kind: 'Deployment', group: 'apps', namespace: 'demo', name: 'old-target'}] }} />)
211
+ for (const label of ['Services', 'Gateways', 'Routes', 'Pods', 'old-route', 'old-target']) expect(html).toContain(label)
212
+ })
213
+ })
214
+
215
+ describe('related resource chips', () => {
216
+ it('tells a Knative Service apart from a core Service', () => {
217
+ const html = renderToString(<RelatedResourcesSection relationships={{
218
+ services: [{ kind: 'Service', namespace: 'team', name: 'web-private' }],
219
+ routedFrom: [{ kind: 'Service', group: 'serving.knative.dev', namespace: 'team', name: 'web' }],
220
+ }} onNavigate={() => {}} />)
221
+ expect(html).toContain('>svc<!-- -->/</span>web-private<')
222
+ expect(html).toContain('>ksvc<!-- -->/</span>web<')
223
+ })
224
+ })
225
+
226
+
227
+ describe('AlertBanner optional actions', () => {
228
+ it('retains the title-only default without introducing a layout wrapper', () => {
229
+ const html = renderToString(<AlertBanner variant="info" title="Paused" />)
230
+ expect(html).toMatch(/<div class="text-sm font-medium [^"]*">Paused<\/div>/)
231
+ expect(html).not.toContain('justify-between'); expect(html).not.toContain('flex-1')
232
+ })
233
+ it.each([undefined, 'Check connection'])('places the optional action next to the title, retaining the body (%s)', message => {
234
+ const html = renderToString(<AlertBanner variant="warning" title="Metrics unavailable" message={message} action={<button>Open settings</button>} />)
235
+ expect(html).toContain('Metrics unavailable'); expect(html).toContain('<button>Open settings</button>')
236
+ expect(html).toContain('flex-wrap'); if (message) expect(html).toContain(message)
237
+ })
238
+ })
@@ -1,6 +1,7 @@
1
1
  import { dedupeResourceRefs, omitResourceRefs } from '../../utils/resource-refs'
2
+ import { customResourceDefinitionRef, getCRDDefinitionsSnapshot, subscribeCRDDefinitions } from '../../utils/navigation'
2
3
  import { Disclosure } from './Disclosure'
3
- import { createContext, useContext, useState } from 'react'
4
+ import { createContext, useContext, useState, useSyncExternalStore } from 'react'
4
5
  import { Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers, X, Minus } from 'lucide-react'
5
6
  import { clsx } from 'clsx'
6
7
  import { formatDuration, formatResources } from '../resources/resource-utils'
@@ -392,6 +393,8 @@ export function ConditionsSection({
392
393
  // ============================================================================
393
394
 
394
395
  const ALERT_COLORS = {
396
+ alert: { bg: 'status-alert', border: '', title: '', message: '', list: '', bullet: '' },
397
+ unknown: { bg: 'status-unknown', border: '', title: '', message: '', list: '', bullet: '' },
395
398
  error: { bg: 'bg-red-50 dark:bg-red-950/30', border: 'border-red-200 dark:border-red-800/30', title: 'text-red-700 dark:text-red-300', message: 'text-red-600/80 dark:text-red-300/70', list: 'text-red-600 dark:text-red-400', bullet: 'text-red-400/60 dark:text-red-400/50' },
396
399
  warning: { bg: 'bg-amber-50 dark:bg-amber-950/30', border: 'border-amber-200 dark:border-amber-800/30', title: 'text-amber-700 dark:text-amber-300', message: 'text-amber-600/80 dark:text-amber-300/70', list: 'text-amber-600 dark:text-amber-400', bullet: 'text-amber-400/60 dark:text-amber-400/50' },
397
400
  info: { bg: 'bg-sky-50 dark:bg-sky-950/30', border: 'border-sky-200 dark:border-sky-800/30', title: 'text-sky-700 dark:text-sky-300', message: 'text-sky-600/80 dark:text-sky-300/70', list: 'text-sky-600 dark:text-sky-400', bullet: 'text-sky-400/60 dark:text-sky-400/50' },
@@ -399,6 +402,8 @@ const ALERT_COLORS = {
399
402
  } as const
400
403
 
401
404
  const DEFAULT_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
405
+ alert: AlertTriangle,
406
+ unknown: AlertTriangle,
402
407
  error: AlertTriangle,
403
408
  warning: AlertTriangle,
404
409
  info: AlertTriangle,
@@ -406,17 +411,19 @@ const DEFAULT_ICONS: Record<string, React.ComponentType<{ className?: string }>>
406
411
  }
407
412
 
408
413
  interface AlertBannerProps {
409
- variant: 'error' | 'warning' | 'info' | 'success'
414
+ variant: keyof typeof ALERT_COLORS
410
415
  icon?: React.ComponentType<{ className?: string }>
411
416
  title: string
412
417
  message?: React.ReactNode
413
418
  items?: string[]
414
419
  children?: React.ReactNode
415
- /** Replaces the default bottom margin when the banner sits in a tighter layout. */
420
+ /** A control at the end of the title line, e.g. a link to where the problem is handled. */
421
+ action?: React.ReactNode
422
+ /** Replaces the default bottom margin (`mb-4`). */
416
423
  className?: string
417
424
  }
418
425
 
419
- export function AlertBanner({ variant, icon, title, message, items, children, className }: AlertBannerProps) {
426
+ export function AlertBanner({ variant, icon, title, message, items, children, action, className }: AlertBannerProps) {
420
427
  const colors = ALERT_COLORS[variant]
421
428
  const Icon = icon || DEFAULT_ICONS[variant]
422
429
  const hasBody = message || items || children
@@ -427,7 +434,14 @@ export function AlertBanner({ variant, icon, title, message, items, children, cl
427
434
  <Icon className={clsx('w-4 h-4 shrink-0', colors.title, hasBody && 'mt-0.5')} />
428
435
  {hasBody ? (
429
436
  <div className="flex-1 min-w-0">
430
- <div className={clsx('text-sm font-medium', colors.title, items && 'mb-1')}>{title}</div>
437
+ {action ? (
438
+ <div className={clsx('flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1', items && 'mb-1')}>
439
+ <div className={clsx('min-w-0 text-sm font-medium', colors.title)}>{title}</div>
440
+ <div className="shrink-0">{action}</div>
441
+ </div>
442
+ ) : (
443
+ <div className={clsx('text-sm font-medium', colors.title, items && 'mb-1')}>{title}</div>
444
+ )}
431
445
  {message && <div className={clsx('text-xs mt-1 break-words', colors.message)}>{message}</div>}
432
446
  {items && items.length > 0 && (
433
447
  <ul className={clsx('text-xs space-y-1', colors.list)}>
@@ -441,6 +455,11 @@ export function AlertBanner({ variant, icon, title, message, items, children, cl
441
455
  )}
442
456
  {children}
443
457
  </div>
458
+ ) : action ? (
459
+ <div className="flex min-w-0 flex-1 flex-wrap items-center justify-between gap-x-3 gap-y-1">
460
+ <div className={clsx('min-w-0 text-sm font-medium', colors.title)}>{title}</div>
461
+ <div className="shrink-0">{action}</div>
462
+ </div>
444
463
  ) : (
445
464
  <div className={clsx('text-sm font-medium', colors.title)}>{title}</div>
446
465
  )}
@@ -527,13 +546,16 @@ export function AnnotationsSection({ data }: { data: any }) {
527
546
  )
528
547
  }
529
548
 
530
- export function MetadataSection({ data }: { data: any }) {
549
+ export function MetadataSection({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
550
+ useSyncExternalStore(subscribeCRDDefinitions, getCRDDefinitionsSnapshot, getCRDDefinitionsSnapshot)
531
551
  const meta = data.metadata
532
552
  if (!meta) return null
553
+ const definition = customResourceDefinitionRef(data.apiVersion, data.kind)
533
554
 
534
555
  return (
535
556
  <Section title="Metadata" defaultExpanded>
536
557
  <PropertyList>
558
+ {definition && <Property label="Definition" value={<ResourceLink {...definition} onNavigate={onNavigate} />} />}
537
559
  <Property label="UID" value={meta.uid} />
538
560
  <Property label="Resource Version" value={meta.resourceVersion} />
539
561
  <Property label="Generation" value={meta.generation} />
@@ -739,7 +761,7 @@ export function formatKindName(kind: string): string {
739
761
  pods: 'Pod', deployments: 'Deployment', daemonsets: 'DaemonSet', statefulsets: 'StatefulSet',
740
762
  replicasets: 'ReplicaSet', services: 'Service', endpointslices: 'EndpointSlice', ingresses: 'Ingress',
741
763
  gateways: 'Gateway', httproutes: 'HTTPRoute', grpcroutes: 'GRPCRoute',
742
- tcproutes: 'TCPRoute', tlsroutes: 'TLSRoute', configmaps: 'ConfigMap',
764
+ tcproutes: 'TCPRoute', tlsroutes: 'TLSRoute', udproutes: 'UDPRoute', configmaps: 'ConfigMap',
743
765
  secrets: 'Secret', jobs: 'Job', cronjobs: 'CronJob', hpas: 'HPA',
744
766
  horizontalpodautoscalers: 'HPA', nodes: 'Node', namespaces: 'Namespace',
745
767
  persistentvolumeclaims: 'PVC', persistentvolumes: 'PV',
@@ -828,6 +850,13 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
828
850
  (relationships.resourceClaims && relationships.resourceClaims.length > 0) ||
829
851
  (relationships.dependencies && relationships.dependencies.length > 0) ||
830
852
  (relationships.dependents && relationships.dependents.length > 0) ||
853
+ (relationships.monitors && relationships.monitors.length > 0) ||
854
+ (relationships.monitorTargets && relationships.monitorTargets.length > 0) ||
855
+ (relationships.backends && relationships.backends.length > 0) ||
856
+ (relationships.routedFrom && relationships.routedFrom.length > 0) ||
857
+ (relationships.protects && relationships.protects.length > 0) ||
858
+ (relationships.healthChecks && relationships.healthChecks.length > 0) ||
859
+ (relationships.stagedPolicies && relationships.stagedPolicies.length > 0) ||
831
860
  relationships.scaleTarget
832
861
 
833
862
  if (!hasRelationships) return null
@@ -889,6 +918,27 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
889
918
  {relationships.dependents && relationships.dependents.length > 0 && (
890
919
  <RelationshipGroup label="Required By" refs={dedupeResourceRefs(relationships.dependents)} onNavigate={onNavigate} />
891
920
  )}
921
+ {relationships.monitors && relationships.monitors.length > 0 && (
922
+ <RelationshipGroup label="Monitored By" refs={dedupeResourceRefs(relationships.monitors)} onNavigate={onNavigate} />
923
+ )}
924
+ {relationships.monitorTargets && relationships.monitorTargets.length > 0 && (
925
+ <RelationshipGroup label="Monitors" refs={dedupeResourceRefs(relationships.monitorTargets)} onNavigate={onNavigate} />
926
+ )}
927
+ {relationships.backends && relationships.backends.length > 0 && (
928
+ <RelationshipGroup label="Routes To" refs={dedupeResourceRefs(relationships.backends)} onNavigate={onNavigate} />
929
+ )}
930
+ {relationships.routedFrom && relationships.routedFrom.length > 0 && (
931
+ <RelationshipGroup label="Routed From" refs={dedupeResourceRefs(relationships.routedFrom)} onNavigate={onNavigate} />
932
+ )}
933
+ {relationships.protects && relationships.protects.length > 0 && (
934
+ <RelationshipGroup label="Selects" refs={dedupeResourceRefs(relationships.protects)} onNavigate={onNavigate} />
935
+ )}
936
+ {relationships.healthChecks && relationships.healthChecks.length > 0 && (
937
+ <RelationshipGroup label="Health Checks" refs={dedupeResourceRefs(relationships.healthChecks)} onNavigate={onNavigate} />
938
+ )}
939
+ {relationships.stagedPolicies && relationships.stagedPolicies.length > 0 && (
940
+ <RelationshipGroup label="Staged Policies" refs={dedupeResourceRefs(relationships.stagedPolicies)} onNavigate={onNavigate} />
941
+ )}
892
942
  {relationships.scaleTarget && (
893
943
  <RelationshipGroup label="Scale Target" refs={[relationships.scaleTarget]} onNavigate={onNavigate} />
894
944
  )}
@@ -940,7 +990,7 @@ export interface ResourceRefBadgeProps {
940
990
  /** Reusable chip/badge for showing a related resource with click-to-navigate */
941
991
  export function ResourceRefBadge({ resourceRef, onClick, wrapAtSeparator }: ResourceRefBadgeProps) {
942
992
  const kindClass = getKindColor(resourceRef.kind)
943
- const kindName = formatKindForRef(resourceRef.kind)
993
+ const kindName = formatKindForRef(resourceRef.kind, resourceRef.group)
944
994
  const content = wrapAtSeparator ? (
945
995
  <>
946
996
  <span className="shrink-0 opacity-60">{kindName}/</span>
@@ -1003,8 +1053,10 @@ export function ResourceLink({ name, kind, namespace = '', group, label, onNavig
1003
1053
  )
1004
1054
  }
1005
1055
 
1006
- function formatKindForRef(kind: string): string {
1056
+ function formatKindForRef(kind: string, group?: string): string {
1007
1057
  const k = kind.toLowerCase()
1058
+ // A Knative Service shares its kind with core Services; use Knative's own short name.
1059
+ if (k === 'service' && group === 'serving.knative.dev') return 'ksvc'
1008
1060
  const shortNames: Record<string, string> = {
1009
1061
  deployment: 'deploy',
1010
1062
  daemonset: 'ds',
@@ -1017,6 +1069,7 @@ function formatKindForRef(kind: string): string {
1017
1069
  httproute: 'hr',
1018
1070
  grpcroute: 'grpc',
1019
1071
  tcproute: 'tcp',
1072
+ udproute: 'udp',
1020
1073
  tlsroute: 'tls',
1021
1074
  secret: 'secret',
1022
1075
  pod: 'pod',
@@ -19,7 +19,9 @@ export type { SelectMenuOption } from './SelectMenu'
19
19
  export { Input } from './Input'
20
20
  export { FilterPill } from './FilterPill'
21
21
  export type { FilterPillTone } from './FilterPill'
22
- export { StatusDot, mapHealthToTone } from './status-tone'
22
+ export { StatusDot, mapHealthToTone, toneFillClass, toneTextClass, worseTone } from './status-tone'
23
+ export { RefLink, type NavigateToRef } from './RefLink'
24
+ export { FoldSection, SectionHeading, type FoldSummary } from './FoldSection'
23
25
  export type { StatusTone, StatusDotProps } from './status-tone'
24
26
  export { DialogPortal } from './DialogPortal'
25
27
  export { ConfirmDialog } from './ConfirmDialog'