@skyhook-io/radar-app 1.14.6 → 1.16.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 -0
- package/package.json +6 -5
- package/src/App.tsx +65 -17
- package/src/RadarApp.tsx +51 -21
- package/src/api/apiResources.test.ts +19 -1
- package/src/api/apiResources.ts +18 -3
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +269 -204
- package/src/api/diagnose.test.ts +102 -1
- package/src/api/diagnose.ts +42 -11
- package/src/api/drain-plan.test.ts +0 -10
- package/src/api/httpErrors.test.ts +95 -0
- package/src/api/httpErrors.ts +101 -0
- package/src/api/policy.ts +33 -5
- package/src/api/radarFeatures.render.test.tsx +190 -0
- package/src/api/radarFeatures.test.ts +133 -0
- package/src/api/radarFeatures.ts +147 -0
- package/src/api/timelineSource.test.ts +2 -10
- package/src/api/timelineSource.ts +3 -2
- package/src/api/traffic.ts +4 -3
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +2 -2
- package/src/components/CloudFunnelButton.tsx +99 -55
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +95 -0
- package/src/components/applications/ApplicationsView.tsx +20 -6
- package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
- package/src/components/audit/UpgradeReadinessView.tsx +9 -13
- package/src/components/capacity/CapacityView.test.tsx +13 -4
- package/src/components/capacity/shared.tsx +14 -13
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/cost/ApplicationCostTab.tsx +9 -1
- package/src/components/cost/CostConnectionAction.tsx +30 -0
- package/src/components/cost/CostTrendChart.tsx +2 -0
- package/src/components/cost/CostView.tsx +4 -24
- package/src/components/cost/WorkloadCostTab.tsx +7 -1
- package/src/components/cost/source.test.ts +30 -3
- package/src/components/cost/source.ts +37 -2
- package/src/components/curl/ServiceCurlButton.tsx +7 -3
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/execution/BatchExecutionView.tsx +1 -1
- package/src/components/execution/JobSetAdmission.test.tsx +160 -37
- package/src/components/execution/JobSetAdmission.tsx +16 -7
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
- package/src/components/home/HomeView.tsx +7 -3
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +25 -3
- package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
- package/src/components/resource/PVCUsageBar.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
- package/src/components/resources/ImageFilesystemModal.tsx +2 -1
- package/src/components/resources/PodFilePreview.tsx +4 -1
- package/src/components/resources/PodFilesystemModal.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +2 -1
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
- package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
- package/src/components/settings/FormSaveActions.tsx +76 -0
- package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
- package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
- package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +393 -571
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/currency-options.test.ts +1 -1
- package/src/components/settings/currency-options.ts +1 -1
- package/src/components/settings/settings-state.test.ts +17 -10
- package/src/components/settings/settings-state.ts +21 -12
- package/src/components/timeline/TimelineList.test.ts +56 -0
- package/src/components/timeline/TimelineList.tsx +7 -1
- package/src/components/traffic/TrafficFlowList.tsx +5 -21
- package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
- package/src/components/traffic/TrafficGraph.tsx +79 -48
- package/src/components/traffic/TrafficView.tsx +61 -37
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/traffic/trafficFilters.test.ts +154 -1
- package/src/components/traffic/trafficFilters.ts +141 -0
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +17 -1
- package/src/components/ui/UpdateNotification.tsx +70 -21
- package/src/components/ui/command-items.ts +14 -0
- 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/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.tsx +47 -14
- package/src/components/workload/historyPaging.test.ts +72 -0
- package/src/components/workload/historyPaging.ts +147 -0
- package/src/components/workload/useHistoryPaging.test.tsx +81 -0
- package/src/context/ConnectionContext.tsx +2 -1
- package/src/context/RadarUpgrade.tsx +30 -0
- package/src/context/RadarUpgradeHost.tsx +23 -0
- package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
- package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
- package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
- package/src/index.ts +1 -0
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/navigation.test.ts +17 -1
- package/src/utils/navigation.ts +16 -0
- package/src/utils/version.ts +11 -0
package/README.md
CHANGED
|
@@ -41,6 +41,8 @@ When `navSlots.embedded` is true, Radar sizes itself to the host container (`hei
|
|
|
41
41
|
|
|
42
42
|
Embedded mode renders only the active view. Radar Hub owns the sidebar, top bar, cluster selector, and navigation. `onClusterLoadStateChange` supplies the loading status for Hub's top bar.
|
|
43
43
|
|
|
44
|
+
The package can be newer than the Radar it talks to. Features that need a newer Radar show an inline "needs Radar vX or newer" note instead of an error. Radar decides this from the `features` block of `/api/capabilities`, or from the Radar's version when it predates a flag. Pass `radarVersion` so Radar can skip those calls before `/api/version-check` answers. Pass `onRequestRadarUpgrade` to run your own upgrade flow from the note's "Upgrade Radar" button. Without it, the note links to the upgrade instructions.
|
|
45
|
+
|
|
44
46
|
## Tailwind
|
|
45
47
|
|
|
46
48
|
Radar uses Tailwind v4 classes throughout. Your app's Tailwind config must scan Radar's source:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"tsc": "node ../node_modules/typescript-7/bin/tsc --noEmit",
|
|
26
26
|
"tsc:ts6": "tsc --noEmit",
|
|
27
27
|
"test": "vitest run src",
|
|
28
|
+
"test:e2e:settings": "playwright test --config playwright.settings.config.ts",
|
|
28
29
|
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
|
29
30
|
"lint": "eslint src && node scripts/check-no-native-title.mjs"
|
|
30
31
|
},
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
"yaml": "^2.9.0"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
|
44
|
-
"@skyhook-io/k8s-ui": ">=1.
|
|
45
|
+
"@skyhook-io/k8s-ui": ">=1.16.0",
|
|
45
46
|
"@tanstack/react-query": ">=5",
|
|
46
47
|
"@xyflow/react": ">=12.0.0",
|
|
47
48
|
"clsx": ">=2",
|
|
@@ -66,11 +67,11 @@
|
|
|
66
67
|
"@xyflow/react": "^12.11.2",
|
|
67
68
|
"clsx": "^2.1.1",
|
|
68
69
|
"elkjs": "^0.11.1",
|
|
69
|
-
"eslint": "^10.
|
|
70
|
+
"eslint": "^10.11.0",
|
|
70
71
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
71
72
|
"eslint-plugin-react-refresh": "^0.5.3",
|
|
72
73
|
"globals": "^17.11.0",
|
|
73
|
-
"lucide-react": "^1.
|
|
74
|
+
"lucide-react": "^1.48.0",
|
|
74
75
|
"postcss": "^8.5.28",
|
|
75
76
|
"prettier": "^3.9.8",
|
|
76
77
|
"react": "^19.3.0",
|
|
@@ -80,7 +81,7 @@
|
|
|
80
81
|
"tailwindcss": "^4.3.3",
|
|
81
82
|
"typescript": "^6.0.2",
|
|
82
83
|
"typescript-eslint": "^8.70.0",
|
|
83
|
-
"vite": "^8.
|
|
84
|
+
"vite": "^8.3.1"
|
|
84
85
|
},
|
|
85
86
|
"sideEffects": [
|
|
86
87
|
"*.css"
|
package/src/App.tsx
CHANGED
|
@@ -46,9 +46,13 @@ import { ConnectionProvider, useConnection } from './context/ConnectionContext'
|
|
|
46
46
|
import { ConnectionErrorView } from './components/ConnectionErrorView'
|
|
47
47
|
import { SyncProgressPanel } from './components/SyncProgressPanel'
|
|
48
48
|
import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/CapabilitiesContext'
|
|
49
|
+
import { RadarUpgradeProvider } from './context/RadarUpgrade'
|
|
49
50
|
import { UserMenu } from './components/UserMenu'
|
|
50
51
|
import { ErrorBoundary } from './components/ui/ErrorBoundary'
|
|
51
52
|
import { UpdateNotification } from './components/ui/UpdateNotification'
|
|
53
|
+
import { openWhatsNew, useWhatsNewStatus, WhatsNew } from './components/whats-new/WhatsNew'
|
|
54
|
+
import { useUsageData, useUsageRecording } from './api/usage-data'
|
|
55
|
+
import { UsageDataPrompt } from './components/usage-data/UsageDataPrompt'
|
|
52
56
|
import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
|
|
53
57
|
import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
|
|
54
58
|
import { useEventSource } from './hooks/useEventSource'
|
|
@@ -67,7 +71,7 @@ import { Tooltip } from './components/ui/Tooltip'
|
|
|
67
71
|
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
68
72
|
import { SettingsDialog, type SettingsSectionId } from './components/settings/SettingsDialog'
|
|
69
73
|
import type { APIResource, TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
70
|
-
import { kindToPluralWithGroup, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource } from './utils/navigation'
|
|
74
|
+
import { kindToPluralWithGroup, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource, withCrossViewParams } from './utils/navigation'
|
|
71
75
|
import { findSelectedTopologyNode } from './utils/topology-selection'
|
|
72
76
|
import { type OmnibarHandle } from './components/ui/Omnibar'
|
|
73
77
|
import { RadarOmnibar } from './components/ui/RadarOmnibar'
|
|
@@ -144,6 +148,28 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
144
148
|
return 'home'
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
// The usage-data name for the current screen: a resource list names its
|
|
152
|
+
// kind's plural ("resources:deployments"); the server keeps built-in kinds
|
|
153
|
+
// only, so a custom resource's name never leaves.
|
|
154
|
+
function usageView(pathname: string, view: ExtendedMainView, upgrade: boolean): string {
|
|
155
|
+
if (upgrade) return 'upgrade'
|
|
156
|
+
if (view === 'resources') {
|
|
157
|
+
const plural = pathname.match(/^\/resources\/([^/]+)/)?.[1]
|
|
158
|
+
if (plural) return `resources:${plural.toLowerCase()}`
|
|
159
|
+
}
|
|
160
|
+
return view
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The screen a crash is counted under. Fixed names, because release builds
|
|
164
|
+
// shorten component names and the component stack can't say which screen.
|
|
165
|
+
const CRASH_LABELS: Record<ExtendedMainView, string> = {
|
|
166
|
+
home: 'Home', topology: 'Topology', resources: 'Resources', timeline: 'Timeline',
|
|
167
|
+
issues: 'Issues', helm: 'Helm', helmCompare: 'HelmCompare', traffic: 'Traffic',
|
|
168
|
+
cost: 'Cost', capacity: 'Capacity', checks: 'Checks', gitops: 'GitOps',
|
|
169
|
+
applications: 'Applications', workload: 'Workload', compare: 'Compare',
|
|
170
|
+
investigations: 'Investigations',
|
|
171
|
+
}
|
|
172
|
+
|
|
147
173
|
// The namespace scope filter is meaningful only on namespaced surfaces. On
|
|
148
174
|
// cluster-scoped views it does nothing, so we disable it with an explanation
|
|
149
175
|
// rather than leaving a dead control that silently ignores the pick:
|
|
@@ -417,6 +443,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
417
443
|
const mainView = getViewFromPath(location.pathname)
|
|
418
444
|
const upgradeReadinessRoute = location.pathname.startsWith('/checks/upgrade')
|
|
419
445
|
|
|
446
|
+
// Opt-in usage data. Embedded hosts own their own consent, so Radar never asks there.
|
|
447
|
+
const usageData = useUsageData(!navCustomization.embedded)
|
|
448
|
+
// A status cached by another screen must not start recording inside a host.
|
|
449
|
+
useUsageRecording(usageView(location.pathname, mainView, upgradeReadinessRoute), navCustomization.embedded ? undefined : usageData.data)
|
|
450
|
+
|
|
420
451
|
// Initialize the kind→plural discovery map app-wide (not just on ResourcesView
|
|
421
452
|
// mount) so the omnibar can open a CRD hit with an irregular plural from any
|
|
422
453
|
// view — kindToPlural would otherwise English-guess the route before a
|
|
@@ -492,6 +523,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
492
523
|
navigate({ pathname: path, search: newParams.toString() })
|
|
493
524
|
}, [location.search, navigate, takeover, goHost])
|
|
494
525
|
|
|
526
|
+
const whatsNewStatus = useWhatsNewStatus()
|
|
527
|
+
|
|
528
|
+
const navigateToPath = useCallback((path: string) => {
|
|
529
|
+
navigate(withCrossViewParams(path, location.search))
|
|
530
|
+
}, [location.search, navigate])
|
|
531
|
+
|
|
495
532
|
// The standalone rail expresses intent to leave the full-width investigation
|
|
496
533
|
// workspace. Close it before routing so the destination is immediately visible;
|
|
497
534
|
// docked investigations stay open across views as a persistent side panel.
|
|
@@ -777,13 +814,14 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
777
814
|
metadata: { namespace: resource.namespace ?? '', name: resource.name },
|
|
778
815
|
})
|
|
779
816
|
if (gitOpsPath) {
|
|
780
|
-
const destination = new URL(gitOpsPath, window.location.origin)
|
|
781
|
-
if (investigationRunID) destination.searchParams.
|
|
817
|
+
const destination = new URL(withCrossViewParams(gitOpsPath, searchParams.toString()), window.location.origin)
|
|
818
|
+
if (investigationRunID === null) destination.searchParams.delete('ai-run')
|
|
819
|
+
else if (investigationRunID) destination.searchParams.set('ai-run', investigationRunID)
|
|
782
820
|
navigate(`${destination.pathname}${destination.search}${destination.hash}`)
|
|
783
821
|
return
|
|
784
822
|
}
|
|
785
823
|
navigateToResourceList(resource, investigationRunID)
|
|
786
|
-
}, [navigate, navigateToHelmRelease, navigateToResourceList])
|
|
824
|
+
}, [navigate, navigateToHelmRelease, navigateToResourceList, searchParams])
|
|
787
825
|
|
|
788
826
|
// Collapse the over-list fullscreen back to the drawer = drop ?full=1 (and the
|
|
789
827
|
// resource-scoped ?tab) in place. The button means "collapse THIS to a drawer"
|
|
@@ -1732,6 +1770,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1732
1770
|
showPinToggle={!railForcedSlim}
|
|
1733
1771
|
onOpenSettings={() => openSettings()}
|
|
1734
1772
|
accountSlot={<UserMenu variant="rail" pinned={navRailEffectivePinned} />}
|
|
1773
|
+
whatsNew={whatsNewStatus.available ? { unread: whatsNewStatus.unread, onOpen: openWhatsNew } : undefined}
|
|
1735
1774
|
/>
|
|
1736
1775
|
)}
|
|
1737
1776
|
{/* `relative` makes this column the containing block for the absolute
|
|
@@ -1833,6 +1872,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1833
1872
|
onSetNamespaces={(ns) => { setNamespaces(ns); setActiveNamespace.mutate({ namespaces: ns }) }}
|
|
1834
1873
|
onToggleTheme={toggleTheme}
|
|
1835
1874
|
onShowDiagnostics={() => setShowDiagnostics(true)}
|
|
1875
|
+
onShowWhatsNew={whatsNewStatus.available ? openWhatsNew : undefined}
|
|
1836
1876
|
onOpenResource={(hit) => navigateToResourceList(searchHitToSelectedResource(hit))}
|
|
1837
1877
|
/>
|
|
1838
1878
|
</div>
|
|
@@ -1977,7 +2017,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1977
2017
|
{/* Search included, not just the path: selection inside a view rides in
|
|
1978
2018
|
the query (?resource=, ?release=), so a path-only key would still
|
|
1979
2019
|
strand a crash on the view that produced it. */}
|
|
1980
|
-
<ErrorBoundary
|
|
2020
|
+
<ErrorBoundary
|
|
2021
|
+
resetKey={location.pathname + location.search}
|
|
2022
|
+
usageLabel={upgradeReadinessRoute ? 'Upgrade' : CRASH_LABELS[mainView]}
|
|
2023
|
+
>
|
|
1981
2024
|
{/* Initial sync in progress: views that need the full cluster dataset
|
|
1982
2025
|
show per-kind progress instead; resource views work as kinds sync. */}
|
|
1983
2026
|
{viewsSyncGated && connection.syncStatus && (
|
|
@@ -1994,7 +2037,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1994
2037
|
fallbackClusterLoadState={showHomeClusterLoadFallback ? clusterLoadState : undefined}
|
|
1995
2038
|
onNavigateToView={setMainView}
|
|
1996
2039
|
onNavigateToHelmRelease={navCustomization.embedded ? undefined : navigateToHelmRelease}
|
|
1997
|
-
onNavigateToManagerPath={navCustomization.embedded || takeover.gitops ? undefined :
|
|
2040
|
+
onNavigateToManagerPath={navCustomization.embedded || takeover.gitops ? undefined : navigateToPath}
|
|
2041
|
+
onShowWhatsNew={!navCustomization.embedded && whatsNewStatus.available ? openWhatsNew : undefined}
|
|
1998
2042
|
// Upgrade impact lives under /checks, which a Cloud host takes
|
|
1999
2043
|
// over wholesale — its fleet pages have no upgrade sub-route, so
|
|
2000
2044
|
// the version line stays plain text there.
|
|
@@ -2435,6 +2479,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2435
2479
|
|
|
2436
2480
|
{/* Update notification — hidden in embedded mode (OSS download nudge). */}
|
|
2437
2481
|
{!navCustomization.embedded && <UpdateNotification />}
|
|
2482
|
+
{!navCustomization.embedded && <WhatsNew onNavigate={navigateToPath} usageData={usageData.data} />}
|
|
2483
|
+
{!navCustomization.embedded && <UsageDataPrompt status={usageData.data} />}
|
|
2438
2484
|
|
|
2439
2485
|
{/* Bottom Dock for Terminal/Logs */}
|
|
2440
2486
|
<BottomDock />
|
|
@@ -2488,17 +2534,19 @@ function App({ manageDocumentTitle = false, documentTitleSuffix, onClusterLoadSt
|
|
|
2488
2534
|
return (
|
|
2489
2535
|
<ConnectionProvider>
|
|
2490
2536
|
<CapabilitiesProvider>
|
|
2491
|
-
<
|
|
2492
|
-
<
|
|
2493
|
-
<
|
|
2494
|
-
<
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2537
|
+
<RadarUpgradeProvider>
|
|
2538
|
+
<ContextSwitchProvider>
|
|
2539
|
+
<DockProvider>
|
|
2540
|
+
<KeyboardShortcutProvider>
|
|
2541
|
+
<AppInner
|
|
2542
|
+
manageDocumentTitle={manageDocumentTitle}
|
|
2543
|
+
documentTitleSuffix={documentTitleSuffix}
|
|
2544
|
+
onClusterLoadStateChange={onClusterLoadStateChange}
|
|
2545
|
+
/>
|
|
2546
|
+
</KeyboardShortcutProvider>
|
|
2547
|
+
</DockProvider>
|
|
2548
|
+
</ContextSwitchProvider>
|
|
2549
|
+
</RadarUpgradeProvider>
|
|
2502
2550
|
</CapabilitiesProvider>
|
|
2503
2551
|
</ConnectionProvider>
|
|
2504
2552
|
)
|
package/src/RadarApp.tsx
CHANGED
|
@@ -33,9 +33,15 @@ import {
|
|
|
33
33
|
showApiSuccess,
|
|
34
34
|
} from "./components/ui/Toast";
|
|
35
35
|
import { setApiBase, setBasename } from "./api/config";
|
|
36
|
+
import { shouldRetryRadarQuery } from "./api/radarFeatures";
|
|
36
37
|
import { NavCustomizationProvider } from "./context/NavCustomization";
|
|
37
38
|
import { FilterLocationBridge } from "./filter/FilterLocationBridge";
|
|
38
39
|
import type { NavCustomization } from "./context/NavCustomization";
|
|
40
|
+
import { RadarUpgradeHostProvider } from "./context/RadarUpgradeHost";
|
|
41
|
+
import type {
|
|
42
|
+
RadarUpgradeHost,
|
|
43
|
+
RadarUpgradeRequest,
|
|
44
|
+
} from "./context/RadarUpgradeHost";
|
|
39
45
|
import type { ClusterLoadState } from "./types/clusterLoadState";
|
|
40
46
|
import { TimelineSourceProvider } from "./context/TimelineSource";
|
|
41
47
|
import type { TimelineSourceConfig } from "./api/timelineSource";
|
|
@@ -65,6 +71,21 @@ declare module "@tanstack/react-query" {
|
|
|
65
71
|
export interface RadarAppProps {
|
|
66
72
|
/** API base URL (REST + SSE + WS). Defaults to '/api' (same-origin). */
|
|
67
73
|
apiBase?: string;
|
|
74
|
+
/**
|
|
75
|
+
* The version of the Radar behind `apiBase`, as the host knows it (Radar
|
|
76
|
+
* Cloud: the cluster's `radar_version`). Radar prefers that Radar's own
|
|
77
|
+
* /version-check answer and uses this only until it arrives or when it
|
|
78
|
+
* fails, to decide up front that a feature needs a newer Radar instead of
|
|
79
|
+
* probing for it.
|
|
80
|
+
*/
|
|
81
|
+
radarVersion?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Called when the user asks to upgrade from an inline "needs a newer Radar"
|
|
84
|
+
* note next to a feature the connected Radar is too old to serve. The host
|
|
85
|
+
* runs its own upgrade flow (Radar Cloud: one-click for owners, "ask an
|
|
86
|
+
* owner" otherwise). Omitted → the note links to the upgrade instructions.
|
|
87
|
+
*/
|
|
88
|
+
onRequestRadarUpgrade?: (request: RadarUpgradeRequest) => void;
|
|
68
89
|
/** React Router basename. Defaults to '' (mounted at root). */
|
|
69
90
|
basename?: string;
|
|
70
91
|
/**
|
|
@@ -163,7 +184,7 @@ function makeDefaultQueryClient(): QueryClient {
|
|
|
163
184
|
defaultOptions: {
|
|
164
185
|
queries: {
|
|
165
186
|
refetchOnWindowFocus: false,
|
|
166
|
-
retry:
|
|
187
|
+
retry: shouldRetryRadarQuery,
|
|
167
188
|
},
|
|
168
189
|
},
|
|
169
190
|
mutationCache: new MutationCache({
|
|
@@ -193,6 +214,8 @@ function makeDefaultQueryClient(): QueryClient {
|
|
|
193
214
|
|
|
194
215
|
export function RadarApp({
|
|
195
216
|
apiBase,
|
|
217
|
+
radarVersion,
|
|
218
|
+
onRequestRadarUpgrade,
|
|
196
219
|
basename,
|
|
197
220
|
router = "browser",
|
|
198
221
|
queryClient,
|
|
@@ -223,32 +246,39 @@ export function RadarApp({
|
|
|
223
246
|
[queryClient],
|
|
224
247
|
);
|
|
225
248
|
|
|
249
|
+
const upgradeHost = React.useMemo<RadarUpgradeHost>(
|
|
250
|
+
() => ({ radarVersion, onRequestRadarUpgrade }),
|
|
251
|
+
[radarVersion, onRequestRadarUpgrade],
|
|
252
|
+
);
|
|
253
|
+
|
|
226
254
|
const inner = (
|
|
227
255
|
<ThemeProvider>
|
|
228
256
|
<QueryClientProvider client={client}>
|
|
229
257
|
<ToastProvider>
|
|
230
258
|
<NavCustomizationProvider value={navSlots}>
|
|
231
|
-
<
|
|
232
|
-
<
|
|
233
|
-
<
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
<DiagnoseProvider
|
|
239
|
-
browserURLState={router !== "memory"}
|
|
240
|
-
forceRouterURLState={router === "memory"}
|
|
241
|
-
onFocusedRun={onInvestigationFocus}
|
|
259
|
+
<RadarUpgradeHostProvider value={upgradeHost}>
|
|
260
|
+
<FilterLocationBridge>
|
|
261
|
+
<TimelineSourceProvider config={timelineSource}>
|
|
262
|
+
<DiagnoseCustomizationProvider
|
|
263
|
+
value={renderDiagnoseAction ?? defaultDiagnoseAction}
|
|
264
|
+
consentCopy={diagnoseConsent}
|
|
265
|
+
renderRunActions={renderInvestigationRunActions}
|
|
242
266
|
>
|
|
243
|
-
<
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
267
|
+
<DiagnoseProvider
|
|
268
|
+
browserURLState={router !== "memory"}
|
|
269
|
+
forceRouterURLState={router === "memory"}
|
|
270
|
+
onFocusedRun={onInvestigationFocus}
|
|
271
|
+
>
|
|
272
|
+
<App
|
|
273
|
+
manageDocumentTitle={manageDocumentTitle}
|
|
274
|
+
documentTitleSuffix={documentTitleSuffix}
|
|
275
|
+
onClusterLoadStateChange={onClusterLoadStateChange}
|
|
276
|
+
/>
|
|
277
|
+
</DiagnoseProvider>
|
|
278
|
+
</DiagnoseCustomizationProvider>
|
|
279
|
+
</TimelineSourceProvider>
|
|
280
|
+
</FilterLocationBridge>
|
|
281
|
+
</RadarUpgradeHostProvider>
|
|
252
282
|
</NavCustomizationProvider>
|
|
253
283
|
</ToastProvider>
|
|
254
284
|
</QueryClientProvider>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { hasKarpenterNodePools } from './apiResources'
|
|
2
|
+
import { hasKarpenterNodePools, hasPolicyReports } from './apiResources'
|
|
3
|
+
import type { APIResource } from '../types'
|
|
3
4
|
|
|
4
5
|
describe('hasKarpenterNodePools', () => {
|
|
5
6
|
it('uses exact API discovery rather than top-CRD counts or kind name alone', () => {
|
|
@@ -9,3 +10,20 @@ describe('hasKarpenterNodePools', () => {
|
|
|
9
10
|
expect(hasKarpenterNodePools(undefined)).toBe(false)
|
|
10
11
|
})
|
|
11
12
|
})
|
|
13
|
+
|
|
14
|
+
describe('hasPolicyReports', () => {
|
|
15
|
+
const resource = (name: string, group: string) => ({ name, group }) as APIResource
|
|
16
|
+
|
|
17
|
+
it('recognizes each report family by the resource names it actually serves', () => {
|
|
18
|
+
expect(hasPolicyReports([resource('policyreports', 'wgpolicyk8s.io')])).toBe(true)
|
|
19
|
+
expect(hasPolicyReports([resource('clusterpolicyreports', 'wgpolicyk8s.io')])).toBe(true)
|
|
20
|
+
expect(hasPolicyReports([resource('reports', 'openreports.io')])).toBe(true)
|
|
21
|
+
expect(hasPolicyReports([resource('clusterreports', 'openreports.io')])).toBe(true)
|
|
22
|
+
expect(hasPolicyReports([resource('policyreports', 'openreports.io')])).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('reads a cluster without them as having no policy engine', () => {
|
|
26
|
+
expect(hasPolicyReports([resource('pods', ''), resource('policyreports', 'example.com')])).toBe(false)
|
|
27
|
+
expect(hasPolicyReports(undefined)).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
})
|
package/src/api/apiResources.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
2
|
import type { APIResource } from "../types";
|
|
3
3
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from "./config";
|
|
4
|
+
import { readErrorBody } from "./httpErrors";
|
|
4
5
|
|
|
5
6
|
// Re-export pure functions from package
|
|
6
7
|
export {
|
|
@@ -20,9 +21,7 @@ async function fetchJSON<T>(path: string): Promise<T> {
|
|
|
20
21
|
headers: getAuthHeaders(),
|
|
21
22
|
});
|
|
22
23
|
if (!response.ok) {
|
|
23
|
-
const error = await response
|
|
24
|
-
.json()
|
|
25
|
-
.catch(() => ({ error: "Unknown error" }));
|
|
24
|
+
const error = await readErrorBody(response);
|
|
26
25
|
throw new Error(error.error || `HTTP ${response.status}`);
|
|
27
26
|
}
|
|
28
27
|
return response.json();
|
|
@@ -37,6 +36,22 @@ export function useAPIResources() {
|
|
|
37
36
|
});
|
|
38
37
|
}
|
|
39
38
|
|
|
39
|
+
// The report families the server watches (reportGroups in
|
|
40
|
+
// internal/k8s/policy_reports.go): the wgpolicyk8s.io working-group API and
|
|
41
|
+
// its openreports.io successor, which names the same resources differently.
|
|
42
|
+
const POLICY_REPORT_RESOURCES: Record<string, readonly string[]> = {
|
|
43
|
+
"wgpolicyk8s.io": ["policyreports", "clusterpolicyreports"],
|
|
44
|
+
"openreports.io": ["reports", "clusterreports"],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function hasPolicyReports(resources: APIResource[] | undefined): boolean {
|
|
48
|
+
return (
|
|
49
|
+
resources?.some((resource) =>
|
|
50
|
+
POLICY_REPORT_RESOURCES[resource.group]?.includes(resource.name),
|
|
51
|
+
) ?? false
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
export function hasKarpenterNodePools(
|
|
41
56
|
resources: APIResource[] | undefined,
|
|
42
57
|
): boolean {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useQuery } from '@tanstack/react-query'
|
|
3
|
+
import { ApiError, useKueueAdmission as admissionQuery } from './client'
|
|
4
|
+
|
|
5
|
+
vi.mock('@tanstack/react-query', async (original) => ({ ...await original<typeof import('@tanstack/react-query')>(), useQuery: vi.fn(() => ({})) }))
|
|
6
|
+
|
|
7
|
+
describe('Kueue admission polling', () => {
|
|
8
|
+
function options(settings?: Parameters<typeof admissionQuery>[3]) {
|
|
9
|
+
admissionQuery('ml', 'training', 'current', settings)
|
|
10
|
+
return vi.mocked(useQuery).mock.calls.at(-1)![0]
|
|
11
|
+
}
|
|
12
|
+
function interval(settings: Parameters<typeof admissionQuery>[3], state: any = {}) {
|
|
13
|
+
const poll = options(settings).refetchInterval as (query: any) => number | false
|
|
14
|
+
return poll({ state })
|
|
15
|
+
}
|
|
16
|
+
it('separates Job and JobSet identities and recreated roots', () => {
|
|
17
|
+
expect(options({ isJob: true }).queryKey).toEqual(['kueue-admission', 'batch', 'jobs', 'ml', 'training', 'current'])
|
|
18
|
+
expect(options({ isRayJob: true }).queryKey).toEqual(['kueue-admission', 'ray.io', 'rayjobs', 'ml', 'training', 'current'])
|
|
19
|
+
expect(options().queryKey).toEqual(['kueue-admission', 'jobset.x-k8s.io', 'jobsets', 'ml', 'training', 'current'])
|
|
20
|
+
})
|
|
21
|
+
it('polls quiet and terminal Jobs slowly, active admission quickly', () => {
|
|
22
|
+
expect(interval({ isJob: true })).toBe(30000)
|
|
23
|
+
expect(interval({ isJob: true, hinted: true })).toBe(5000)
|
|
24
|
+
expect(interval({ isJob: true }, { data: { workloads: [{}] } })).toBe(5000)
|
|
25
|
+
expect(interval({ isJob: true, hinted: true, terminal: true })).toBe(30000)
|
|
26
|
+
expect(interval(undefined)).toBe(5000)
|
|
27
|
+
})
|
|
28
|
+
it('stops on confirmed absence or denied requests, not transient outages or old absence', () => {
|
|
29
|
+
expect(interval({ isJob: true }, { data: { uid: 'current', installed: false } })).toBe(false)
|
|
30
|
+
expect(interval({ isJob: true, hinted: true }, { data: { uid: 'old', installed: false } })).toBe(5000)
|
|
31
|
+
expect(interval({ isJob: true }, { error: new ApiError('denied', 403) })).toBe(false)
|
|
32
|
+
expect(interval({ isJob: true, hinted: true }, { error: new ApiError('warming', 503) })).toBe(5000)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isCapacityCursorInvalidError,
|
|
6
6
|
shouldRetryCapacityQuery,
|
|
7
7
|
} from "./client";
|
|
8
|
+
import { RadarFeatureUnsupportedError } from "./radarFeatures";
|
|
8
9
|
|
|
9
10
|
describe("isCapacityCursorInvalidError", () => {
|
|
10
11
|
it("uses the structured capacity error code", () => {
|
|
@@ -42,6 +43,12 @@ describe("shouldRetryCapacityQuery", () => {
|
|
|
42
43
|
);
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
it("never retries a feature the connected Radar predates", () => {
|
|
47
|
+
expect(
|
|
48
|
+
shouldRetryCapacityQuery(0, new RadarFeatureUnsupportedError("capacity", {})),
|
|
49
|
+
).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
45
52
|
it("bounds retries for transient failures", () => {
|
|
46
53
|
expect(shouldRetryCapacityQuery(0, new ApiError("unavailable", 503))).toBe(
|
|
47
54
|
true,
|
|
@@ -63,6 +70,14 @@ describe("capacityRefetchInterval", () => {
|
|
|
63
70
|
).toBe(false);
|
|
64
71
|
});
|
|
65
72
|
|
|
73
|
+
it("stops polling a Radar that predates Capacity", () => {
|
|
74
|
+
expect(
|
|
75
|
+
capacityRefetchInterval(undefined, true)({
|
|
76
|
+
state: { error: new RadarFeatureUnsupportedError("capacity", {}) },
|
|
77
|
+
}),
|
|
78
|
+
).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
66
81
|
it("polls fast while the server reports syncing", () => {
|
|
67
82
|
expect(
|
|
68
83
|
capacityRefetchInterval(undefined, true)(withData({ state: "syncing" })),
|
|
@@ -25,9 +25,17 @@ describe('runInClusterMerged error surfacing', () => {
|
|
|
25
25
|
await expect(runInClusterMerged('Service', 'prod', 'web')).rejects.toThrow('no eligible routes')
|
|
26
26
|
})
|
|
27
27
|
|
|
28
|
+
// The banner around this message already says the in-cluster test couldn't run.
|
|
28
29
|
it('rejects a non-JSON gateway failure with the status, not a parse error', async () => {
|
|
29
30
|
respond(504, '<html>Gateway Timeout</html>', 'text/html')
|
|
30
|
-
await expect(runInClusterMerged('Service', 'prod', 'web')).rejects.toThrow('
|
|
31
|
+
await expect(runInClusterMerged('Service', 'prod', 'web')).rejects.toThrow('HTTP 504 (Gateway Timeout)')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("keeps a proxy's plain-text reason", async () => {
|
|
35
|
+
respond(503, 'cluster "prod" not connected\n', 'text/plain; charset=utf-8')
|
|
36
|
+
await expect(runInClusterMerged('Service', 'prod', 'web')).rejects.toThrow(
|
|
37
|
+
'cluster "prod" not connected (HTTP 503)',
|
|
38
|
+
)
|
|
31
39
|
})
|
|
32
40
|
|
|
33
41
|
it('resolves a clean run with the finalized payload', async () => {
|