@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.
- package/package.json +6 -6
- package/src/App.tsx +2 -6
- package/src/api/client.ts +138 -6
- package/src/api/policy.test.ts +38 -0
- package/src/api/policy.ts +187 -0
- package/src/components/home/HomeView.tsx +15 -3
- package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
- package/src/components/home/TopologyPreview.tsx +57 -11
- package/src/components/home/mcpToolCatalog.ts +15 -5
- package/src/components/resources/CompositeRenderer.tsx +60 -3
- package/src/components/resources/ResourcesView.tsx +15 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
- package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
- package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
- package/src/components/resources/renderers/PodRenderer.tsx +8 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +9 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +38 -21
- package/src/components/traffic/TrafficFlowList.tsx +16 -2
- package/src/components/traffic/TrafficGraph.tsx +155 -63
- package/src/components/traffic/TrafficView.tsx +168 -52
- package/src/components/traffic/TrafficWizard.tsx +13 -1
- package/src/components/traffic/trafficFilters.test.ts +103 -0
- package/src/components/traffic/trafficFilters.ts +117 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +75 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +47 -2
- package/src/components/workload/WorkloadView.tsx +55 -7
- package/src/utils/navigation.ts +44 -2
- package/src/utils/network-policy-navigation.test.ts +68 -0
- package/src/utils/topology-selection.test.ts +40 -0
- 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: (
|
|
343
|
-
restartWorkloadMutation.mutate
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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,
|
package/src/utils/navigation.ts
CHANGED
|
@@ -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
|
+
}
|