@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.
- package/README.md +2 -9
- package/package.json +6 -6
- package/src/App.tsx +142 -334
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +267 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +113 -63
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/SelfManagedStart.tsx +96 -0
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/AgentControls.tsx +26 -3
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/agentCatalog.ts +6 -0
- package/src/components/diagnose/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +18 -0
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +165 -0
- package/src/components/execution/JobSetAdmission.tsx +26 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
- package/src/components/home/HomeView.tsx +6 -2
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +27 -8
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/model.test.ts +2 -1
- package/src/components/rightsizing/notices.test.tsx +4 -3
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/SettingsDialog.tsx +168 -92
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +19 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +16 -0
- package/src/components/ui/UpdateNotification.tsx +13 -9
- package/src/components/ui/command-items.ts +14 -3
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/useContextSwitchFlow.tsx +147 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +47 -19
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +34 -1
- package/src/utils/navigation.ts +28 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/utils/version.ts +11 -0
- 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
|
|
25
|
-
/**
|
|
26
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|
package/src/utils/auditBadges.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
+
})
|
package/src/utils/navigation.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
6
|
-
if (
|
|
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'
|
|
11
|
+
if (typeof sourceGroup === 'string') return sourceGroup
|
|
10
12
|
|
|
11
|
-
|
|
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
|
|
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
|
|
31
|
-
if (
|
|
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
|
package/src/utils/version.ts
CHANGED
|
@@ -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
|
|