@skyhook-io/radar-app 1.14.5 → 1.15.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 (117) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +142 -334
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.admission.test.ts +34 -0
  6. package/src/api/client.jobset.test.ts +18 -0
  7. package/src/api/client.resource-identity.test.ts +44 -0
  8. package/src/api/client.rightsizing.test.ts +7 -3
  9. package/src/api/client.ts +267 -78
  10. package/src/api/preferences.test.ts +54 -0
  11. package/src/api/preferences.ts +38 -0
  12. package/src/api/usage-data.fixtures.ts +33 -0
  13. package/src/api/usage-data.ts +165 -0
  14. package/src/components/CloudFunnelButton.test.tsx +155 -0
  15. package/src/components/CloudFunnelButton.tsx +113 -63
  16. package/src/components/ContextSwitcher.tsx +13 -132
  17. package/src/components/SelfManagedStart.tsx +96 -0
  18. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  19. package/src/components/applications/ApplicationsView.tsx +5 -3
  20. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  21. package/src/components/cloudConnectHandoff.test.ts +1 -1
  22. package/src/components/cloudConnectHandoff.ts +1 -1
  23. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  24. package/src/components/diagnose/AgentControls.tsx +26 -3
  25. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  26. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  27. package/src/components/diagnose/agentCatalog.ts +6 -0
  28. package/src/components/diagnose/launch.test.ts +23 -0
  29. package/src/components/diagnose/launch.ts +4 -0
  30. package/src/components/diagnose/parts.test.tsx +18 -0
  31. package/src/components/dialog-names.test.ts +69 -0
  32. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  33. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  34. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  35. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  36. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  37. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  38. package/src/components/execution/BatchExecutionView.tsx +324 -65
  39. package/src/components/execution/JobSetAdmission.test.tsx +165 -0
  40. package/src/components/execution/JobSetAdmission.tsx +26 -0
  41. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  42. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  43. package/src/components/execution/batch-run-actions.test.ts +1 -0
  44. package/src/components/execution/batch-timeline.test.ts +3 -0
  45. package/src/components/execution/execution-definition.test.ts +15 -1
  46. package/src/components/execution/execution-definition.ts +4 -0
  47. package/src/components/execution/member-collection.render.test.tsx +142 -0
  48. package/src/components/gitops/GitOpsView.tsx +33 -2
  49. package/src/components/gitops/destination-toast.test.ts +33 -0
  50. package/src/components/gitops/destination-toast.ts +46 -0
  51. package/src/components/gitops/useDestinationCluster.ts +30 -0
  52. package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
  53. package/src/components/home/HomeView.tsx +6 -2
  54. package/src/components/home/MCPSetupDialog.tsx +10 -0
  55. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  56. package/src/components/home/RadarVersionLine.tsx +28 -3
  57. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  58. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  59. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  60. package/src/components/nav/PrimaryNavRail.tsx +27 -8
  61. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  62. package/src/components/resource/PVCUsageBar.tsx +62 -16
  63. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  64. package/src/components/resources/ResourcesView.tsx +3 -3
  65. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  66. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  67. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  70. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  71. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  75. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  76. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  77. package/src/components/rightsizing/copy.test.ts +7 -0
  78. package/src/components/rightsizing/model.test.ts +2 -1
  79. package/src/components/rightsizing/notices.test.tsx +4 -3
  80. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  81. package/src/components/settings/PrivacySection.test.tsx +84 -0
  82. package/src/components/settings/PrivacySection.tsx +140 -0
  83. package/src/components/settings/SettingsDialog.tsx +168 -92
  84. package/src/components/settings/controls.tsx +55 -0
  85. package/src/components/settings/settings-state.test.ts +18 -0
  86. package/src/components/settings/settings-state.ts +19 -0
  87. package/src/components/timeline/TimelineView.tsx +9 -3
  88. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  89. package/src/components/ui/ErrorBoundary.tsx +5 -0
  90. package/src/components/ui/Omnibar.tsx +16 -0
  91. package/src/components/ui/UpdateNotification.tsx +13 -9
  92. package/src/components/ui/command-items.ts +14 -3
  93. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  94. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  95. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  96. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  97. package/src/components/useContextSwitchFlow.tsx +147 -0
  98. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  99. package/src/components/whats-new/WhatsNew.tsx +423 -0
  100. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  101. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  102. package/src/components/whats-new/releaseNotes.ts +121 -0
  103. package/src/components/workload/WorkloadView.test.ts +17 -1
  104. package/src/components/workload/WorkloadView.tsx +47 -19
  105. package/src/context/ContextSwitchContext.tsx +18 -2
  106. package/src/context/NavCustomization.tsx +4 -58
  107. package/src/context/ThemeContext.tsx +3 -11
  108. package/src/hooks/useClusterLoadState.ts +2 -2
  109. package/src/hooks/useFavorites.ts +3 -5
  110. package/src/k8s-ui-exports.test.ts +73 -0
  111. package/src/utils/auditBadges.ts +1 -1
  112. package/src/utils/navigation.test.ts +34 -1
  113. package/src/utils/navigation.ts +28 -5
  114. package/src/utils/topology-selection.test.ts +56 -0
  115. package/src/utils/topology-selection.ts +9 -9
  116. package/src/utils/version.ts +11 -0
  117. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -1,15 +1,3 @@
1
- // Slot-based customization of Radar's top nav.
2
- //
3
- // Lets library consumers (e.g. Radar Hub) swap out the brand area, the
4
- // context picker, and append items on the right of the action bar —
5
- // without forking App.tsx or building a parallel nav.
6
- //
7
- // The `embedded` flag hides chrome that only makes sense for Radar's
8
- // standalone OSS binary: GitHub star link, update-from-GitHub notifier,
9
- // Radar's own OIDC/proxy-mode UserMenu. Consumers typically provide
10
- // their own auth UI via `rightExtras`.
11
- //
12
- // Default (no provider): Radar renders its standalone nav unchanged.
13
1
  import { createContext, useContext } from 'react';
14
2
  import type { ReactNode } from 'react';
15
3
 
@@ -21,11 +9,9 @@ import type { ReactNode } from 'react';
21
9
  */
22
10
  export type FleetTakeoverTarget = 'issues' | 'gitops' | 'checks' | 'certs';
23
11
 
24
- interface NavCustomizationBase {
25
- /** Replaces Radar's Skyhook/radar logo + wordmark. */
26
- brandSlot?: ReactNode;
27
- /** Replaces the ContextSwitcher (kubeconfig-context picker). */
28
- contextSlot?: ReactNode;
12
+ export interface NavCustomization {
13
+ /** Radar Hub owns all chrome; Radar renders only the active view. */
14
+ embedded?: boolean;
29
15
  /**
30
16
  * When set, a "Compare across clusters" option is added to the Compare
31
17
  * button in resource action bars. The host returns the URL that should
@@ -50,8 +36,7 @@ interface NavCustomizationBase {
50
36
  * canonical surfaces rather than a second, diverging per-cluster copy:
51
37
  * - 'issues' → the Active Issues panel + cluster-health issues count
52
38
  * - 'gitops' → the GitOps controllers card
53
- * - 'checks' → the Cluster Audit card (and any route to /audit; legacy
54
- * `clusterChecksHref` folded in here)
39
+ * - 'checks' → the Cluster Audit card (and any route to /audit)
55
40
  * - 'certs' → the Certificate Health card
56
41
  *
57
42
  * View-shaped targets (issues / gitops / checks) are honored for every entry:
@@ -64,13 +49,6 @@ interface NavCustomizationBase {
64
49
  * falls back to `window.location` (a hard reload).
65
50
  */
66
51
  fleetTakeoverHref?: (target: FleetTakeoverTarget) => string | undefined;
67
- /**
68
- * @deprecated Superseded by `fleetTakeoverHref('checks')`. Kept so consumers
69
- * still on the pre-1.7 hook keep working (App.tsx folds it into the 'checks'
70
- * target) — this makes adding `fleetTakeoverHref` an additive, non-breaking
71
- * change. Remove in a major release once all consumers have migrated.
72
- */
73
- clusterChecksHref?: () => string;
74
52
  /**
75
53
  * Optional smooth navigator for host-owned URLs. When the host takes a
76
54
  * destination over (`fleetTakeoverHref`, `crossClusterCompareHref`), Radar
@@ -82,40 +60,8 @@ interface NavCustomizationBase {
82
60
  * OSS / other hosts are unaffected.
83
61
  */
84
62
  onHostNavigate?: (url: string) => void;
85
- /**
86
- * Chrome level for embedded hosts. Default ('full', or omitted) renders
87
- * Radar's top bar + the view-switcher. 'none' suppresses BOTH — the host
88
- * drives view navigation and cluster/namespace scope from its OWN chrome, and
89
- * Radar renders just the active view's content full-bleed. Radar Hub uses this
90
- * to surface per-cluster views (Topology / Resources / Traffic / Cost) that
91
- * don't aggregate to the fleet as native cloud destinations under one chrome,
92
- * gated by a cluster picker — instead of a second, redundant in-cluster nav.
93
- * Only meaningful with `embedded: true`.
94
- */
95
- chrome?: 'full' | 'none';
96
63
  }
97
64
 
98
- /**
99
- * Slot-based customization of Radar's top nav.
100
- *
101
- * Standalone-mode consumers pass `embedded: false` (or omit it) and may
102
- * optionally append items via `rightExtras`. Embedded-mode consumers must
103
- * supply `rightExtras` — Radar's OSS chrome (GitHub star, update notifier,
104
- * built-in UserMenu) is hidden, so the host app owns the right side of the
105
- * nav and must render its own user/auth UI there.
106
- */
107
- export type NavCustomization =
108
- | (NavCustomizationBase & {
109
- embedded?: false;
110
- /** Appended to the right of the action bar (before the UserMenu). */
111
- rightExtras?: ReactNode;
112
- })
113
- | (NavCustomizationBase & {
114
- embedded: true;
115
- /** Required in embedded mode: Radar's own UserMenu is hidden. */
116
- rightExtras: ReactNode;
117
- });
118
-
119
65
  const NavCustomizationContext = createContext<NavCustomization>({});
120
66
 
121
67
  export function NavCustomizationProvider({
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
2
- import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
2
+ import { loadPreferences, persistPreferences } from '../api/preferences'
3
3
 
4
4
  type Theme = 'dark' | 'light'
5
5
 
@@ -34,14 +34,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
34
34
  const setTheme = (newTheme: Theme) => {
35
35
  setThemeState(newTheme)
36
36
  localStorage.setItem(THEME_STORAGE_KEY, newTheme)
37
- fetch(apiUrl('/settings'), {
38
- method: 'PUT',
39
- credentials: getCredentialsMode(),
40
- headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
41
- body: JSON.stringify({ theme: newTheme }),
42
- }).then((res) => {
43
- if (!res.ok) console.warn('[settings] Failed to persist theme:', res.status)
44
- }).catch((err) => console.warn('[settings] Failed to persist theme:', err))
37
+ persistPreferences({ theme: newTheme }).catch((err) => console.warn('[settings] Failed to persist theme:', err))
45
38
  }
46
39
 
47
40
  const toggleTheme = () => {
@@ -56,8 +49,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
56
49
 
57
50
  // Sync theme from server (persisted settings survive port changes in desktop app)
58
51
  useEffect(() => {
59
- fetch(apiUrl('/settings'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
60
- .then((res) => res.ok ? res.json() : null)
52
+ loadPreferences()
61
53
  .then((data) => {
62
54
  if (data?.theme && (data.theme === 'dark' || data.theme === 'light') && data.theme !== theme) {
63
55
  setThemeState(data.theme)
@@ -21,8 +21,8 @@ interface UseClusterLoadStateResult {
21
21
  }
22
22
 
23
23
  // Tracks cluster-data warmup (deferred/partial dashboard load) once the main
24
- // connection is usable. Standalone / embedded-with-chrome render it in Radar's
25
- // topbar; chromeless hosts (Radar Hub) receive it via onClusterLoadStateChange;
24
+ // connection is usable. Standalone renders it in Radar's topbar; Radar Hub
25
+ // receives it via onClusterLoadStateChange;
26
26
  // a chromeless host without a callback gets a fallback row on Home.
27
27
  export function useClusterLoadState({
28
28
  namespaces,
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback, useEffect } from 'react'
2
- import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
2
+ import { loadPreferences, persistPreferences } from '../api/preferences'
3
3
 
4
4
  export interface PinnedKind {
5
5
  name: string // plural name for API calls, e.g. "pods", "deployments"
@@ -25,8 +25,7 @@ function savePinned(pinned: PinnedKind[]) {
25
25
  } catch {
26
26
  // ignore storage errors
27
27
  }
28
- fetch(apiUrl('/settings'), { method: 'PUT', credentials: getCredentialsMode(), headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ pinnedKinds: pinned }) })
29
- .then((res) => { if (!res.ok) console.warn('[settings] Failed to persist pinned kinds:', res.status) })
28
+ persistPreferences({ pinnedKinds: pinned })
30
29
  .catch((err) => console.warn('[settings] Failed to persist pinned kinds:', err))
31
30
  }
32
31
 
@@ -39,8 +38,7 @@ export function usePinnedKinds() {
39
38
 
40
39
  // Sync from server (persisted settings survive port changes in desktop app)
41
40
  useEffect(() => {
42
- fetch(apiUrl('/settings'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
43
- .then((res) => res.ok ? res.json() : null)
41
+ loadPreferences()
44
42
  .then((data) => {
45
43
  if (data?.pinnedKinds?.length && loadPinned().length === 0) {
46
44
  setPinned(data.pinnedKinds)
@@ -0,0 +1,73 @@
1
+ /// <reference types="node" />
2
+ import { describe, expect, test } from 'vitest'
3
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ // Radar's own build aliases @skyhook-io/k8s-ui to its source, so a deep
8
+ // import that the package's `exports` map can't serve still builds here and
9
+ // only fails in a consumer that installs the published packages (Radar Hub).
10
+ // This resolves every deep import the way Node and bundlers do: the most
11
+ // specific matching key wins, and an array target resolves to its first entry
12
+ // without falling back when that file doesn't exist.
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url))
15
+ const k8sUiRoot = resolve(here, '../../packages/k8s-ui')
16
+ const exportsMap: Record<string, string | string[]> = JSON.parse(
17
+ readFileSync(join(k8sUiRoot, 'package.json'), 'utf8'),
18
+ ).exports
19
+
20
+ function resolveExport(subpath: string): string | undefined {
21
+ const key = `./${subpath}`
22
+ let target = exportsMap[key]
23
+ let star: string | undefined
24
+ if (target === undefined) {
25
+ let best = ''
26
+ for (const pattern of Object.keys(exportsMap)) {
27
+ const i = pattern.indexOf('*')
28
+ if (i < 0) continue
29
+ const prefix = pattern.slice(0, i)
30
+ const suffix = pattern.slice(i + 1)
31
+ if (key.startsWith(prefix) && key.endsWith(suffix) && key.length >= prefix.length + suffix.length && prefix.length > best.length) {
32
+ best = prefix
33
+ target = exportsMap[pattern]
34
+ star = key.slice(prefix.length, key.length - suffix.length)
35
+ }
36
+ }
37
+ }
38
+ if (target === undefined) return undefined
39
+ const first = Array.isArray(target) ? target[0] : target
40
+ return join(k8sUiRoot, star === undefined ? first : first.split('*').join(star))
41
+ }
42
+
43
+ // Everything radar-app publishes that can import from k8s-ui, stylesheets
44
+ // included (vitest doesn't load CSS, so these are read from disk).
45
+ function sourceFiles(dir: string): string[] {
46
+ return readdirSync(dir).flatMap((name) => {
47
+ const path = join(dir, name)
48
+ if (statSync(path).isDirectory()) return sourceFiles(path)
49
+ return /\.(ts|tsx|css)$/.test(name) && !/\.test\.(ts|tsx)$/.test(name) ? [path] : []
50
+ })
51
+ }
52
+
53
+ describe('@skyhook-io/k8s-ui deep imports', () => {
54
+ const imports = new Set<string>()
55
+ for (const file of sourceFiles(here)) {
56
+ // Any quoted specifier: `from` imports, side-effect and dynamic imports,
57
+ // and stylesheet @imports all resolve through the same map.
58
+ for (const m of readFileSync(file, 'utf8').matchAll(/['"]@skyhook-io\/k8s-ui\/([^'"]+)['"]/g)) {
59
+ imports.add(m[1])
60
+ }
61
+ }
62
+
63
+ test('finds the deep imports it checks, stylesheets included', () => {
64
+ expect(imports.size).toBeGreaterThan(10)
65
+ expect(imports).toContain('theme/variables.css')
66
+ })
67
+
68
+ test.each([...imports].sort())('%s resolves through the published exports map', (subpath) => {
69
+ const file = resolveExport(subpath)
70
+ expect(file, `no exports entry serves "@skyhook-io/k8s-ui/${subpath}"`).toBeDefined()
71
+ expect(existsSync(file!), `"@skyhook-io/k8s-ui/${subpath}" resolves to missing ${file}`).toBe(true)
72
+ })
73
+ })
@@ -30,7 +30,7 @@ export function isBadgeWorthy(
30
30
  /**
31
31
  * buildAuditSeverityMap keys badge-worthy findings by the same resource key the
32
32
  * backend stamps onto topology nodes (`node.data.auditKey`): `group|Kind|ns|name`,
33
- * group following the audit convention (built-ins → their group, CRDs → "").
33
+ * using the resource’s actual API group.
34
34
  */
35
35
  export function buildAuditSeverityMap(
36
36
  findings: AuditFinding[] | undefined,
@@ -1,9 +1,10 @@
1
+ import type { Topology } from '@skyhook-io/k8s-ui/types/core'
1
2
  import { afterEach, describe, expect, it } from 'vitest'
2
3
  import {
3
4
  initNavigationMap,
4
5
  resetNavigationMap,
5
6
  } from '@skyhook-io/k8s-ui/utils/navigation'
6
- import { relatedResourcePath, resourcePath } from './navigation'
7
+ import { getNetworkPolicyResourceTarget, relatedResourcePath, resourcePath, withCrossViewParams } from './navigation'
7
8
 
8
9
  afterEach(resetNavigationMap)
9
10
 
@@ -43,3 +44,35 @@ describe('resource navigation paths', () => {
43
44
  ).toBe('/workload/pods/default/batch')
44
45
  })
45
46
  })
47
+
48
+
49
+ describe('JobSet investigation navigation', () => {
50
+ it('preserves selected member and tab on the exact JobSet route', () => {
51
+ expect(relatedResourcePath({ kind: 'jobsets', namespace: 'training', name: 'wide', group: 'jobset.x-k8s.io', run: 'jobs/training/worker-240', tab: 'logs' })).toBe('/workload/jobsets/training/wide?apiGroup=jobset.x-k8s.io&run=jobs%2Ftraining%2Fworker-240&tab=logs')
52
+ })
53
+ it('leaves colliding JobSet kinds in their group-aware drawer', () => {
54
+ expect(relatedResourcePath({ kind: 'jobsets', namespace: 'training', name: 'wide', group: 'other.example' })).toBe('/resources/jobsets?resource=training%2Fwide&apiGroup=other.example')
55
+ })
56
+ })
57
+
58
+
59
+ it('uses the Kubernetes kind for a Calico aggregate target', () => {
60
+ expect(getNetworkPolicyResourceTarget({nodes: [{id: 'policy', kind: 'CalicoNetworkPolicy', name: 'allow', status: 'healthy', data: {apiVersion: 'crd.projectcalico.org/v1', resourceKind: 'NetworkPolicy'}}], edges: []} as unknown as Topology))
61
+ .toEqual({kind: 'networkpolicies', group: 'crd.projectcalico.org'})
62
+ })
63
+
64
+ describe('withCrossViewParams', () => {
65
+ it('carries the namespace pick and open investigation onto a bare path', () => {
66
+ expect(withCrossViewParams('/gitops/argo/argocd/app', '?namespaces=a,b&ai-run=r1&tab=yaml'))
67
+ .toBe('/gitops/argo/argocd/app?namespaces=a%2Cb&ai-run=r1')
68
+ })
69
+
70
+ it('keeps what the path sets itself, and its hash', () => {
71
+ expect(withCrossViewParams('/resources/pods?namespaces=x#top', '?namespaces=a&ai-run=r1'))
72
+ .toBe('/resources/pods?namespaces=x&ai-run=r1#top')
73
+ })
74
+
75
+ it('leaves the path alone when nothing is scoped', () => {
76
+ expect(withCrossViewParams('/helm', '')).toBe('/helm')
77
+ })
78
+ })
@@ -1,5 +1,6 @@
1
1
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
2
2
  import { apiVersionToGroup, kindToPluralWithGroup } from '@skyhook-io/k8s-ui/utils/navigation'
3
+ import { topologyNodeResourceKind } from '@skyhook-io/k8s-ui/utils/topology-neighborhood'
3
4
  import type { SelectedResource, Topology } from '@skyhook-io/k8s-ui/types/core'
4
5
  import type { SearchHit } from '../api/client'
5
6
 
@@ -48,7 +49,7 @@ export function getNetworkPolicyResourceTarget(topology: Topology | null): { kin
48
49
 
49
50
  const group = networkPolicyGroup(node)
50
51
  const target = {
51
- kind: kindToPluralWithGroup(node.kind, group ?? ''),
52
+ kind: kindToPluralWithGroup(topologyNodeResourceKind(node), group ?? ''),
52
53
  ...(group ? { group } : {}),
53
54
  }
54
55
  targets.set(`${target.kind}\u0000${target.group ?? ''}`, target)
@@ -67,12 +68,18 @@ export function getNetworkPolicyResourceTarget(topology: Topology | null): { kin
67
68
  * positional and WorkloadViewRoute can parse it back. '_' is safe — it's not a
68
69
  * valid DNS-1123 namespace label, so it can never collide with a real one.
69
70
  */
70
- export function buildWorkloadPath(resource: SelectedResource): string {
71
+ export type ResourceNavigationTarget = SelectedResource & { run?: string; tab?: string }
72
+
73
+ export function buildWorkloadPath(resource: ResourceNavigationTarget): string {
71
74
  const kind = encodeURIComponent(resource.kind)
72
75
  const namespace = encodeURIComponent(resource.namespace || '_')
73
76
  const name = encodeURIComponent(resource.name)
74
77
  const base = `/workload/${kind}/${namespace}/${name}`
75
- return resource.group ? `${base}?apiGroup=${encodeURIComponent(resource.group)}` : base
78
+ const params = new URLSearchParams()
79
+ if (resource.group) params.set('apiGroup', resource.group)
80
+ if (resource.run) params.set('run', resource.run)
81
+ if (resource.tab) params.set('tab', resource.tab)
82
+ return params.size ? `${base}?${params}` : base
76
83
  }
77
84
 
78
85
  /**
@@ -101,9 +108,9 @@ export function resourcePath(resource: SelectedResource): string {
101
108
 
102
109
  const FULLSCREEN_RESOURCE_KINDS = new Set(['pods', 'deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs', 'nodes'])
103
110
 
104
- export function relatedResourcePath(resource: SelectedResource): string {
111
+ export function relatedResourcePath(resource: ResourceNavigationTarget): string {
105
112
  const apiKind = kindToPluralWithGroup(resource.kind, resource.group ?? '').toLowerCase()
106
- if (FULLSCREEN_RESOURCE_KINDS.has(apiKind)) {
113
+ if (FULLSCREEN_RESOURCE_KINDS.has(apiKind) || (apiKind === 'jobsets' && resource.group === 'jobset.x-k8s.io')) {
107
114
  return buildWorkloadPath({ ...resource, kind: apiKind })
108
115
  }
109
116
  return resourcePath(resource)
@@ -126,3 +133,19 @@ export function openExternal(url: string): void {
126
133
  window.open(url, '_blank')
127
134
  })
128
135
  }
136
+
137
+ // setMainView's cross-view params. A navigation whose search omits
138
+ // ?namespaces= reads as an empty pick to App's URL sync, which clears the
139
+ // user's namespace scope.
140
+ const CROSS_VIEW_PARAMS = ['namespaces', 'ai-run'] as const
141
+
142
+ /** Carries the current cross-view params onto an in-app path that doesn't set them itself. */
143
+ export function withCrossViewParams(path: string, currentSearch: string): string {
144
+ const destination = new URL(path, 'http://radar.invalid')
145
+ const current = new URLSearchParams(currentSearch)
146
+ for (const key of CROSS_VIEW_PARAMS) {
147
+ const value = current.get(key)
148
+ if (value && !destination.searchParams.has(key)) destination.searchParams.set(key, value)
149
+ }
150
+ return `${destination.pathname}${destination.search}${destination.hash}`
151
+ }
@@ -37,4 +37,60 @@ describe('findSelectedTopologyNode', () => {
37
37
 
38
38
  expect(findSelectedTopologyNode(topology, selected('two.example', 'widgets'))?.id).toBe('second')
39
39
  })
40
+
41
+ it('matches a pseudo topology kind through its real Kubernetes kind', () => {
42
+ const knative = node('knative', 'KnativeService', 'api', 'serving.knative.dev/v1')
43
+ knative.data.resourceKind = 'Service'
44
+
45
+ expect(findSelectedTopologyNode(
46
+ { nodes: [knative], edges: [] },
47
+ { kind: 'services', namespace: 'default', name: 'api', group: 'serving.knative.dev' },
48
+ )?.id).toBe('knative')
49
+ })
50
+
51
+ it('keeps an omitted or explicit core Service selection on the core node', () => {
52
+ const core = node('core', 'Service', 'api')
53
+ const knative = node('knative', 'KnativeService', 'api', 'serving.knative.dev/v1')
54
+ knative.data.resourceKind = 'Service'
55
+ const topology: Topology = { nodes: [knative, core], edges: [] }
56
+
57
+ expect(findSelectedTopologyNode(topology, {
58
+ kind: 'services', namespace: 'default', name: 'api',
59
+ })?.id).toBe('core')
60
+ expect(findSelectedTopologyNode(topology, {
61
+ kind: 'services', namespace: 'default', name: 'api', group: '',
62
+ })?.id).toBe('core')
63
+ })
64
+
65
+ it('infers a typed Job group when its topology node omits apiVersion', () => {
66
+ const core = node('core', 'Job', 'train')
67
+ const volcano = node('volcano', 'Job', 'train', 'batch.volcano.sh/v1alpha1')
68
+ const topology: Topology = { nodes: [volcano, core], edges: [] }
69
+
70
+ expect(findSelectedTopologyNode(topology, {
71
+ kind: 'jobs', namespace: 'default', name: 'train', group: 'batch',
72
+ })?.id).toBe('core')
73
+ expect(findSelectedTopologyNode(topology, {
74
+ kind: 'jobs', namespace: 'default', name: 'train', group: 'batch.volcano.sh',
75
+ })?.id).toBe('volcano')
76
+ })
77
+
78
+ it('infers the policy group for a typed PodDisruptionBudget node', () => {
79
+ const pdb = node('pdb', 'PodDisruptionBudget', 'budget')
80
+
81
+ expect(findSelectedTopologyNode(
82
+ { nodes: [pdb], edges: [] },
83
+ { kind: 'poddisruptionbudgets', namespace: 'default', name: 'budget', group: 'policy' },
84
+ )?.id).toBe('pdb')
85
+ })
86
+
87
+ it('fails closed for an omitted unknown group when custom resources collide', () => {
88
+ const one = node('one', 'Widget', 'same', 'one.example/v1')
89
+ const two = node('two', 'Widget', 'same', 'two.example/v1')
90
+
91
+ expect(findSelectedTopologyNode(
92
+ { nodes: [one, two], edges: [] },
93
+ { kind: 'widgets', namespace: 'default', name: 'same' },
94
+ )).toBeUndefined()
95
+ })
40
96
  })
@@ -1,16 +1,16 @@
1
1
  import type { SelectedResource, Topology, TopologyNode } from '@skyhook-io/k8s-ui/types/core'
2
+ import { builtinGroupForKind, canonicalResourceGroup } from '@skyhook-io/k8s-ui/utils/api-resources'
2
3
  import { apiVersionToGroup, kindToPluralWithGroup } from '@skyhook-io/k8s-ui/utils/navigation'
4
+ import { topologyNodeResourceKind } from '@skyhook-io/k8s-ui/utils/topology-neighborhood'
3
5
 
4
6
  function topologyNodeGroup(node: TopologyNode): string | undefined {
5
- const apiVersionGroup = apiVersionToGroup(node.data.apiVersion as string | undefined)
6
- if (apiVersionGroup) return apiVersionGroup
7
+ const apiVersion = node.data.apiVersion
8
+ if (typeof apiVersion === 'string' && apiVersion) return apiVersionToGroup(apiVersion)
7
9
 
8
10
  const sourceGroup = node.data.sourceGroup
9
- if (typeof sourceGroup === 'string' && sourceGroup) return sourceGroup
11
+ if (typeof sourceGroup === 'string') return sourceGroup
10
12
 
11
- // Native NetworkPolicy nodes predate apiVersion in the topology payload.
12
- if (node.kind === 'NetworkPolicy') return 'networking.k8s.io'
13
- return undefined
13
+ return builtinGroupForKind(topologyNodeResourceKind(node))
14
14
  }
15
15
 
16
16
  export function findSelectedTopologyNode(
@@ -22,13 +22,13 @@ export function findSelectedTopologyNode(
22
22
  const candidates = topology?.nodes.filter(node =>
23
23
  ((node.data.namespace as string) || '') === namespace &&
24
24
  node.name === selectedResource.name &&
25
- (kindToPluralWithGroup(node.kind, topologyNodeGroup(node) ?? '') === selectedKind || node.kind === selectedResource.kind),
25
+ (kindToPluralWithGroup(topologyNodeResourceKind(node), topologyNodeGroup(node) ?? '') === selectedKind || topologyNodeResourceKind(node) === selectedResource.kind),
26
26
  ) ?? []
27
27
 
28
28
  if (candidates.length === 0) return undefined
29
29
 
30
- const selectedGroup = selectedResource.group || undefined
31
- if (!selectedGroup) return candidates[0]
30
+ const selectedGroup = canonicalResourceGroup(selectedResource.kind, selectedResource.group)
31
+ if (selectedGroup === undefined) return candidates.length === 1 ? candidates[0] : undefined
32
32
 
33
33
  const groupMatch = candidates.find(node => topologyNodeGroup(node) === selectedGroup)
34
34
  if (groupMatch) return groupMatch
@@ -32,6 +32,17 @@ function parseVersion(version: string): [major: number, minor: number, patch: nu
32
32
  return [Number(match[1]), Number(match[2]), Number(match[3]), !!match[4]]
33
33
  }
34
34
 
35
+ /** Semver order (a prerelease sorts before its release); null when either side isn't a version. */
36
+ export function compareVersions(a: string, b: string): number | null {
37
+ const x = parseVersion(a)
38
+ const y = parseVersion(b)
39
+ if (!x || !y) return null
40
+ for (let i = 0; i < 3; i++) {
41
+ if (x[i] !== y[i]) return (x[i] as number) - (y[i] as number)
42
+ }
43
+ return Number(y[3]) - Number(x[3])
44
+ }
45
+
35
46
  export function getVersionUpdateStatus(current: string, latest?: string): VersionUpdateStatus {
36
47
  if (!latest) return { tier: 'none' }
37
48