@skyhook-io/radar-app 1.9.7 → 1.11.0

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 (41) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +2 -6
  3. package/src/api/client.ts +138 -6
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +187 -0
  6. package/src/components/home/HomeView.tsx +15 -3
  7. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  8. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  9. package/src/components/home/TopologyPreview.tsx +57 -11
  10. package/src/components/home/mcpToolCatalog.ts +15 -5
  11. package/src/components/resources/CompositeRenderer.tsx +60 -3
  12. package/src/components/resources/ResourcesView.tsx +15 -3
  13. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  14. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  15. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  16. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  17. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  18. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  19. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  20. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  21. package/src/components/resources/renderers/PodRenderer.tsx +8 -0
  22. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  23. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  24. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  25. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  26. package/src/components/resources/renderers/WorkloadRenderer.tsx +9 -0
  27. package/src/components/resources/renderers/index.ts +1 -0
  28. package/src/components/traffic/TrafficFilterSidebar.tsx +38 -21
  29. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  30. package/src/components/traffic/TrafficGraph.tsx +155 -63
  31. package/src/components/traffic/TrafficView.tsx +168 -52
  32. package/src/components/traffic/TrafficWizard.tsx +13 -1
  33. package/src/components/traffic/trafficFilters.test.ts +103 -0
  34. package/src/components/traffic/trafficFilters.ts +117 -0
  35. package/src/components/ui/DiagnosticsOverlay.test.ts +75 -0
  36. package/src/components/ui/DiagnosticsOverlay.tsx +47 -2
  37. package/src/components/workload/WorkloadView.tsx +55 -7
  38. package/src/utils/navigation.ts +44 -2
  39. package/src/utils/network-policy-navigation.test.ts +68 -0
  40. package/src/utils/topology-selection.test.ts +40 -0
  41. package/src/utils/topology-selection.ts +39 -0
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { formatEnvValue, formatForGitHub } from './DiagnosticsOverlay'
4
+ import type { DiagnosticsSnapshot } from '../../api/client'
5
+
6
+ const baseSnapshot: DiagnosticsSnapshot = {
7
+ timestamp: '2026-08-19T10:00:00Z',
8
+ radarVersion: '1.9.0',
9
+ goVersion: 'go1.26.5',
10
+ goos: 'linux',
11
+ goarch: 'amd64',
12
+ uptime: '11s',
13
+ uptimeSec: 11,
14
+ }
15
+
16
+ const desktop: NonNullable<DiagnosticsSnapshot['desktop']> = {
17
+ sessionType: 'wayland',
18
+ desktopEnvironment: 'ubuntu:GNOME',
19
+ displayServer: 'wayland+x11',
20
+ renderOverrides: [
21
+ { key: 'GDK_BACKEND', value: '', set: false },
22
+ { key: 'WEBKIT_DISABLE_DMABUF_RENDERER', value: '1', set: true },
23
+ { key: 'WEBKIT_DISABLE_COMPOSITING_MODE', value: '', set: true },
24
+ ],
25
+ sandbox: [{ key: 'SNAP', value: '/snap/radar-desktop/42', set: true }],
26
+ webkitLibrary: 'libwebkit2gtk-4.1.so.0.13.6',
27
+ gpuPolicy: 'on-demand',
28
+ }
29
+
30
+ describe('formatEnvValue', () => {
31
+ it('separates a variable that was never set from one set to empty', () => {
32
+ // Only the second suppresses the desktop app's own WebKit defaults, so
33
+ // collapsing them would hide the cause of a rendering failure.
34
+ expect(formatEnvValue({ key: 'GDK_BACKEND', value: '', set: false })).toBe('(unset)')
35
+ expect(formatEnvValue({ key: 'GDK_BACKEND', value: '', set: true })).toBe('(empty)')
36
+ })
37
+
38
+ it('passes a real value through unchanged', () => {
39
+ expect(formatEnvValue({ key: 'GTK_THEME', value: 'Adwaita:dark', set: true })).toBe('Adwaita:dark')
40
+ })
41
+ })
42
+
43
+ describe('formatForGitHub desktop section', () => {
44
+ it('omits the section entirely when the backend reports no desktop data', () => {
45
+ expect(formatForGitHub(baseSnapshot, undefined, false)).not.toContain('### Desktop')
46
+ })
47
+
48
+ it('reports the host rendering environment', () => {
49
+ const md = formatForGitHub({ ...baseSnapshot, desktop }, undefined, false)
50
+
51
+ expect(md).toContain('### Desktop')
52
+ expect(md).toContain('Display Server: `wayland+x11`')
53
+ expect(md).toContain('Session Type: `wayland`')
54
+ expect(md).toContain('Desktop: `ubuntu:GNOME`')
55
+ expect(md).toContain('Webview Library: `libwebkit2gtk-4.1.so.0.13.6`')
56
+ expect(md).toContain('Webview GPU Policy: `on-demand`')
57
+ expect(md).toContain('Sandbox: `SNAP=/snap/radar-desktop/42`')
58
+ })
59
+
60
+ it('renders each override state distinguishably', () => {
61
+ const md = formatForGitHub({ ...baseSnapshot, desktop }, undefined, false)
62
+
63
+ expect(md).toContain('`GDK_BACKEND=(unset)`')
64
+ expect(md).toContain('`WEBKIT_DISABLE_DMABUF_RENDERER=1`')
65
+ expect(md).toContain('`WEBKIT_DISABLE_COMPOSITING_MODE=(empty)`')
66
+ })
67
+
68
+ it('degrades to placeholders rather than blanks when the host reports nothing', () => {
69
+ const md = formatForGitHub({ ...baseSnapshot, desktop: {} }, undefined, false)
70
+
71
+ expect(md).toContain('Display Server: `(none)`')
72
+ expect(md).toContain('Session Type: `(unset)`')
73
+ expect(md).toContain('Desktop: `(unset)`')
74
+ })
75
+ })
@@ -4,7 +4,7 @@ import { clsx } from 'clsx'
4
4
  import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
5
5
  import { openExternal } from '../../utils/navigation'
6
6
  import { useDiagnostics } from '../../api/client'
7
- import type { DiagnosticsSnapshot, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase, DiagSampleWindow } from '../../api/client'
7
+ import type { DiagnosticsSnapshot, DiagEnvVar, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase, DiagSampleWindow } from '../../api/client'
8
8
  import { getK8sUIPerfSnapshot, type K8sUIPerfSnapshot } from '@skyhook-io/k8s-ui'
9
9
 
10
10
  interface DiagnosticsOverlayProps {
@@ -119,6 +119,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
119
119
  <PermissionsSection data={data} />
120
120
  <APIDiscoverySection data={data} />
121
121
  <PerfSection data={data} />
122
+ <DesktopSection data={data} />
122
123
  <RuntimeSection data={data} />
123
124
  <ConfigSection data={data} />
124
125
  {data.errors && data.errors.length > 0 && (
@@ -529,6 +530,35 @@ function formatFrontendUs(w: { count: number; last: number; p50: number; p95: nu
529
530
  return `last ${fmt(w.last)} · p50 ${fmt(w.p50)} · p95 ${fmt(w.p95)} · max ${fmt(w.max)} (n=${w.count})`
530
531
  }
531
532
 
533
+ // An override present but empty is not the same as one that was never set —
534
+ // merely existing is enough to suppress the desktop app's WebKit defaults.
535
+ export function formatEnvValue(v: DiagEnvVar): string {
536
+ if (!v.set) return '(unset)'
537
+ return v.value === '' ? '(empty)' : v.value
538
+ }
539
+
540
+ function DesktopSection({ data }: { data: DiagnosticsSnapshot }) {
541
+ if (!data.desktop) return null
542
+ const d = data.desktop
543
+ const overrides = d.renderOverrides ?? []
544
+ const sandbox = d.sandbox ?? []
545
+ return (
546
+ <Section title="Desktop">
547
+ {d.displayServer && <Row label="Display Server" value={d.displayServer} />}
548
+ <Row label="Session Type" value={d.sessionType || '(unset)'} />
549
+ <Row label="Desktop Environment" value={d.desktopEnvironment || '(unset)'} />
550
+ {d.webkitLibrary && <Row label="Webview Library" value={d.webkitLibrary} />}
551
+ {d.gpuPolicy && <Row label="Webview GPU Policy" value={d.gpuPolicy} />}
552
+ {overrides.map((v) => (
553
+ <Row key={v.key} label={v.key} value={formatEnvValue(v)} />
554
+ ))}
555
+ {sandbox.map((v) => (
556
+ <Row key={v.key} label={`Sandbox: ${v.key}`} value={v.value} />
557
+ ))}
558
+ </Section>
559
+ )
560
+ }
561
+
532
562
  function RuntimeSection({ data }: { data: DiagnosticsSnapshot }) {
533
563
  if (!data.runtime) return null
534
564
  const rt = data.runtime
@@ -580,7 +610,7 @@ function CopyButton({ label, onClick, copied }: { label: string; onClick: () =>
580
610
 
581
611
  // --- GitHub-friendly formatting ---
582
612
 
583
- function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnapshot, includeRawJson = true): string {
613
+ export function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnapshot, includeRawJson = true): string {
584
614
  const lines: string[] = []
585
615
  lines.push(`## Radar Diagnostics`)
586
616
  lines.push(``)
@@ -758,6 +788,21 @@ function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnap
758
788
  lines.push(``)
759
789
  }
760
790
 
791
+ if (data.desktop) {
792
+ const d = data.desktop
793
+ lines.push(`### Desktop`)
794
+ lines.push(`- Display Server: \`${d.displayServer || '(none)'}\` | Session Type: \`${d.sessionType || '(unset)'}\` | Desktop: \`${d.desktopEnvironment || '(unset)'}\``)
795
+ if (d.webkitLibrary) lines.push(`- Webview Library: \`${d.webkitLibrary}\``)
796
+ if (d.gpuPolicy) lines.push(`- Webview GPU Policy: \`${d.gpuPolicy}\``)
797
+ if (d.renderOverrides && d.renderOverrides.length > 0) {
798
+ lines.push(`- Render Overrides: ${d.renderOverrides.map((v) => `\`${v.key}=${formatEnvValue(v)}\``).join(' ')}`)
799
+ }
800
+ if (d.sandbox && d.sandbox.length > 0) {
801
+ lines.push(`- Sandbox: ${d.sandbox.map((v) => `\`${v.key}=${v.value}\``).join(' ')}`)
802
+ }
803
+ lines.push(``)
804
+ }
805
+
761
806
  if (data.runtime) {
762
807
  const rt = data.runtime
763
808
  lines.push(`### Runtime`)
@@ -22,6 +22,7 @@ import {
22
22
  gitOpsOwnerFromRelationships,
23
23
  getGitOpsResourceStatus,
24
24
  isDiagnoseKind,
25
+ isRolloutKind,
25
26
  } from '@skyhook-io/k8s-ui'
26
27
  import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
27
28
  import type { SelectedResource, ResourceRef, Relationships } from '../../types'
@@ -46,6 +47,8 @@ import {
46
47
  useRestartWorkload,
47
48
  useWorkloadRevisions,
48
49
  useRollbackWorkload,
50
+ useRolloutAction,
51
+ useRolloutCapabilities,
49
52
  useWorkloadPods,
50
53
  useFluxReconcile,
51
54
  useFluxSyncWithSource,
@@ -109,6 +112,20 @@ import { RoleBindingRenderer } from '../resources/renderers/RoleBindingRenderer'
109
112
  import { NamespaceRenderer } from '../resources/renderers/NamespaceRenderer'
110
113
  import { HPARenderer } from '../resources/renderers/HPARenderer'
111
114
  import { PVCRenderer } from '../resources/renderers/PVCRenderer'
115
+ import { RolloutRenderer } from '../resources/renderers/RolloutRenderer'
116
+ import { KyvernoPolicyCoverage } from '../resources/renderers/KyvernoPolicyCoverage'
117
+ import { KyvernoPolicyQueued } from '../resources/renderers/KyvernoPolicyQueued'
118
+ import { CNPGObjectStoreRenderer } from '../resources/renderers/CNPGObjectStoreRenderer'
119
+ import { VeleroBSLRenderer } from '../resources/renderers/VeleroBSLRenderer'
120
+ import { VeleroBackupRenderer } from '../resources/renderers/VeleroBackupRenderer'
121
+ import { VeleroRestoreRenderer } from '../resources/renderers/VeleroRestoreRenderer'
122
+ import { CNPGClusterRenderer } from '../resources/renderers/CNPGClusterRenderer'
123
+ import { CNPGImageCatalogRenderer } from '../resources/renderers/CNPGImageCatalogRenderer'
124
+ import {
125
+ CNPGDatabaseRenderer,
126
+ CNPGPublicationRenderer,
127
+ CNPGSubscriptionRenderer,
128
+ } from '../resources/renderers/CNPGDeclarativeRenderer'
112
129
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
113
130
  import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
114
131
  import { useDesktopDownload } from '../../hooks/useDesktopDownload'
@@ -141,6 +158,18 @@ const rendererOverrides: RendererOverrides = {
141
158
  NamespaceRenderer,
142
159
  HPARenderer,
143
160
  PVCRenderer,
161
+ RolloutRenderer,
162
+ KyvernoPolicyCoverage,
163
+ KyvernoPolicyQueued,
164
+ CNPGObjectStoreRenderer,
165
+ VeleroBSLRenderer,
166
+ VeleroBackupRenderer,
167
+ VeleroRestoreRenderer,
168
+ CNPGClusterRenderer,
169
+ CNPGDatabaseRenderer,
170
+ CNPGPublicationRenderer,
171
+ CNPGSubscriptionRenderer,
172
+ CNPGImageCatalogRenderer,
144
173
  }
145
174
 
146
175
  // ============================================================================
@@ -269,11 +298,19 @@ function useActionsBarProps(
269
298
  const deleteMutation = useDeleteResource()
270
299
  const restartWorkloadMutation = useRestartWorkload()
271
300
  const rollbackMutation = useRollbackWorkload()
301
+ const rolloutActionMutation = useRolloutAction()
272
302
  const triggerCronJobMutation = useTriggerCronJob()
273
303
  const suspendCronJobMutation = useSuspendCronJob()
274
304
  const resumeCronJobMutation = useResumeCronJob()
275
305
 
276
- const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets'].includes(kind.toLowerCase())
306
+ const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(kind.toLowerCase())
307
+ const isRollout = isRolloutKind(kind)
308
+ const { data: rolloutCapabilities } = useRolloutCapabilities(namespace, name, isRollout)
309
+ // Restart and Rollback are the generic workload buttons, so a Rollout has to
310
+ // withhold the callback the way promote-full does. Permissive until the probe
311
+ // answers — withholding while it loads would flicker the shared buttons.
312
+ const rolloutAllows = (verb: 'restart' | 'rollback') =>
313
+ !isRollout || !rolloutCapabilities || rolloutCapabilities[verb]
277
314
  const {
278
315
  data: revisionsList,
279
316
  isLoading: revisionsLoading,
@@ -339,17 +376,28 @@ function useActionsBarProps(
339
376
  cascadeDependents: cascadePreview?.dependents,
340
377
  cascadeLoading,
341
378
  cascadeRootResolved: cascadeError ? false : cascadePreview?.rootResolved,
342
- onRestart: (params: Parameters<typeof restartWorkloadMutation.mutate>[0]) =>
343
- restartWorkloadMutation.mutate(params),
379
+ onRestart: rolloutAllows('restart')
380
+ ? (params: Parameters<typeof restartWorkloadMutation.mutate>[0]) =>
381
+ restartWorkloadMutation.mutate(params)
382
+ : undefined,
344
383
  isRestarting: restartWorkloadMutation.isPending,
345
384
  revisions: revisionsList,
346
385
  revisionsLoading,
347
386
  revisionsError: revisionsError ?? null,
348
- onRollback: (
349
- params: Parameters<typeof rollbackMutation.mutate>[0],
350
- callbacks?: { onSuccess?: () => void },
351
- ) => rollbackMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
387
+ onRollback: rolloutAllows('rollback')
388
+ ? (
389
+ params: Parameters<typeof rollbackMutation.mutate>[0],
390
+ callbacks?: { onSuccess?: () => void },
391
+ ) => rollbackMutation.mutate(params, { onSuccess: callbacks?.onSuccess })
392
+ : undefined,
352
393
  isRollingBack: rollbackMutation.isPending,
394
+ // Absent when promote-full is denied — the dialog reads the callback's
395
+ // presence as the permission signal and hides the option.
396
+ // mutateAsync, not mutate: the revision dialog awaits this before closing.
397
+ onRolloutPromoteFull: rolloutCapabilities?.promoteFull
398
+ ? (params: { namespace: string; name: string }) =>
399
+ rolloutActionMutation.mutateAsync({ action: 'promote-full', ...params })
400
+ : undefined,
353
401
  onTriggerCronJob: (params: Parameters<typeof triggerCronJobMutation.mutate>[0]) =>
354
402
  triggerCronJobMutation.mutate(params),
355
403
  isTriggeringCronJob: triggerCronJobMutation.isPending,
@@ -1,6 +1,6 @@
1
1
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
2
- import { kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
3
- import type { SelectedResource } from '@skyhook-io/k8s-ui/types/core'
2
+ import { apiVersionToGroup, kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
3
+ import type { SelectedResource, Topology } from '@skyhook-io/k8s-ui/types/core'
4
4
  import type { SearchHit } from '../api/client'
5
5
 
6
6
  /**
@@ -17,6 +17,48 @@ export function searchHitToSelectedResource(hit: SearchHit): SelectedResource {
17
17
  export { kindToPlural, pluralToKind, refToSelectedResource, apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
18
18
  export type { NavigateToResource } from '@skyhook-io/k8s-ui/utils/navigation'
19
19
 
20
+ const NETWORK_POLICY_TOPOLOGY_KINDS = new Set([
21
+ 'NetworkPolicy',
22
+ 'CalicoNetworkPolicy',
23
+ 'CalicoGlobalNetworkPolicy',
24
+ 'CalicoStagedNetworkPolicy',
25
+ 'CalicoStagedGlobalNetworkPolicy',
26
+ 'CalicoStagedKubernetesNetworkPolicy',
27
+ 'CiliumNetworkPolicy',
28
+ 'CiliumClusterwideNetworkPolicy',
29
+ 'ClusterNetworkPolicy',
30
+ ])
31
+
32
+ function networkPolicyGroup(node: Topology['nodes'][number]): string | undefined {
33
+ const apiVersionGroup = apiVersionToGroup(node.data.apiVersion as string | undefined)
34
+ if (apiVersionGroup) return apiVersionGroup
35
+
36
+ const sourceGroup = node.data.sourceGroup
37
+ if (typeof sourceGroup === 'string' && sourceGroup) return sourceGroup
38
+
39
+ if (node.kind === 'NetworkPolicy') return 'networking.k8s.io'
40
+ return undefined
41
+ }
42
+
43
+ /** Return a resource route only when the policy aggregate has one target. */
44
+ export function getNetworkPolicyResourceTarget(topology: Topology | null): { kind: string; group?: string } | undefined {
45
+ const targets = new Map<string, { kind: string; group?: string }>()
46
+ for (const node of topology?.nodes ?? []) {
47
+ if (!NETWORK_POLICY_TOPOLOGY_KINDS.has(node.kind)) continue
48
+
49
+ const group = networkPolicyGroup(node)
50
+ const target = {
51
+ kind: kindToPlural(node.kind),
52
+ ...(group ? { group } : {}),
53
+ }
54
+ targets.set(`${target.kind}\u0000${target.group ?? ''}`, target)
55
+ }
56
+
57
+ if (targets.size !== 1) return undefined
58
+ for (const target of targets.values()) return target
59
+ return undefined
60
+ }
61
+
20
62
  /**
21
63
  * Build a /workload/:kind/:namespace/:name URL, preserving the API group as a
22
64
  * query param so the WorkloadView can resolve CRDs with colliding kind names.
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Topology, TopologyNode } from '@skyhook-io/k8s-ui/types/core'
3
+ import { getNetworkPolicyResourceTarget } from './navigation'
4
+
5
+ function node(kind: string, apiVersion?: string): TopologyNode {
6
+ return {
7
+ id: `${kind}/default/policy`,
8
+ kind,
9
+ name: 'policy',
10
+ status: 'healthy',
11
+ data: { namespace: 'default', ...(apiVersion ? { apiVersion } : {}) },
12
+ } as TopologyNode
13
+ }
14
+
15
+ function topology(...nodes: TopologyNode[]): Topology {
16
+ return { nodes, edges: [] }
17
+ }
18
+
19
+ describe('getNetworkPolicyResourceTarget', () => {
20
+ it('routes a Calico policy to its API group instead of the native policy list', () => {
21
+ expect(getNetworkPolicyResourceTarget(topology(node('CalicoNetworkPolicy', 'projectcalico.org/v3')))).toEqual({
22
+ kind: 'networkpolicies',
23
+ group: 'projectcalico.org',
24
+ })
25
+ })
26
+
27
+ it('uses the Calico policy kind when the aggregate only contains global policies', () => {
28
+ expect(getNetworkPolicyResourceTarget(topology(node('CalicoGlobalNetworkPolicy', 'crd.projectcalico.org/v1')))).toEqual({
29
+ kind: 'globalnetworkpolicies',
30
+ group: 'crd.projectcalico.org',
31
+ })
32
+ })
33
+
34
+ it('routes staged Kubernetes Calico policies to their exact API group', () => {
35
+ expect(getNetworkPolicyResourceTarget(topology(node('CalicoStagedKubernetesNetworkPolicy', 'crd.projectcalico.org/v1')))).toEqual({
36
+ kind: 'stagedkubernetesnetworkpolicies',
37
+ group: 'crd.projectcalico.org',
38
+ })
39
+ })
40
+
41
+ it('uses the generic resources view when the aggregate contains multiple policy targets', () => {
42
+ expect(getNetworkPolicyResourceTarget(topology(
43
+ node('NetworkPolicy', 'networking.k8s.io/v1'),
44
+ node('CalicoNetworkPolicy', 'crd.projectcalico.org/v1'),
45
+ ))).toBeUndefined()
46
+ })
47
+
48
+ it('keeps a stable route when multiple policies share one kind and group', () => {
49
+ expect(getNetworkPolicyResourceTarget(topology(
50
+ node('CalicoNetworkPolicy', 'crd.projectcalico.org/v1'),
51
+ node('CalicoNetworkPolicy', 'crd.projectcalico.org/v1'),
52
+ ))).toEqual({
53
+ kind: 'networkpolicies',
54
+ group: 'crd.projectcalico.org',
55
+ })
56
+ })
57
+
58
+ it('preserves the native NetworkPolicy API group when apiVersion is absent', () => {
59
+ expect(getNetworkPolicyResourceTarget(topology(node('NetworkPolicy')))).toEqual({
60
+ kind: 'networkpolicies',
61
+ group: 'networking.k8s.io',
62
+ })
63
+ })
64
+
65
+ it('returns no targeted route when topology has not identified a policy', () => {
66
+ expect(getNetworkPolicyResourceTarget(topology(node('Deployment')))).toBeUndefined()
67
+ })
68
+ })
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { SelectedResource, Topology, TopologyNode } from '@skyhook-io/k8s-ui/types/core'
3
+ import { findSelectedTopologyNode } from './topology-selection'
4
+
5
+ function node(id: string, kind: string, name: string, apiVersion?: string): TopologyNode {
6
+ return {
7
+ id,
8
+ kind,
9
+ name,
10
+ status: 'healthy',
11
+ data: { namespace: 'default', ...(apiVersion ? { apiVersion } : {}) },
12
+ } as TopologyNode
13
+ }
14
+
15
+ function selected(group?: string, kind = 'networkpolicies'): SelectedResource {
16
+ return { kind, namespace: 'default', name: 'policy', group }
17
+ }
18
+
19
+ describe('findSelectedTopologyNode', () => {
20
+ it('prefers the node from the selected API group when names collide', () => {
21
+ const native = node('native', 'NetworkPolicy', 'policy')
22
+ const calico = node('calico', 'CalicoNetworkPolicy', 'policy', 'projectcalico.org/v3')
23
+
24
+ expect(findSelectedTopologyNode({ nodes: [native, calico], edges: [] }, selected('projectcalico.org'))?.id).toBe('calico')
25
+ })
26
+
27
+ it('keeps native NetworkPolicy selection working without an apiVersion on the node', () => {
28
+ const native = node('native', 'NetworkPolicy', 'policy')
29
+
30
+ expect(findSelectedTopologyNode({ nodes: [native], edges: [] }, selected('networking.k8s.io'))?.id).toBe('native')
31
+ })
32
+
33
+ it('matches arbitrary same-name nodes by API group when both groups are present', () => {
34
+ const first = node('first', 'Widget', 'policy', 'one.example/v1')
35
+ const second = node('second', 'Widget', 'policy', 'two.example/v1')
36
+ const topology: Topology = { nodes: [first, second], edges: [] }
37
+
38
+ expect(findSelectedTopologyNode(topology, selected('two.example', 'widgets'))?.id).toBe('second')
39
+ })
40
+ })
@@ -0,0 +1,39 @@
1
+ import type { SelectedResource, Topology, TopologyNode } from '@skyhook-io/k8s-ui/types/core'
2
+ import { apiVersionToGroup, kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
3
+
4
+ function topologyNodeGroup(node: TopologyNode): string | undefined {
5
+ const apiVersionGroup = apiVersionToGroup(node.data.apiVersion as string | undefined)
6
+ if (apiVersionGroup) return apiVersionGroup
7
+
8
+ const sourceGroup = node.data.sourceGroup
9
+ if (typeof sourceGroup === 'string' && sourceGroup) return sourceGroup
10
+
11
+ // Native NetworkPolicy nodes predate apiVersion in the topology payload.
12
+ if (node.kind === 'NetworkPolicy') return 'networking.k8s.io'
13
+ return undefined
14
+ }
15
+
16
+ export function findSelectedTopologyNode(
17
+ topology: Topology | null,
18
+ selectedResource: SelectedResource,
19
+ ): TopologyNode | undefined {
20
+ const namespace = selectedResource.namespace || ''
21
+ const candidates = topology?.nodes.filter(node =>
22
+ ((node.data.namespace as string) || '') === namespace &&
23
+ node.name === selectedResource.name &&
24
+ (kindToPlural(node.kind) === selectedResource.kind || node.kind === selectedResource.kind),
25
+ ) ?? []
26
+
27
+ if (candidates.length === 0) return undefined
28
+
29
+ const selectedGroup = selectedResource.group || undefined
30
+ if (!selectedGroup) return candidates[0]
31
+
32
+ const groupMatch = candidates.find(node => topologyNodeGroup(node) === selectedGroup)
33
+ if (groupMatch) return groupMatch
34
+
35
+ // Preserve the old name/kind/ns behavior only when the topology has no group
36
+ // information to compare. If it does, returning no match is safer than
37
+ // highlighting a resource from another API group.
38
+ return candidates.some(node => topologyNodeGroup(node) !== undefined) ? undefined : candidates[0]
39
+ }