@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.
- package/README.md +2 -0
- package/package.json +6 -5
- package/src/App.tsx +14 -11
- 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.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +247 -201
- 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 +223 -0
- package/src/api/radarFeatures.test.ts +142 -0
- package/src/api/radarFeatures.ts +148 -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/components/CloudFunnelButton.tsx +9 -9
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +5 -6
- 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/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/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/home/HomeView.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- 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/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/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +382 -521
- 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 +20 -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/Omnibar.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +57 -12
- package/src/components/workload/WorkloadView.tsx +44 -6
- 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
|
@@ -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
|
-
{/*
|
|
58
|
-
|
|
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>,
|
|
64
|
-
clusters
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 {
|
|
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
|
|
8
|
-
const html = renderToStaticMarkup(UpgradeReadinessError({ error: new
|
|
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(
|
|
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
|
|
254
|
-
|
|
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=
|
|
262
|
-
body={
|
|
263
|
-
|
|
264
|
-
This cluster’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
|
|
2293
|
+
it("maps an unsupported-feature error to the upgrade message, never 'Unknown error'", () => {
|
|
2293
2294
|
const html = renderToString(
|
|
2294
|
-
<>{integrationBlock(undefined, new
|
|
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.
|
|
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
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
|
|
1526
|
-
if (
|
|
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
|
|
1531
|
-
detail={
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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.
|
|
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
|
|
7
|
-
expect(costSourceLabel('kubecost')).toBe('Kubecost
|
|
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' ? '
|
|
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':
|