@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.
Files changed (128) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +65 -17
  4. package/src/RadarApp.tsx +51 -21
  5. package/src/api/apiResources.test.ts +19 -1
  6. package/src/api/apiResources.ts +18 -3
  7. package/src/api/client.admission.test.ts +34 -0
  8. package/src/api/client.capacity.test.ts +15 -0
  9. package/src/api/client.trace.test.ts +9 -1
  10. package/src/api/client.ts +269 -204
  11. package/src/api/diagnose.test.ts +102 -1
  12. package/src/api/diagnose.ts +42 -11
  13. package/src/api/drain-plan.test.ts +0 -10
  14. package/src/api/httpErrors.test.ts +95 -0
  15. package/src/api/httpErrors.ts +101 -0
  16. package/src/api/policy.ts +33 -5
  17. package/src/api/radarFeatures.render.test.tsx +190 -0
  18. package/src/api/radarFeatures.test.ts +133 -0
  19. package/src/api/radarFeatures.ts +147 -0
  20. package/src/api/timelineSource.test.ts +2 -10
  21. package/src/api/timelineSource.ts +3 -2
  22. package/src/api/traffic.ts +4 -3
  23. package/src/api/usage-data.fixtures.ts +33 -0
  24. package/src/api/usage-data.ts +165 -0
  25. package/src/components/CloudFunnelButton.test.tsx +2 -2
  26. package/src/components/CloudFunnelButton.tsx +99 -55
  27. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  28. package/src/components/NamespaceSwitcher.tsx +17 -1
  29. package/src/components/SelfManagedStart.tsx +95 -0
  30. package/src/components/applications/ApplicationsView.tsx +20 -6
  31. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  32. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  33. package/src/components/capacity/CapacityView.test.tsx +13 -4
  34. package/src/components/capacity/shared.tsx +14 -13
  35. package/src/components/cloudConnectHandoff.test.ts +1 -1
  36. package/src/components/cloudConnectHandoff.ts +1 -1
  37. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  38. package/src/components/cost/CostConnectionAction.tsx +30 -0
  39. package/src/components/cost/CostTrendChart.tsx +2 -0
  40. package/src/components/cost/CostView.tsx +4 -24
  41. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  42. package/src/components/cost/source.test.ts +30 -3
  43. package/src/components/cost/source.ts +37 -2
  44. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  45. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  46. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  47. package/src/components/dialog-names.test.ts +69 -0
  48. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  49. package/src/components/dock/TerminalTab.tsx +2 -1
  50. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  51. package/src/components/execution/BatchExecutionView.tsx +1 -1
  52. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  53. package/src/components/execution/JobSetAdmission.tsx +16 -7
  54. package/src/components/gitops/GitOpsView.tsx +12 -0
  55. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  56. package/src/components/home/HomeView.tsx +7 -3
  57. package/src/components/home/MCPSetupDialog.tsx +37 -37
  58. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  59. package/src/components/home/RadarVersionLine.tsx +28 -3
  60. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  61. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  62. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  63. package/src/components/resource/PVCUsageBar.tsx +4 -1
  64. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  65. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  66. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  67. package/src/components/resources/PodFilePreview.tsx +4 -1
  68. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  69. package/src/components/resources/ResourcesView.tsx +2 -1
  70. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  71. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -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/rightsizing/RightsizingScanView.tsx +4 -1
  75. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  76. package/src/components/settings/FormSaveActions.tsx +76 -0
  77. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  78. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  79. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  80. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  81. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  82. package/src/components/settings/PrivacySection.test.tsx +84 -0
  83. package/src/components/settings/PrivacySection.tsx +140 -0
  84. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  85. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  86. package/src/components/settings/SettingsDialog.tsx +393 -571
  87. package/src/components/settings/controls.tsx +55 -0
  88. package/src/components/settings/currency-options.test.ts +1 -1
  89. package/src/components/settings/currency-options.ts +1 -1
  90. package/src/components/settings/settings-state.test.ts +17 -10
  91. package/src/components/settings/settings-state.ts +21 -12
  92. package/src/components/timeline/TimelineList.test.ts +56 -0
  93. package/src/components/timeline/TimelineList.tsx +7 -1
  94. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  95. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  96. package/src/components/traffic/TrafficGraph.tsx +79 -48
  97. package/src/components/traffic/TrafficView.tsx +61 -37
  98. package/src/components/traffic/TrafficWizard.tsx +4 -2
  99. package/src/components/traffic/trafficFilters.test.ts +154 -1
  100. package/src/components/traffic/trafficFilters.ts +141 -0
  101. package/src/components/ui/ErrorBoundary.tsx +5 -0
  102. package/src/components/ui/Omnibar.tsx +17 -1
  103. package/src/components/ui/UpdateNotification.tsx +70 -21
  104. package/src/components/ui/command-items.ts +14 -0
  105. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  106. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  107. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  108. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  109. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  110. package/src/components/whats-new/WhatsNew.tsx +423 -0
  111. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  112. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  113. package/src/components/whats-new/releaseNotes.ts +121 -0
  114. package/src/components/workload/WorkloadView.tsx +47 -14
  115. package/src/components/workload/historyPaging.test.ts +72 -0
  116. package/src/components/workload/historyPaging.ts +147 -0
  117. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  118. package/src/context/ConnectionContext.tsx +2 -1
  119. package/src/context/RadarUpgrade.tsx +30 -0
  120. package/src/context/RadarUpgradeHost.tsx +23 -0
  121. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  122. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  123. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  124. package/src/index.ts +1 -0
  125. package/src/k8s-ui-exports.test.ts +73 -0
  126. package/src/utils/navigation.test.ts +17 -1
  127. package/src/utils/navigation.ts +16 -0
  128. 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.14.6",
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.14.8",
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.10.0",
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.37.0",
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.2.2"
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.set('ai-run', investigationRunID)
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 resetKey={location.pathname + location.search}>
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 : (path) => navigate(path)}
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
- <ContextSwitchProvider>
2492
- <DockProvider>
2493
- <KeyboardShortcutProvider>
2494
- <AppInner
2495
- manageDocumentTitle={manageDocumentTitle}
2496
- documentTitleSuffix={documentTitleSuffix}
2497
- onClusterLoadStateChange={onClusterLoadStateChange}
2498
- />
2499
- </KeyboardShortcutProvider>
2500
- </DockProvider>
2501
- </ContextSwitchProvider>
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: 1,
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
- <FilterLocationBridge>
232
- <TimelineSourceProvider config={timelineSource}>
233
- <DiagnoseCustomizationProvider
234
- value={renderDiagnoseAction ?? defaultDiagnoseAction}
235
- consentCopy={diagnoseConsent}
236
- renderRunActions={renderInvestigationRunActions}
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
- <App
244
- manageDocumentTitle={manageDocumentTitle}
245
- documentTitleSuffix={documentTitleSuffix}
246
- onClusterLoadStateChange={onClusterLoadStateChange}
247
- />
248
- </DiagnoseProvider>
249
- </DiagnoseCustomizationProvider>
250
- </TimelineSourceProvider>
251
- </FilterLocationBridge>
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
+ })
@@ -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('In-cluster test failed (504)')
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 () => {