@skyhook-io/radar-app 1.15.0 → 1.16.1

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 (89) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +14 -11
  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.capacity.test.ts +15 -0
  8. package/src/api/client.trace.test.ts +9 -1
  9. package/src/api/client.ts +247 -201
  10. package/src/api/diagnose.test.ts +102 -1
  11. package/src/api/diagnose.ts +42 -11
  12. package/src/api/drain-plan.test.ts +0 -10
  13. package/src/api/httpErrors.test.ts +95 -0
  14. package/src/api/httpErrors.ts +101 -0
  15. package/src/api/policy.ts +33 -5
  16. package/src/api/radarFeatures.render.test.tsx +223 -0
  17. package/src/api/radarFeatures.test.ts +142 -0
  18. package/src/api/radarFeatures.ts +148 -0
  19. package/src/api/timelineSource.test.ts +2 -10
  20. package/src/api/timelineSource.ts +3 -2
  21. package/src/api/traffic.ts +4 -3
  22. package/src/components/CloudFunnelButton.tsx +9 -9
  23. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  24. package/src/components/NamespaceSwitcher.tsx +17 -1
  25. package/src/components/SelfManagedStart.tsx +5 -6
  26. package/src/components/applications/ApplicationsView.tsx +20 -6
  27. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  28. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  29. package/src/components/capacity/CapacityView.test.tsx +13 -4
  30. package/src/components/capacity/shared.tsx +14 -13
  31. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  32. package/src/components/cost/CostConnectionAction.tsx +30 -0
  33. package/src/components/cost/CostTrendChart.tsx +2 -0
  34. package/src/components/cost/CostView.tsx +4 -24
  35. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  36. package/src/components/cost/source.test.ts +30 -3
  37. package/src/components/cost/source.ts +37 -2
  38. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  39. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  40. package/src/components/dock/TerminalTab.tsx +2 -1
  41. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  42. package/src/components/gitops/GitOpsView.tsx +12 -0
  43. package/src/components/home/HomeView.tsx +1 -1
  44. package/src/components/home/MCPSetupDialog.tsx +37 -37
  45. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  46. package/src/components/resource/PVCUsageBar.tsx +4 -1
  47. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  48. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  49. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  50. package/src/components/resources/PodFilePreview.tsx +4 -1
  51. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  52. package/src/components/resources/ResourcesView.tsx +2 -1
  53. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  54. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  55. package/src/components/settings/FormSaveActions.tsx +76 -0
  56. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  57. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  58. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  59. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  60. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  61. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  62. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  63. package/src/components/settings/SettingsDialog.tsx +382 -521
  64. package/src/components/settings/currency-options.test.ts +1 -1
  65. package/src/components/settings/currency-options.ts +1 -1
  66. package/src/components/settings/settings-state.test.ts +17 -10
  67. package/src/components/settings/settings-state.ts +20 -12
  68. package/src/components/timeline/TimelineList.test.ts +56 -0
  69. package/src/components/timeline/TimelineList.tsx +7 -1
  70. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  71. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  72. package/src/components/traffic/TrafficGraph.tsx +79 -48
  73. package/src/components/traffic/TrafficView.tsx +61 -37
  74. package/src/components/traffic/TrafficWizard.tsx +4 -2
  75. package/src/components/traffic/trafficFilters.test.ts +154 -1
  76. package/src/components/traffic/trafficFilters.ts +141 -0
  77. package/src/components/ui/Omnibar.tsx +1 -1
  78. package/src/components/ui/UpdateNotification.tsx +57 -12
  79. package/src/components/workload/WorkloadView.tsx +44 -6
  80. package/src/components/workload/historyPaging.test.ts +72 -0
  81. package/src/components/workload/historyPaging.ts +147 -0
  82. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  83. package/src/context/ConnectionContext.tsx +2 -1
  84. package/src/context/RadarUpgrade.tsx +30 -0
  85. package/src/context/RadarUpgradeHost.tsx +23 -0
  86. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  87. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  88. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  89. package/src/index.ts +1 -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.15.0",
3
+ "version": "1.16.1",
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.1",
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,6 +46,7 @@ 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'
@@ -2533,17 +2534,19 @@ function App({ manageDocumentTitle = false, documentTitleSuffix, onClusterLoadSt
2533
2534
  return (
2534
2535
  <ConnectionProvider>
2535
2536
  <CapabilitiesProvider>
2536
- <ContextSwitchProvider>
2537
- <DockProvider>
2538
- <KeyboardShortcutProvider>
2539
- <AppInner
2540
- manageDocumentTitle={manageDocumentTitle}
2541
- documentTitleSuffix={documentTitleSuffix}
2542
- onClusterLoadStateChange={onClusterLoadStateChange}
2543
- />
2544
- </KeyboardShortcutProvider>
2545
- </DockProvider>
2546
- </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>
2547
2550
  </CapabilitiesProvider>
2548
2551
  </ConnectionProvider>
2549
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 {
@@ -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 () => {