@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
@@ -0,0 +1,47 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import type { ReactNode } from 'react'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ const state = vi.hoisted(() => ({
6
+ mode: 'local' as string | undefined,
7
+ authEnabled: false as boolean | undefined,
8
+ }))
9
+
10
+ vi.mock('@skyhook-io/k8s-ui', () => ({
11
+ NamespacePicker: ({ limitedListHelp }: { limitedListHelp?: ReactNode }) => <div>{limitedListHelp ?? 'no-help'}</div>,
12
+ }))
13
+ vi.mock('../api/client', () => ({
14
+ useNamespaceScope: () => ({ data: undefined, isLoading: false }),
15
+ useSetActiveNamespace: () => ({ isPending: false, mutate: vi.fn() }),
16
+ useCapabilities: () => ({ data: state.mode === undefined ? undefined : { deployment: { mode: state.mode } } }),
17
+ useAuthMe: () => ({ data: state.authEnabled === undefined ? undefined : { authEnabled: state.authEnabled } }),
18
+ }))
19
+
20
+ import { NamespaceSwitcher } from './NamespaceSwitcher'
21
+
22
+ describe('NamespaceSwitcher namespace help', () => {
23
+ beforeEach(() => {
24
+ state.mode = 'local'
25
+ state.authEnabled = false
26
+ })
27
+
28
+ it('links to the namespace docs in local Radar without auth', () => {
29
+ const html = renderToStaticMarkup(<NamespaceSwitcher />)
30
+ expect(html).toContain('How to add namespaces')
31
+ expect(html).toContain('#namespaces-missing-from-the-picker')
32
+ })
33
+
34
+ // --namespaces and config.json belong to whoever runs the server; on a shared
35
+ // install the viewer can't use them.
36
+ it.each([
37
+ ['in-cluster Radar', 'in-cluster', false],
38
+ ['Radar Cloud', 'cloud', true],
39
+ ['auth-enabled local Radar', 'local', true],
40
+ ['capabilities still loading', undefined, false],
41
+ ['auth state still loading', 'local', undefined],
42
+ ] as const)('offers no help for %s', (_label, mode, authEnabled) => {
43
+ state.mode = mode
44
+ state.authEnabled = authEnabled
45
+ expect(renderToStaticMarkup(<NamespaceSwitcher />)).toContain('no-help')
46
+ })
47
+ })
@@ -1,9 +1,11 @@
1
1
  import { forwardRef } from 'react'
2
2
  import { NamespacePicker, type NamespacePickerHandle } from '@skyhook-io/k8s-ui'
3
- import { useNamespaceScope, useSetActiveNamespace } from '../api/client'
3
+ import { useAuthMe, useCapabilities, useNamespaceScope, useSetActiveNamespace } from '../api/client'
4
4
 
5
5
  export type NamespaceSwitcherHandle = NamespacePickerHandle
6
6
 
7
+ const NAMESPACES_HELP_URL = 'https://radarhq.io/docs/configuration/files#namespaces-missing-from-the-picker'
8
+
7
9
  interface NamespaceSwitcherProps {
8
10
  className?: string
9
11
  disabled?: boolean
@@ -24,6 +26,19 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
24
26
  const { data: scope, isLoading } = useNamespaceScope()
25
27
  const setActive = useSetActiveNamespace()
26
28
 
29
+ const { data: capabilities } = useCapabilities()
30
+ const { data: authMe } = useAuthMe()
31
+ // --namespaces and ~/.radar/config.json only reach a Radar the user launched
32
+ // themselves. With auth enabled, a non-authoritative list is also the
33
+ // per-user RBAC filter on a shared install, where neither applies.
34
+ const canConfigureNamespaces = capabilities?.deployment?.mode === 'local' && authMe?.authEnabled === false
35
+
36
+ const limitedListHelp = canConfigureNamespaces ? (
37
+ <a href={NAMESPACES_HELP_URL} target="_blank" rel="noreferrer" className="text-accent-text hover:underline">
38
+ How to add namespaces
39
+ </a>
40
+ ) : undefined
41
+
27
42
  return (
28
43
  <NamespacePicker
29
44
  ref={ref}
@@ -36,6 +51,7 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
36
51
  className={className}
37
52
  variant={variant}
38
53
  label={label}
54
+ limitedListHelp={limitedListHelp}
39
55
  />
40
56
  )
41
57
  })
@@ -54,14 +54,13 @@ export function SelfManagedStart({ appUrl, onBack }: { appUrl: string; onBack: (
54
54
  </li>
55
55
  ))}
56
56
  </ol>
57
- {/* What a platform engineer has to bring, split by stage: a trial
58
- needs only an address clusters can reach (a cloud load balancer
59
- is the install page's default; Postgres and a built-in admin come
60
- bundled); production adds their own Postgres and IdP. */}
57
+ {/* Split by stage so a trial doesn't look like a production install:
58
+ the trial bundles Postgres and a built-in admin. */}
61
59
  <div className="mt-5 space-y-1 text-[12px] leading-relaxed text-theme-text-tertiary">
62
60
  <p>
63
- <b className="font-medium text-theme-text-secondary">For a trial</b>, you just need a hostname your
64
- clusters can reach the hub on.
61
+ <b className="font-medium text-theme-text-secondary">For a trial</b>, install it in one cluster and open
62
+ it with port-forward, no public address needed. To connect more clusters, give it an address they can
63
+ reach.
65
64
  </p>
66
65
  <p>
67
66
  <b className="font-medium text-theme-text-secondary">For production</b>, connect your own Postgres and
@@ -4,7 +4,11 @@ import {
4
4
  ApplicationsList,
5
5
  ApplicationDetail,
6
6
  CenteredEmpty,
7
+ getRadarUpgradeRequirement,
7
8
  PageHeader,
9
+ RadarUpgradeAction,
10
+ radarUpgradeDetail,
11
+ radarUpgradeHeadline,
8
12
  FreshnessControl,
9
13
  IssueRow,
10
14
  ISSUE_SEVERITY_RANK,
@@ -166,6 +170,7 @@ export function ApplicationsView({
166
170
  );
167
171
  }
168
172
  if (query.error) {
173
+ const upgrade = getRadarUpgradeRequirement(query.error);
169
174
  return (
170
175
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
171
176
  <div className="shrink-0 border-b border-theme-border px-4 py-4">
@@ -175,12 +180,21 @@ export function ApplicationsView({
175
180
  description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
176
181
  />
177
182
  </div>
178
- <CenteredEmpty
179
- tone="filtered"
180
- icon={Boxes}
181
- headline="Failed to load applications"
182
- body={(query.error as Error).message}
183
- />
183
+ {upgrade ? (
184
+ <CenteredEmpty
185
+ icon={Boxes}
186
+ headline={radarUpgradeHeadline("Applications")}
187
+ body={radarUpgradeDetail(upgrade)}
188
+ action={<RadarUpgradeAction requirement={upgrade} />}
189
+ />
190
+ ) : (
191
+ <CenteredEmpty
192
+ tone="filtered"
193
+ icon={Boxes}
194
+ headline="Failed to load applications"
195
+ body={(query.error as Error).message}
196
+ />
197
+ )}
184
198
  </div>
185
199
  );
186
200
  }
@@ -1,18 +1,26 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToStaticMarkup } from 'react-dom/server'
3
3
  import { ApiError } from '../../api/client'
4
- import { UPGRADE_IMPACT_DOCS_URL, UPGRADE_IMPACT_MIN_RADAR_VERSION, UpgradeReadinessError, groupFindings, incompleteUpgradeCheckCount, issueSpecificReferences, summaryMeta, upgradeEvaluationSummary, upgradeEvidenceCoverageLabel, upgradeUnavailableKindsMessage } from './UpgradeReadinessView'
4
+ import { RadarFeatureUnsupportedError } from '../../api/radarFeatures'
5
+ import { UPGRADE_IMPACT_DOCS_URL, UpgradeReadinessError, groupFindings, incompleteUpgradeCheckCount, issueSpecificReferences, summaryMeta, upgradeEvaluationSummary, upgradeEvidenceCoverageLabel, upgradeUnavailableKindsMessage } from './UpgradeReadinessView'
5
6
 
6
7
  describe('UpgradeReadinessError', () => {
7
- it('maps an unmatched endpoint 404 to the v1.9 upgrade message', () => {
8
- const html = renderToStaticMarkup(UpgradeReadinessError({ error: new ApiError('Unknown error', 404) }))
8
+ it('maps an unsupported-feature error to the v1.9 upgrade message', () => {
9
+ const html = renderToStaticMarkup(UpgradeReadinessError({ error: new RadarFeatureUnsupportedError('upgradeReadiness', {}) }))
9
10
 
10
- expect(html).toContain('Upgrade impact needs a newer Radar')
11
- expect(html).toContain(UPGRADE_IMPACT_MIN_RADAR_VERSION)
12
- expect(html).toContain('Upgrade the in-cluster Radar to enable it')
11
+ expect(html).toContain('Upgrade impact needs a newer Radar on this cluster')
12
+ expect(html).toContain('Available from Radar v1.9.')
13
13
  expect(html).not.toContain('Unable to analyze upgrade impact')
14
14
  })
15
15
 
16
+ it('keeps a non-JSON 404 that is not the router signature on the generic error state', () => {
17
+ // e.g. Radar Hub's plain-text "not found" for a cluster it can't resolve.
18
+ const html = renderToStaticMarkup(UpgradeReadinessError({ error: new ApiError('HTTP 404 (Not Found)', 404) }))
19
+
20
+ expect(html).toContain('Unable to analyze upgrade impact')
21
+ expect(html).not.toContain('needs a newer Radar')
22
+ })
23
+
16
24
  it('keeps a real resource 404 on the generic error state', () => {
17
25
  const html = renderToStaticMarkup(UpgradeReadinessError({ error: new ApiError('Cluster not found', 404) }))
18
26
 
@@ -19,8 +19,12 @@ import {
19
19
  CollapseChevron,
20
20
  EmptyState,
21
21
  FreshnessControl,
22
+ getRadarUpgradeRequirement,
22
23
  PageHeader,
23
24
  PaneLoader,
25
+ RadarUpgradeAction,
26
+ radarUpgradeDetail,
27
+ radarUpgradeHeadline,
24
28
  SelectMenu,
25
29
  } from '@skyhook-io/k8s-ui'
26
30
  import {
@@ -43,7 +47,6 @@ interface UpgradeReadinessViewProps {
43
47
 
44
48
  const FINDING_CAP = 8
45
49
  export const UPGRADE_IMPACT_DOCS_URL = 'https://radarhq.io/docs/features/upgrade-impact'
46
- export const UPGRADE_IMPACT_MIN_RADAR_VERSION = 'v1.9.0'
47
50
 
48
51
  const statusMeta: Record<UpgradeReadinessCheckStatus, {
49
52
  label: string
@@ -250,22 +253,15 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
250
253
  }
251
254
 
252
255
  export function UpgradeReadinessError({ error, onResetTarget }: { error: unknown; onResetTarget?: () => void }) {
253
- const missingEndpoint = error instanceof ApiError
254
- && error.status === 404
255
- && error.message === 'Unknown error'
256
- if (missingEndpoint) {
256
+ const upgrade = getRadarUpgradeRequirement(error)
257
+ if (upgrade) {
257
258
  return (
258
259
  <EmptyState
259
260
  tone="neutral"
260
261
  icon={ShieldAlert}
261
- headline="Upgrade impact needs a newer Radar"
262
- body={
263
- <>
264
- This cluster&rsquo;s Radar predates Upgrade impact (added in Radar {UPGRADE_IMPACT_MIN_RADAR_VERSION}).
265
- <br />
266
- Upgrade the in-cluster Radar to enable it.
267
- </>
268
- }
262
+ headline={radarUpgradeHeadline('Upgrade impact')}
263
+ body={radarUpgradeDetail(upgrade)}
264
+ action={<RadarUpgradeAction requirement={upgrade} />}
269
265
  />
270
266
  )
271
267
  }
@@ -26,6 +26,7 @@ import { CapacityView } from "./CapacityView";
26
26
  import { updateDemandSearchParam } from "./CapacityDemand";
27
27
  import { integrationBlock } from "./shared";
28
28
  import { ApiError } from "../../api/client";
29
+ import { RadarFeatureUnsupportedError } from "../../api/radarFeatures";
29
30
 
30
31
  vi.mock("../../context/ConnectionContext", () => ({
31
32
  useConnection: () => ({ connection: { state: "connected" } }),
@@ -2289,15 +2290,23 @@ describe("CapacityView activity", () => {
2289
2290
  });
2290
2291
 
2291
2292
  describe("integrationBlock version skew", () => {
2292
- it("maps a top-level 404 to the pre-v1.9.0 upgrade message, never 'Unknown error'", () => {
2293
+ it("maps an unsupported-feature error to the upgrade message, never 'Unknown error'", () => {
2293
2294
  const html = renderToString(
2294
- <>{integrationBlock(undefined, new ApiError("404 page not found", 404), false, "Loading")}</>,
2295
+ <>{integrationBlock(undefined, new RadarFeatureUnsupportedError("capacity", { currentVersion: "v1.7.2" }), false, "Loading")}</>,
2295
2296
  );
2296
- expect(html).toContain("Capacity needs a newer Radar");
2297
- expect(html).toContain("v1.9.0");
2297
+ expect(html).toContain("Capacity needs a newer Radar on this cluster");
2298
+ expect(html).toContain("Available from Radar v1.9. This cluster runs v1.7.2.");
2298
2299
  expect(html).not.toContain("Capacity unavailable");
2299
2300
  });
2300
2301
 
2302
+ it("does not read a handler's JSON 404 as a missing route", () => {
2303
+ const html = renderToString(
2304
+ <>{integrationBlock(undefined, new ApiError("nodepool not found", 404, { error: "nodepool not found" }), false, "Loading")}</>,
2305
+ );
2306
+ expect(html).toContain("Capacity unavailable");
2307
+ expect(html).not.toContain("needs a newer Radar");
2308
+ });
2309
+
2301
2310
  it("keeps ordinary failures on the generic error state", () => {
2302
2311
  const html = renderToString(
2303
2312
  <>{integrationBlock(undefined, new ApiError("boom", 500), false, "Loading")}</>,
@@ -15,6 +15,10 @@ import {
15
15
  import {
16
16
  FreshnessControl,
17
17
  formatDuration,
18
+ getRadarUpgradeRequirement,
19
+ RadarUpgradeAction,
20
+ radarUpgradeDetail,
21
+ radarUpgradeHeadline,
18
22
  memberRef,
19
23
  PaneLoader,
20
24
  ResourceBar,
@@ -51,7 +55,6 @@ import {
51
55
  import {
52
56
  isCapacityCursorInvalidError,
53
57
  isForbiddenError,
54
- isNotFoundError,
55
58
  useCapacityPools,
56
59
  } from "../../api/client";
57
60
  import type { SelectedResource } from "../../types";
@@ -1520,21 +1523,19 @@ export function integrationBlock(
1520
1523
  if (!response && error) {
1521
1524
  if (isForbiddenError(error))
1522
1525
  return <DeniedCapacityState detail={errorMessage(error)} />;
1523
- // A 404 on the top-level capacity routes means the route doesn't exist on
1524
- // this Radar — a pre-v1.9.0 binary behind a version-skewed host (Radar
1525
- // Cloud), not a failure. The embedded OSS binary can never hit this.
1526
- if (isNotFoundError(error))
1526
+ // Only reachable behind a version-skewed host (Radar Cloud): the OSS
1527
+ // binary always serves the routes its own frontend calls.
1528
+ const upgrade = getRadarUpgradeRequirement(error);
1529
+ if (upgrade)
1527
1530
  return (
1528
1531
  <EmptyState
1529
1532
  icon={Gauge}
1530
- title="Capacity needs a newer Radar"
1531
- detail={
1532
- <>
1533
- This cluster&rsquo;s Radar predates the Capacity view (added in
1534
- Radar v1.9.0).
1535
- <br />
1536
- Upgrade the in-cluster Radar to enable it.
1537
- </>
1533
+ title={radarUpgradeHeadline("Capacity")}
1534
+ detail={radarUpgradeDetail(upgrade)}
1535
+ action={
1536
+ <div className="mt-3 text-sm">
1537
+ <RadarUpgradeAction requirement={upgrade} />
1538
+ </div>
1538
1539
  }
1539
1540
  />
1540
1541
  );
@@ -33,6 +33,7 @@ import {
33
33
  isCostDiscoveryPending,
34
34
  } from './source'
35
35
  import { useNavCustomization } from '../../context/NavCustomization'
36
+ import { CostConnectionAction } from './CostConnectionAction'
36
37
 
37
38
  type ApplicationCostState =
38
39
  | 'loading'
@@ -130,6 +131,8 @@ export function ApplicationCostTab({
130
131
  state === 'authentication_error' ||
131
132
  state === 'configuration_mismatch' ||
132
133
  state === 'deployment_configuration_error' ||
134
+ state === 'metrics_settings_error' ||
135
+ state === 'cost_settings_error' ||
133
136
  state === 'history_unsupported' ||
134
137
  state === 'load_error'
135
138
  ) {
@@ -398,7 +401,9 @@ export function getApplicationCostState(
398
401
  reason === 'source_unavailable' ||
399
402
  reason === 'authentication_error' ||
400
403
  reason === 'configuration_mismatch' ||
401
- reason === 'deployment_configuration_error'
404
+ reason === 'deployment_configuration_error' ||
405
+ reason === 'metrics_settings_error' ||
406
+ reason === 'cost_settings_error'
402
407
  )
403
408
  return reason
404
409
  if (queryError) return 'load_error'
@@ -512,6 +517,8 @@ function reasonLabel(reason?: CostUnavailableReason) {
512
517
  if (reason === 'not_found') return 'Workload not found'
513
518
  if (reason === 'configuration_mismatch') return 'Kubecost settings are not valid for this cluster'
514
519
  if (reason === 'deployment_configuration_error') return 'Radar cost deployment is misconfigured'
520
+ if (reason === 'metrics_settings_error') return 'Saved metrics settings need review'
521
+ if (reason === 'cost_settings_error') return 'Saved cost settings need review'
515
522
  return 'No workload cost metrics'
516
523
  }
517
524
 
@@ -577,6 +584,7 @@ function ApplicationCostUnavailable({
577
584
  <div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
578
585
  <Coins className="h-8 w-8 text-theme-text-tertiary/50" />
579
586
  <div className="text-sm">{text}</div>
587
+ <CostConnectionAction reason={state} />
580
588
  </div>
581
589
  </div>
582
590
  )
@@ -0,0 +1,30 @@
1
+ import type { CostUnavailableReason } from '../../api/client'
2
+ import type { ReactNode } from 'react'
3
+ import { useNavCustomization } from '../../context/NavCustomization'
4
+ import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
5
+ import { usePreviousIntegrationSettings } from '../../hooks/usePreviousIntegrationSettings'
6
+ import { costConfigurationAction, isCostConfigurable } from './source'
7
+
8
+ export function CostConnectionAction({ reason, children }: { reason?: CostUnavailableReason | 'load_error'; children?: ReactNode }) {
9
+ const { embedded } = useNavCustomization()
10
+ const { configManagement } = useCapabilitiesContext()
11
+ const available = !embedded && isCostConfigurable(reason)
12
+ const offers = usePreviousIntegrationSettings(available)
13
+ if (!available) return <>{children}</>
14
+ const action = costConfigurationAction(reason as CostUnavailableReason, offers)
15
+ return (
16
+ <div className="flex flex-col items-center gap-2 text-center">
17
+ {action.note && <p className="text-xs text-theme-text-secondary">{action.note}</p>}
18
+ <div className="flex flex-wrap items-center justify-center gap-2">
19
+ <button
20
+ type="button"
21
+ onClick={() => window.dispatchEvent(new CustomEvent('radar:open-settings', { detail: { section: action.section } }))}
22
+ className="btn-brand px-3 py-1.5 text-xs font-medium"
23
+ >
24
+ {configManagement === 'operator' || configManagement === 'cloud' ? 'View connection settings' : action.label}
25
+ </button>
26
+ {children}
27
+ </div>
28
+ </div>
29
+ )
30
+ }
@@ -513,6 +513,8 @@ export function kubecostTrendUnavailableMessage(
513
513
  return 'Radar could not reach Kubecost. Check Settings → Cost.'
514
514
  case 'configuration_mismatch':
515
515
  return 'Kubecost returned data for a different cluster. Check the cluster ID in Settings → Cost.'
516
+ case 'cost_settings_error':
517
+ return 'This cluster’s saved cost settings need review. Check Settings → Cost.'
516
518
  default:
517
519
  return 'Kubecost history could not be loaded.'
518
520
  }
@@ -41,8 +41,8 @@ import { clusterCloudConsoleLink, nodeCloudConsoleLink } from './cloud-console'
41
41
  import { RightsizingScanView } from '../rightsizing/RightsizingScanView'
42
42
  import { CostViewTabs } from './CostViewTabs'
43
43
  import { useNavCustomization } from '../../context/NavCustomization'
44
+ import { CostConnectionAction } from './CostConnectionAction'
44
45
  import {
45
- costConfigurationAction,
46
46
  costFreshnessLabel,
47
47
  costIntegrationUnavailableMessage,
48
48
  costRateLabels,
@@ -57,13 +57,6 @@ interface CostViewProps {
57
57
  }
58
58
 
59
59
  const SYSTEM_COST_NAMESPACES = new Set(['kube-system', 'kube-public', 'kube-node-lease'])
60
- const CONFIGURABLE_COST_REASONS = new Set<CostUnavailableReason>([
61
- 'no_prometheus',
62
- 'no_cost_source',
63
- 'source_unavailable',
64
- 'authentication_error',
65
- 'configuration_mismatch',
66
- ])
67
60
 
68
61
  export function CostView(props: CostViewProps) {
69
62
  const { pathname } = useLocation()
@@ -137,8 +130,6 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
137
130
  settingsAvailable,
138
131
  namespaceScoped: namespaceScopeCount > 0,
139
132
  })
140
- const canConfigure = settingsAvailable && reason != null && CONFIGURABLE_COST_REASONS.has(reason)
141
- const configureAction = costConfigurationAction(reason)
142
133
 
143
134
  return (
144
135
  <CostOverviewState>
@@ -146,18 +137,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
146
137
  <div className="flex flex-col items-center gap-3 text-theme-text-secondary">
147
138
  <Coins className="w-8 h-8 text-theme-text-tertiary/40" />
148
139
  <p className="text-sm">{message}</p>
149
- <div className="flex flex-wrap items-center justify-center gap-2">
150
- {canConfigure && (
151
- <button
152
- type="button"
153
- onClick={() => window.dispatchEvent(
154
- new CustomEvent('radar:open-settings', { detail: { section: configureAction.section } }),
155
- )}
156
- className="btn-brand px-3 py-1.5 text-xs font-medium"
157
- >
158
- {configureAction.label}
159
- </button>
160
- )}
140
+ <CostConnectionAction reason={reason}>
161
141
  {(reason === 'no_prometheus' || reason === 'no_cost_source' || reason === 'source_unavailable') && (
162
142
  <button
163
143
  onClick={() => {
@@ -170,7 +150,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
170
150
  {isFetching ? 'Checking…' : 'Check again'}
171
151
  </button>
172
152
  )}
173
- </div>
153
+ </CostConnectionAction>
174
154
  <button
175
155
  onClick={onBack}
176
156
  className="text-xs text-accent-text hover:text-theme-text-primary transition-colors"
@@ -893,7 +873,7 @@ function CostHelpDialog({ currency, source, window, onClose }: { currency: strin
893
873
  Which currency is shown?
894
874
  </h3>
895
875
  <p>
896
- Radar labels these values <strong>{currency}</strong> and does not convert them. Auto
876
+ Radar labels these values <strong>{currency}</strong> and does not convert them. Automatic
897
877
  reads <code>currencyCode</code> or <code>DISPLAY_CURRENCY</code> from a
898
878
  cluster-discovered OpenCost installation or any active Kubecost installation, then
899
879
  falls back to USD. A manually configured Prometheus URL disables OpenCost currency
@@ -28,6 +28,7 @@ import {
28
28
  isCostDiscoveryPending,
29
29
  } from './source'
30
30
  import { useNavCustomization } from '../../context/NavCustomization'
31
+ import { CostConnectionAction } from './CostConnectionAction'
31
32
 
32
33
  type WorkloadCostState =
33
34
  | 'loading'
@@ -98,6 +99,8 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
98
99
  state === 'authentication_error' ||
99
100
  state === 'configuration_mismatch' ||
100
101
  state === 'deployment_configuration_error' ||
102
+ state === 'metrics_settings_error' ||
103
+ state === 'cost_settings_error' ||
101
104
  state === 'history_unsupported' ||
102
105
  state === 'load_error'
103
106
  ) {
@@ -306,7 +309,9 @@ export function getWorkloadCostState(
306
309
  reason === 'source_unavailable' ||
307
310
  reason === 'authentication_error' ||
308
311
  reason === 'configuration_mismatch' ||
309
- reason === 'deployment_configuration_error'
312
+ reason === 'deployment_configuration_error' ||
313
+ reason === 'metrics_settings_error' ||
314
+ reason === 'cost_settings_error'
310
315
  )
311
316
  return reason
312
317
  if (queryError) return 'load_error'
@@ -375,6 +380,7 @@ function WorkloadCostUnavailable({
375
380
  <div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
376
381
  <Coins className="h-8 w-8 text-theme-text-tertiary/50" />
377
382
  <div className="text-sm">{message}</div>
383
+ <CostConnectionAction reason={state} />
378
384
  </div>
379
385
  </div>
380
386
  )
@@ -1,10 +1,30 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { costDataThroughLabel, costFreshnessLabel, costIntegrationUnavailableMessage, costRateLabels, costSourceLabel, isCostDiscoveryPending } from './source'
2
+ import { COST_SOURCE_OPTIONS, costSourcePreferenceLabel, costConfigurationAction, isCostConfigurable, costDataThroughLabel, costFreshnessLabel, costIntegrationUnavailableMessage, costRateLabels, costSourceLabel, isCostDiscoveryPending } from './source'
3
3
 
4
4
  describe('cost source presentation', () => {
5
+ it('routes missing sources to one applicable previous connection', () => {
6
+ const both = { metrics: 'previous', cost: 'previous', explicitCostBackend: true } as const
7
+ expect(costConfigurationAction('no_prometheus', both).section).toBe('prometheus')
8
+ expect(costConfigurationAction('no_cost_source', both).section).toBe('cost')
9
+ expect(costConfigurationAction('no_prometheus', { ...both, metrics: undefined }).section).toBe('cost')
10
+ expect(costConfigurationAction('no_cost_source', { ...both, explicitCostBackend: false }).section).toBe('prometheus')
11
+ expect(costConfigurationAction('no_prometheus', { ...both, metrics: undefined, explicitCostBackend: false }).label).toBe('Configure metrics')
12
+ expect(costConfigurationAction('authentication_error', { ...both, cost: undefined }).label).toBe('Configure cost source')
13
+ expect(costConfigurationAction('configuration_mismatch', both).section).toBe('cost')
14
+ })
15
+ it('keeps the neutral action and explains reuse in the note', () => {
16
+ const copy = { metrics: 'copy', explicitCostBackend: false } as const
17
+ expect(costConfigurationAction('no_prometheus', copy)).toMatchObject({ section: 'prometheus', label: 'Configure metrics' })
18
+ expect(costConfigurationAction('no_prometheus', copy).note).toContain('if that backend also serves this cluster')
19
+ expect(costConfigurationAction('no_prometheus', { ...copy, metrics: 'previous' })).toMatchObject({ label: 'Configure metrics' })
20
+ expect(costConfigurationAction('no_prometheus', { ...copy, metrics: 'previous' }).note).toContain('now saved per cluster')
21
+ })
22
+ it.each(['access_denied', 'not_found', 'history_unsupported', 'no_workload_data', 'no_metrics', 'query_error', 'load_error', undefined])('does not offer connection edits for %s', reason => {
23
+ expect(isCostConfigurable(reason)).toBe(false)
24
+ })
5
25
  it('distinguishes Prometheus windows from Kubecost ETL freshness', () => {
6
- expect(costSourceLabel('prometheus')).toBe('OpenCost via Prometheus')
7
- expect(costSourceLabel('kubecost')).toBe('Kubecost Aggregator')
26
+ expect(costSourceLabel('prometheus')).toBe('OpenCost metrics')
27
+ expect(costSourceLabel('kubecost')).toBe('Kubecost')
8
28
  expect(costFreshnessLabel('prometheus', '1h')).toBe('last 1h average')
9
29
  expect(costFreshnessLabel('kubecost', '1h', '2026-08-26T13:58:00Z')).toContain('1-hour allocation average')
10
30
  expect(costFreshnessLabel('kubecost', '1d', '2026-08-26T13:58:00Z')).toContain('1-day allocation average')
@@ -12,6 +32,13 @@ describe('cost source presentation', () => {
12
32
  expect(costDataThroughLabel('invalid')).toBe('')
13
33
  })
14
34
 
35
+ it('names cost-source preferences the same way in every install type', () => {
36
+ expect(COST_SOURCE_OPTIONS.map(option => option.label)).toEqual(['Automatic', 'OpenCost metrics', 'Kubecost'])
37
+ expect(costSourcePreferenceLabel(undefined)).toBe('Automatic')
38
+ expect(costSourcePreferenceLabel('')).toBe('Automatic')
39
+ expect(costSourcePreferenceLabel('prometheus')).toBe('OpenCost metrics')
40
+ })
41
+
15
42
  it('labels fallback allocation windows without calling a daily average current', () => {
16
43
  expect(costRateLabels('1h').hourly).toBe('Hourly (1-hour average)')
17
44
  expect(costRateLabels('1d').rate).toBe('1-day average hourly rate')
@@ -1,17 +1,48 @@
1
1
  import type { CostDataSource, CostUnavailableReason } from '../../api/client'
2
+ import { previousSettingsNote, type PreviousIntegrationSettings } from '../../hooks/usePreviousIntegrationSettings'
3
+
4
+ export function isCostConfigurable(reason?: string): boolean {
5
+ return reason === 'no_prometheus' || reason === 'no_cost_source' || reason === 'source_unavailable'
6
+ || reason === 'authentication_error' || reason === 'configuration_mismatch'
7
+ || reason === 'metrics_settings_error' || reason === 'cost_settings_error'
8
+ }
9
+
10
+ export type CostSourcePreference = 'auto' | CostDataSource
11
+
12
+ export const COST_SOURCE_OPTIONS: { value: CostSourcePreference; label: string; description: string }[] = [
13
+ { value: 'auto', label: 'Automatic', description: 'OpenCost metrics when available, otherwise Kubecost' },
14
+ { value: 'prometheus', label: 'OpenCost metrics', description: 'From the metrics connection' },
15
+ { value: 'kubecost', label: 'Kubecost', description: 'From a Kubecost Aggregator' },
16
+ ]
17
+
18
+ export function costSourcePreferenceLabel(preference?: string): string {
19
+ return COST_SOURCE_OPTIONS.find(option => option.value === (preference || 'auto'))?.label ?? preference ?? 'Automatic'
20
+ }
2
21
 
3
22
  export function costSourceLabel(source?: CostDataSource): string {
4
- return source === 'kubecost' ? 'Kubecost Aggregator' : 'OpenCost via Prometheus'
23
+ return costSourcePreferenceLabel(source === 'kubecost' ? 'kubecost' : 'prometheus')
5
24
  }
6
25
 
7
26
  export function isCostDiscoveryPending(reason?: string): boolean {
8
27
  return reason === 'no_prometheus' || reason === 'no_cost_source'
9
28
  }
10
29
 
11
- export function costConfigurationAction(reason?: CostUnavailableReason): {
30
+ export function costConfigurationAction(reason?: CostUnavailableReason, offers?: PreviousIntegrationSettings): {
12
31
  section: 'prometheus' | 'cost'
13
32
  label: string
33
+ note?: string
14
34
  } {
35
+ if (reason === 'metrics_settings_error') return { section: 'prometheus', label: 'Review metrics settings' }
36
+ if (reason === 'cost_settings_error') return { section: 'cost', label: 'Review cost settings' }
37
+ const previousKind = reason === 'no_prometheus'
38
+ ? offers?.metrics ? 'metrics' : offers?.explicitCostBackend ? 'cost' : undefined
39
+ : reason === 'no_cost_source'
40
+ ? offers?.explicitCostBackend ? 'cost' : offers?.metrics ? 'metrics' : undefined
41
+ : isCostConfigurable(reason) && offers?.cost ? 'cost' : undefined
42
+ const offer = previousKind && offers?.[previousKind]
43
+ if (previousKind && offer) return previousKind === 'metrics'
44
+ ? { section: 'prometheus', label: 'Configure metrics', note: previousSettingsNote('metrics', offer) }
45
+ : { section: 'cost', label: 'Configure cost source', note: previousSettingsNote('cost', offer) }
15
46
  return reason === 'no_prometheus'
16
47
  ? { section: 'prometheus', label: 'Configure metrics' }
17
48
  : { section: 'cost', label: 'Configure cost source' }
@@ -84,6 +115,10 @@ export function costIntegrationUnavailableMessage(
84
115
  return settingsAvailable
85
116
  ? 'Kubecost Aggregator is unavailable. Check the URL, network path, and cluster ID in Settings → Cost.'
86
117
  : 'Kubecost Aggregator is unavailable. Update this cluster’s cost-source configuration in the host application or Radar deployment.'
118
+ case 'metrics_settings_error':
119
+ return 'This cluster’s saved metrics settings need review before cost data can load. Review them in Settings → Metrics.'
120
+ case 'cost_settings_error':
121
+ return 'This cluster’s saved cost settings need review before cost data can load. Review them in Settings → Cost.'
87
122
  case 'deployment_configuration_error':
88
123
  return 'Cost collection is misconfigured by this Radar deployment. Update its environment variables or Helm cost values, then restart Radar.'
89
124
  case 'authentication_error':