@skyhook-io/radar-app 1.12.3 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/api/client.ts +38 -6
- package/src/components/cost/ApplicationCostTab.test.ts +45 -0
- package/src/components/cost/ApplicationCostTab.tsx +115 -64
- package/src/components/cost/CostTrendChart.test.ts +65 -0
- package/src/components/cost/CostTrendChart.tsx +107 -17
- package/src/components/cost/CostView.test.ts +36 -1
- package/src/components/cost/CostView.tsx +133 -51
- package/src/components/cost/CurrentAllocationUse.tsx +8 -4
- package/src/components/cost/WorkloadCostTab.test.ts +50 -0
- package/src/components/cost/WorkloadCostTab.tsx +109 -61
- package/src/components/cost/source.test.ts +33 -0
- package/src/components/cost/source.ts +100 -0
- package/src/components/diagnose/parts.test.tsx +12 -4
- package/src/components/diagnose/parts.tsx +19 -15
- package/src/components/home/CostCard.tsx +3 -2
- package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
- package/src/components/rightsizing/copy.test.ts +19 -0
- package/src/components/settings/SettingsDialog.tsx +666 -103
- package/src/components/settings/settings-state.test.ts +42 -0
- package/src/components/settings/settings-state.ts +39 -0
- package/src/index.css +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"diff": "^9.0.0",
|
|
36
36
|
"monaco-editor": "0.55.1",
|
|
37
37
|
"react-markdown": "^10.1.0",
|
|
38
|
-
"react-virtuoso": "^4.18.
|
|
38
|
+
"react-virtuoso": "^4.18.12",
|
|
39
39
|
"remark-gfm": "^4.0.1",
|
|
40
40
|
"shiki": "^4.4.2",
|
|
41
41
|
"yaml": "^2.9.0"
|
|
@@ -69,8 +69,8 @@
|
|
|
69
69
|
"eslint": "^10.8.0",
|
|
70
70
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
71
71
|
"eslint-plugin-react-refresh": "^0.5.3",
|
|
72
|
-
"globals": "^17.
|
|
73
|
-
"lucide-react": "^1.
|
|
72
|
+
"globals": "^17.11.0",
|
|
73
|
+
"lucide-react": "^1.37.0",
|
|
74
74
|
"postcss": "^8.5.26",
|
|
75
75
|
"prettier": "^3.9.5",
|
|
76
76
|
"react": "^19.2.8",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"tailwindcss": "^4.3.3",
|
|
81
81
|
"typescript": "^6.0.2",
|
|
82
82
|
"typescript-eslint": "^8.67.0",
|
|
83
|
-
"vite": "^8.2.
|
|
83
|
+
"vite": "^8.2.2"
|
|
84
84
|
},
|
|
85
85
|
"sideEffects": [
|
|
86
86
|
"*.css"
|
package/src/App.tsx
CHANGED
|
@@ -2507,6 +2507,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2507
2507
|
open={showSettings}
|
|
2508
2508
|
initialSection={settingsSection}
|
|
2509
2509
|
onClose={() => setShowSettings(false)}
|
|
2510
|
+
onNavigateToResource={navigateToResourceList}
|
|
2510
2511
|
/>
|
|
2511
2512
|
|
|
2512
2513
|
{/* Debug overlay — dev mode, standalone only. Embedded hosts (Radar Hub)
|
package/src/api/client.ts
CHANGED
|
@@ -882,24 +882,36 @@ export interface OpenCostNamespaceCost {
|
|
|
882
882
|
|
|
883
883
|
export type CostUnavailableReason =
|
|
884
884
|
| "no_prometheus"
|
|
885
|
+
| "no_cost_source"
|
|
885
886
|
| "no_metrics"
|
|
886
887
|
| "query_error"
|
|
887
888
|
| "access_denied"
|
|
888
|
-
| "not_found"
|
|
889
|
+
| "not_found"
|
|
890
|
+
| "source_unavailable"
|
|
891
|
+
| "authentication_error"
|
|
892
|
+
| "configuration_mismatch"
|
|
893
|
+
| "deployment_configuration_error"
|
|
894
|
+
| "history_unsupported"
|
|
895
|
+
| "insufficient_history";
|
|
896
|
+
|
|
897
|
+
export type CostDataSource = "prometheus" | "kubecost";
|
|
889
898
|
|
|
890
899
|
export interface OpenCostSummary {
|
|
891
900
|
available: boolean;
|
|
892
901
|
reason?: CostUnavailableReason;
|
|
902
|
+
source?: CostDataSource;
|
|
903
|
+
dataThrough?: string;
|
|
893
904
|
currency?: string;
|
|
894
905
|
window?: string;
|
|
895
906
|
totalHourlyCost?: number;
|
|
896
907
|
totalStorageCost?: number;
|
|
897
908
|
totalIdleCost?: number;
|
|
898
909
|
clusterEfficiency?: number;
|
|
910
|
+
namespaceScope?: string[];
|
|
899
911
|
namespaces?: OpenCostNamespaceCost[];
|
|
900
912
|
}
|
|
901
913
|
|
|
902
|
-
const
|
|
914
|
+
const costDiscoveryFirstSeenAt = new Map<string, number>();
|
|
903
915
|
|
|
904
916
|
function costRefetchInterval(
|
|
905
917
|
defaultInterval: number | false = COST_REFRESH_INTERVAL_MS,
|
|
@@ -915,15 +927,18 @@ function costRefetchInterval(
|
|
|
915
927
|
}) => {
|
|
916
928
|
const data = query.state.data;
|
|
917
929
|
const queryID = `${contextName ?? "unknown"}:${query.queryHash ?? JSON.stringify(query.queryKey ?? "opencost")}`;
|
|
918
|
-
if (
|
|
930
|
+
if (
|
|
931
|
+
data?.available === false &&
|
|
932
|
+
(data.reason === "no_prometheus" || data.reason === "no_cost_source")
|
|
933
|
+
) {
|
|
919
934
|
const now = Date.now();
|
|
920
|
-
const firstSeenAt =
|
|
921
|
-
|
|
935
|
+
const firstSeenAt = costDiscoveryFirstSeenAt.get(queryID) ?? now;
|
|
936
|
+
costDiscoveryFirstSeenAt.set(queryID, firstSeenAt);
|
|
922
937
|
return now - firstSeenAt < COST_DISCOVERY_GRACE_MS
|
|
923
938
|
? COST_DISCOVERY_RETRY_INTERVAL_MS
|
|
924
939
|
: defaultInterval;
|
|
925
940
|
}
|
|
926
|
-
|
|
941
|
+
costDiscoveryFirstSeenAt.delete(queryID);
|
|
927
942
|
return defaultInterval;
|
|
928
943
|
};
|
|
929
944
|
}
|
|
@@ -963,6 +978,9 @@ export interface OpenCostWorkloadCost {
|
|
|
963
978
|
export interface OpenCostWorkloadResponse {
|
|
964
979
|
available: boolean;
|
|
965
980
|
reason?: CostUnavailableReason;
|
|
981
|
+
source?: CostDataSource;
|
|
982
|
+
window?: string;
|
|
983
|
+
dataThrough?: string;
|
|
966
984
|
currency?: string;
|
|
967
985
|
namespace: string;
|
|
968
986
|
workloads: OpenCostWorkloadCost[];
|
|
@@ -991,6 +1009,9 @@ export function useOpenCostWorkloads(
|
|
|
991
1009
|
export interface OpenCostWorkloadDetailResponse {
|
|
992
1010
|
available: boolean;
|
|
993
1011
|
reason?: CostUnavailableReason;
|
|
1012
|
+
source?: CostDataSource;
|
|
1013
|
+
window?: string;
|
|
1014
|
+
dataThrough?: string;
|
|
994
1015
|
currency?: string;
|
|
995
1016
|
namespace: string;
|
|
996
1017
|
kind: string;
|
|
@@ -1036,8 +1057,12 @@ export interface OpenCostTrendSeries {
|
|
|
1036
1057
|
export interface OpenCostTrendResponse {
|
|
1037
1058
|
available: boolean;
|
|
1038
1059
|
reason?: CostUnavailableReason;
|
|
1060
|
+
source?: CostDataSource;
|
|
1039
1061
|
currency?: string;
|
|
1040
1062
|
range: string;
|
|
1063
|
+
windowStart?: number;
|
|
1064
|
+
windowEnd?: number;
|
|
1065
|
+
dataThrough?: string;
|
|
1041
1066
|
series?: OpenCostTrendSeries[];
|
|
1042
1067
|
}
|
|
1043
1068
|
|
|
@@ -1058,6 +1083,7 @@ export function useOpenCostTrend(range_: CostTimeRange = "24h") {
|
|
|
1058
1083
|
export interface OpenCostWorkloadTrendResponse {
|
|
1059
1084
|
available: boolean;
|
|
1060
1085
|
reason?: CostUnavailableReason;
|
|
1086
|
+
source?: CostDataSource;
|
|
1061
1087
|
currency?: string;
|
|
1062
1088
|
namespace: string;
|
|
1063
1089
|
kind: string;
|
|
@@ -1131,6 +1157,9 @@ export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWork
|
|
|
1131
1157
|
export interface OpenCostApplicationCostResponse {
|
|
1132
1158
|
available: boolean;
|
|
1133
1159
|
reason?: CostUnavailableReason;
|
|
1160
|
+
source?: CostDataSource;
|
|
1161
|
+
window?: string;
|
|
1162
|
+
dataThrough?: string;
|
|
1134
1163
|
currency?: string;
|
|
1135
1164
|
partial?: boolean;
|
|
1136
1165
|
totals: OpenCostApplicationCostTotals;
|
|
@@ -1146,6 +1175,7 @@ export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationW
|
|
|
1146
1175
|
export interface OpenCostApplicationCostTrendResponse {
|
|
1147
1176
|
available: boolean;
|
|
1148
1177
|
reason?: CostUnavailableReason;
|
|
1178
|
+
source?: CostDataSource;
|
|
1149
1179
|
currency?: string;
|
|
1150
1180
|
range: string;
|
|
1151
1181
|
partial?: boolean;
|
|
@@ -1238,6 +1268,8 @@ export interface OpenCostNodeCost {
|
|
|
1238
1268
|
export interface OpenCostNodeResponse {
|
|
1239
1269
|
available: boolean;
|
|
1240
1270
|
reason?: CostUnavailableReason;
|
|
1271
|
+
source?: CostDataSource;
|
|
1272
|
+
dataThrough?: string;
|
|
1241
1273
|
currency?: string;
|
|
1242
1274
|
nodes?: OpenCostNodeCost[];
|
|
1243
1275
|
}
|
|
@@ -53,6 +53,48 @@ describe('getApplicationCostState', () => {
|
|
|
53
53
|
).toBe('partial_missing_history')
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
+
it('keeps Kubecost application totals visible when history is unsupported', () => {
|
|
57
|
+
const current: OpenCostApplicationCostResponse = {
|
|
58
|
+
available: true,
|
|
59
|
+
source: 'kubecost',
|
|
60
|
+
currency: 'USD',
|
|
61
|
+
totals: {
|
|
62
|
+
hourlyCost: 0.4, cpuCost: 0.25, memoryCost: 0.15, replicas: 3,
|
|
63
|
+
cpuUsageAvailable: true, memoryUsageAvailable: true,
|
|
64
|
+
cpuAllocationUse: 40, memoryAllocationUse: 60,
|
|
65
|
+
},
|
|
66
|
+
coverage: { total: 1, included: 1 },
|
|
67
|
+
workloads: [],
|
|
68
|
+
}
|
|
69
|
+
const trend: OpenCostApplicationCostTrendResponse = {
|
|
70
|
+
available: false,
|
|
71
|
+
source: 'kubecost',
|
|
72
|
+
reason: 'history_unsupported',
|
|
73
|
+
currency: 'USD',
|
|
74
|
+
range: '24h',
|
|
75
|
+
coverage: { total: 1, included: 0 },
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
expect(getApplicationCostState(current, trend, {})).toBe('partial_missing_history')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('does not let unsupported history mask current loading or errors', () => {
|
|
82
|
+
const trend: OpenCostApplicationCostTrendResponse = {
|
|
83
|
+
available: false,
|
|
84
|
+
source: 'kubecost',
|
|
85
|
+
reason: 'history_unsupported',
|
|
86
|
+
currency: 'USD',
|
|
87
|
+
range: '24h',
|
|
88
|
+
coverage: { total: 1, included: 0 },
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
expect(getApplicationCostState(undefined, trend, { currentLoading: true })).toBe('loading')
|
|
92
|
+
expect(getApplicationCostState(undefined, trend, { currentError: true })).toBe('load_error')
|
|
93
|
+
expect(
|
|
94
|
+
getApplicationCostState(undefined, trend, { currentError: new ApiError('denied', 403) }),
|
|
95
|
+
).toBe('access_denied')
|
|
96
|
+
})
|
|
97
|
+
|
|
56
98
|
it('uses historical data when current app metrics are absent but history exists', () => {
|
|
57
99
|
const current: OpenCostApplicationCostResponse = {
|
|
58
100
|
available: false,
|
|
@@ -206,5 +248,8 @@ describe('getApplicationCostState', () => {
|
|
|
206
248
|
}),
|
|
207
249
|
).toBe('access_denied')
|
|
208
250
|
expect(getApplicationCostState(current, undefined, { trendError: true })).toBe('access_denied')
|
|
251
|
+
|
|
252
|
+
current.reason = 'configuration_mismatch'
|
|
253
|
+
expect(getApplicationCostState(current, undefined, {})).toBe('configuration_mismatch')
|
|
209
254
|
})
|
|
210
255
|
})
|
|
@@ -25,6 +25,14 @@ import {
|
|
|
25
25
|
import { isOpenCostWorkloadKind } from './kinds'
|
|
26
26
|
import { CurrentAllocationUse } from './CurrentAllocationUse'
|
|
27
27
|
import { costUnavailableReasonFromError } from './errors'
|
|
28
|
+
import {
|
|
29
|
+
costFreshnessLabel,
|
|
30
|
+
costIntegrationUnavailableMessage,
|
|
31
|
+
costRateLabels,
|
|
32
|
+
costSourceLabel,
|
|
33
|
+
isCostDiscoveryPending,
|
|
34
|
+
} from './source'
|
|
35
|
+
import { useNavCustomization } from '../../context/NavCustomization'
|
|
28
36
|
|
|
29
37
|
type ApplicationCostState =
|
|
30
38
|
| 'loading'
|
|
@@ -53,8 +61,9 @@ export function ApplicationCostTab({
|
|
|
53
61
|
workloads,
|
|
54
62
|
onSelectWorkloadCost,
|
|
55
63
|
}: ApplicationCostTabProps) {
|
|
64
|
+
const settingsAvailable = !useNavCustomization().embedded
|
|
56
65
|
const [range, setRange] = useState<CostTimeRange>('24h')
|
|
57
|
-
const [
|
|
66
|
+
const [discoverySince, setDiscoverySince] = useState<number | null>(null)
|
|
58
67
|
const supportedWorkloads = useMemo(() => applicationCostWorkloads(workloads), [workloads])
|
|
59
68
|
const unsupportedCount = workloads.length - supportedWorkloads.length
|
|
60
69
|
const queriesEnabled = supportedWorkloads.length > 0
|
|
@@ -77,10 +86,10 @@ export function ApplicationCostTab({
|
|
|
77
86
|
})
|
|
78
87
|
|
|
79
88
|
useEffect(() => {
|
|
80
|
-
if (state
|
|
81
|
-
|
|
89
|
+
if (isCostDiscoveryPending(state)) {
|
|
90
|
+
setDiscoverySince((prev) => prev ?? Date.now())
|
|
82
91
|
} else {
|
|
83
|
-
|
|
92
|
+
setDiscoverySince(null)
|
|
84
93
|
}
|
|
85
94
|
}, [state])
|
|
86
95
|
|
|
@@ -96,6 +105,7 @@ export function ApplicationCostTab({
|
|
|
96
105
|
<ApplicationCostUnavailable
|
|
97
106
|
state="no_metrics"
|
|
98
107
|
message="No steady-state workloads in this app are currently cost-attributed."
|
|
108
|
+
settingsAvailable={settingsAvailable}
|
|
99
109
|
/>
|
|
100
110
|
)
|
|
101
111
|
}
|
|
@@ -115,22 +125,28 @@ export function ApplicationCostTab({
|
|
|
115
125
|
state === 'query_error' ||
|
|
116
126
|
state === 'access_denied' ||
|
|
117
127
|
state === 'not_found' ||
|
|
128
|
+
state === 'no_cost_source' ||
|
|
129
|
+
state === 'source_unavailable' ||
|
|
130
|
+
state === 'authentication_error' ||
|
|
131
|
+
state === 'configuration_mismatch' ||
|
|
132
|
+
state === 'deployment_configuration_error' ||
|
|
133
|
+
state === 'history_unsupported' ||
|
|
118
134
|
state === 'load_error'
|
|
119
135
|
) {
|
|
120
|
-
const discoveryAgeMs =
|
|
121
|
-
if (state
|
|
136
|
+
const discoveryAgeMs = discoverySince == null ? 0 : Date.now() - discoverySince
|
|
137
|
+
if (isCostDiscoveryPending(state) && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) {
|
|
122
138
|
return (
|
|
123
139
|
<ApplicationCostDiscovering
|
|
124
140
|
isFetching={currentQuery.isFetching || trendQuery.isFetching}
|
|
125
141
|
onRetry={() => {
|
|
126
|
-
|
|
142
|
+
setDiscoverySince(Date.now())
|
|
127
143
|
currentQuery.refetch()
|
|
128
144
|
trendQuery.refetch()
|
|
129
145
|
}}
|
|
130
146
|
/>
|
|
131
147
|
)
|
|
132
148
|
}
|
|
133
|
-
return <ApplicationCostUnavailable state={state} />
|
|
149
|
+
return <ApplicationCostUnavailable state={state} settingsAvailable={settingsAvailable} />
|
|
134
150
|
}
|
|
135
151
|
|
|
136
152
|
const current = currentQuery.data
|
|
@@ -151,12 +167,16 @@ export function ApplicationCostTab({
|
|
|
151
167
|
const maxCost = Math.max(...rows.map((row) => row.current?.hourlyCost ?? 0), 0)
|
|
152
168
|
const currentCurrency = current?.currency ?? trend?.currency ?? DEFAULT_COST_CURRENCY
|
|
153
169
|
const trendCurrency = trend?.currency ?? current?.currency ?? DEFAULT_COST_CURRENCY
|
|
170
|
+
const source = current?.source ?? trend?.source
|
|
171
|
+
const historyUnsupported = trend?.reason === 'history_unsupported'
|
|
172
|
+
const currentWindow = current?.window
|
|
173
|
+
const rateLabels = costRateLabels(currentWindow)
|
|
154
174
|
|
|
155
175
|
return (
|
|
156
176
|
<div className="mx-auto w-full max-w-[1600px] space-y-4">
|
|
157
177
|
{(current?.partial ||
|
|
158
178
|
trend?.partial ||
|
|
159
|
-
state === 'partial_missing_history' ||
|
|
179
|
+
(state === 'partial_missing_history' && !historyUnsupported) ||
|
|
160
180
|
state === 'partial_missing_current' ||
|
|
161
181
|
unsupportedCount > 0) && (
|
|
162
182
|
<div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-sm text-theme-text-secondary">
|
|
@@ -185,61 +205,76 @@ export function ApplicationCostTab({
|
|
|
185
205
|
<div className="text-sm font-semibold text-theme-text-primary">
|
|
186
206
|
Application compute cost
|
|
187
207
|
</div>
|
|
188
|
-
<CostInfoTooltip content="Values are based on
|
|
208
|
+
<CostInfoTooltip content="Values are based on CPU and memory allocation grouped by the workloads in this application. The cost provider attributes the greater of requested or observed resources." />
|
|
189
209
|
</div>
|
|
190
210
|
<div className="text-xs text-theme-text-tertiary">
|
|
191
|
-
|
|
192
|
-
|
|
211
|
+
CPU and memory allocation rate ({trendCurrency}/hr) for Deployment, StatefulSet,
|
|
212
|
+
and DaemonSet workloads
|
|
193
213
|
</div>
|
|
194
214
|
</div>
|
|
195
215
|
</div>
|
|
196
|
-
<CostTimeRangeSelector value={range} onChange={setRange} />
|
|
216
|
+
{!historyUnsupported && <CostTimeRangeSelector value={range} onChange={setRange} />}
|
|
197
217
|
</div>
|
|
198
218
|
|
|
199
|
-
|
|
200
|
-
<div className="
|
|
219
|
+
{historyUnsupported ? (
|
|
220
|
+
<div className="grid gap-4 p-4 sm:grid-cols-2">
|
|
201
221
|
<CostMetricBlock
|
|
202
|
-
label={
|
|
203
|
-
value={
|
|
204
|
-
|
|
205
|
-
trend?.windowTotalCost ?? 0,
|
|
206
|
-
trendLoading || state === 'partial_missing_history',
|
|
207
|
-
trendCurrency,
|
|
208
|
-
)}
|
|
209
|
-
subvalue={
|
|
210
|
-
state === 'partial_missing_history'
|
|
211
|
-
? 'Historical data incomplete'
|
|
212
|
-
: `${included} of ${total} workloads included`
|
|
213
|
-
}
|
|
222
|
+
label={rateLabels.hourly}
|
|
223
|
+
value={totals ? formatCostPerHour(hourly, currentCurrency) : '—'}
|
|
224
|
+
subvalue={`${included} of ${total} workloads included`}
|
|
214
225
|
/>
|
|
215
226
|
<CostMetricBlock
|
|
216
227
|
label="Projected monthly"
|
|
217
228
|
value={totals ? formatProjectedMonthlyCost(hourly, currentCurrency) : '—'}
|
|
218
|
-
subvalue={
|
|
219
|
-
totals
|
|
220
|
-
? `${formatCostPerHour(hourly, currentCurrency)} current rate`
|
|
221
|
-
: 'Current allocation unavailable'
|
|
222
|
-
}
|
|
229
|
+
subvalue={`${rateLabels.rate} × 730 hours`}
|
|
223
230
|
/>
|
|
224
231
|
</div>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
232
|
+
) : (
|
|
233
|
+
<div className="grid gap-4 p-4 lg:grid-cols-[240px_minmax(0,1fr)]">
|
|
234
|
+
<div className="space-y-4">
|
|
235
|
+
<CostMetricBlock
|
|
236
|
+
label={`Spend over ${range}`}
|
|
237
|
+
value={formatHistoricalSpend(
|
|
238
|
+
points.length,
|
|
239
|
+
trend?.windowTotalCost ?? 0,
|
|
240
|
+
trendLoading || state === 'partial_missing_history',
|
|
241
|
+
trendCurrency,
|
|
242
|
+
)}
|
|
243
|
+
subvalue={
|
|
244
|
+
state === 'partial_missing_history'
|
|
245
|
+
? 'Historical data incomplete'
|
|
246
|
+
: `${included} of ${total} workloads included`
|
|
247
|
+
}
|
|
248
|
+
/>
|
|
249
|
+
<CostMetricBlock
|
|
250
|
+
label="Projected monthly"
|
|
251
|
+
value={totals ? formatProjectedMonthlyCost(hourly, currentCurrency) : '—'}
|
|
252
|
+
subvalue={
|
|
253
|
+
totals
|
|
254
|
+
? `${formatCostPerHour(hourly, currentCurrency)} · ${rateLabels.rate}`
|
|
255
|
+
: 'Current allocation unavailable'
|
|
256
|
+
}
|
|
257
|
+
/>
|
|
258
|
+
</div>
|
|
259
|
+
<div className="min-w-0">
|
|
260
|
+
{trendLoading ? (
|
|
261
|
+
<div className="flex h-[240px] items-center justify-center rounded-md border border-dashed border-theme-border bg-theme-base/60 text-sm text-theme-text-tertiary">
|
|
262
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
263
|
+
Loading historical cost…
|
|
264
|
+
</div>
|
|
265
|
+
) : hasTrend && chartSeries.length > 0 ? (
|
|
266
|
+
<div className="min-w-0">
|
|
267
|
+
<StackedAreaChart series={chartSeries} currency={trendCurrency} />
|
|
268
|
+
<ChartLegend series={chartSeries} />
|
|
269
|
+
</div>
|
|
270
|
+
) : (
|
|
271
|
+
<div className="flex h-[240px] items-center justify-center rounded-md border border-dashed border-theme-border bg-theme-base/60 text-sm text-theme-text-tertiary">
|
|
272
|
+
No historical workload owner cost points for this range.
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
241
276
|
</div>
|
|
242
|
-
|
|
277
|
+
)}
|
|
243
278
|
</section>
|
|
244
279
|
|
|
245
280
|
<div className="grid gap-4 md:grid-cols-2">
|
|
@@ -257,7 +292,7 @@ export function ApplicationCostTab({
|
|
|
257
292
|
value={totals ? formatProjectedDailyRate(hourly, currentCurrency) : '—'}
|
|
258
293
|
subvalue={
|
|
259
294
|
totals
|
|
260
|
-
? `${formatCostPerHour(hourly, currentCurrency)}
|
|
295
|
+
? `${formatCostPerHour(hourly, currentCurrency)} · ${rateLabels.rate}`
|
|
261
296
|
: 'Current allocation unavailable'
|
|
262
297
|
}
|
|
263
298
|
/>
|
|
@@ -274,6 +309,7 @@ export function ApplicationCostTab({
|
|
|
274
309
|
cpuUsageAvailable={totals?.cpuUsageAvailable ?? false}
|
|
275
310
|
memoryUsageAvailable={totals?.memoryUsageAvailable ?? false}
|
|
276
311
|
scopeNote="Included workloads only"
|
|
312
|
+
window={source === 'kubecost' ? currentWindow : undefined}
|
|
277
313
|
/>
|
|
278
314
|
|
|
279
315
|
<section className="rounded-lg border border-theme-border bg-theme-surface/50">
|
|
@@ -283,7 +319,7 @@ export function ApplicationCostTab({
|
|
|
283
319
|
Workload contributors
|
|
284
320
|
</div>
|
|
285
321
|
<div className="text-xs text-theme-text-tertiary">
|
|
286
|
-
Projected monthly from
|
|
322
|
+
Projected monthly from the {rateLabels.rate}, sorted by spend
|
|
287
323
|
</div>
|
|
288
324
|
</div>
|
|
289
325
|
<div className="text-xs text-theme-text-tertiary">{rows.length} tracked</div>
|
|
@@ -315,13 +351,15 @@ export function ApplicationCostTab({
|
|
|
315
351
|
</section>
|
|
316
352
|
|
|
317
353
|
<div className="text-xs text-theme-text-tertiary">
|
|
318
|
-
|
|
354
|
+
{costSourceLabel(source)} · {costFreshnessLabel(source, source === 'kubecost' ? currentWindow : '1h', current?.dataThrough)}.{' '}
|
|
319
355
|
{currentCurrency !== DEFAULT_COST_CURRENCY && (
|
|
320
356
|
<>Labeled {currentCurrency}; no conversion. </>
|
|
321
357
|
)}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
358
|
+
{historyUnsupported
|
|
359
|
+
? 'Historical application charts are not available for Kubecost yet. '
|
|
360
|
+
: 'Historical spend uses the selected range. '}
|
|
361
|
+
Projected monthly values multiply the {rateLabels.rate}. Batch/job cost is separate;
|
|
362
|
+
storage/PVC and network costs remain at namespace and cluster level.
|
|
325
363
|
</div>
|
|
326
364
|
</div>
|
|
327
365
|
)
|
|
@@ -348,14 +386,19 @@ export function getApplicationCostState(
|
|
|
348
386
|
if (trendHasData) return 'partial_missing_current'
|
|
349
387
|
const reason =
|
|
350
388
|
current?.reason ??
|
|
351
|
-
trend?.reason ??
|
|
352
389
|
costUnavailableReasonFromError(status.currentError) ??
|
|
390
|
+
trend?.reason ??
|
|
353
391
|
costUnavailableReasonFromError(status.trendError)
|
|
354
392
|
if (
|
|
355
393
|
reason === 'no_prometheus' ||
|
|
394
|
+
reason === 'no_cost_source' ||
|
|
356
395
|
reason === 'query_error' ||
|
|
357
396
|
reason === 'access_denied' ||
|
|
358
|
-
reason === 'not_found'
|
|
397
|
+
reason === 'not_found' ||
|
|
398
|
+
reason === 'source_unavailable' ||
|
|
399
|
+
reason === 'authentication_error' ||
|
|
400
|
+
reason === 'configuration_mismatch' ||
|
|
401
|
+
reason === 'deployment_configuration_error'
|
|
359
402
|
)
|
|
360
403
|
return reason
|
|
361
404
|
if (queryError) return 'load_error'
|
|
@@ -462,10 +505,13 @@ function applicationCostKey(ref: { kind: string; namespace: string; name: string
|
|
|
462
505
|
}
|
|
463
506
|
|
|
464
507
|
function reasonLabel(reason?: CostUnavailableReason) {
|
|
465
|
-
if (reason === 'no_prometheus') return '
|
|
508
|
+
if (reason === 'no_prometheus') return 'Metrics backend not found'
|
|
509
|
+
if (reason === 'no_cost_source') return 'Cost source not found'
|
|
466
510
|
if (reason === 'query_error') return 'Cost query failed'
|
|
467
511
|
if (reason === 'access_denied') return 'No access to this workload'
|
|
468
512
|
if (reason === 'not_found') return 'Workload not found'
|
|
513
|
+
if (reason === 'configuration_mismatch') return 'Kubecost settings are not valid for this cluster'
|
|
514
|
+
if (reason === 'deployment_configuration_error') return 'Radar cost deployment is misconfigured'
|
|
469
515
|
return 'No workload cost metrics'
|
|
470
516
|
}
|
|
471
517
|
|
|
@@ -482,11 +528,11 @@ function ApplicationCostDiscovering({
|
|
|
482
528
|
<Loader2 className="h-8 w-8 animate-spin text-theme-text-tertiary/60" />
|
|
483
529
|
<div>
|
|
484
530
|
<p className="text-sm font-medium text-theme-text-primary">
|
|
485
|
-
Looking for
|
|
531
|
+
Looking for cost data…
|
|
486
532
|
</p>
|
|
487
533
|
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
488
|
-
|
|
489
|
-
|
|
534
|
+
Radar is checking OpenCost metrics in a PromQL-compatible backend and a local Kubecost
|
|
535
|
+
3 Aggregator. First discovery can take a few seconds.
|
|
490
536
|
</p>
|
|
491
537
|
</div>
|
|
492
538
|
<button
|
|
@@ -504,23 +550,28 @@ function ApplicationCostDiscovering({
|
|
|
504
550
|
function ApplicationCostUnavailable({
|
|
505
551
|
state,
|
|
506
552
|
message,
|
|
553
|
+
settingsAvailable,
|
|
507
554
|
}: {
|
|
508
555
|
state: CostUnavailableReason | 'load_error'
|
|
509
556
|
message?: string
|
|
557
|
+
settingsAvailable: boolean
|
|
510
558
|
}) {
|
|
511
559
|
const text =
|
|
512
560
|
message ??
|
|
561
|
+
costIntegrationUnavailableMessage(state, settingsAvailable) ??
|
|
513
562
|
(state === 'no_prometheus'
|
|
514
|
-
? '
|
|
563
|
+
? 'No compatible metrics backend was found. OpenCost application cost requires OpenCost metrics in a PromQL-compatible backend.'
|
|
515
564
|
: state === 'query_error'
|
|
516
|
-
? 'Cost data is temporarily unavailable.
|
|
565
|
+
? 'Cost data is temporarily unavailable. A metrics backend was found, but application cost queries failed.'
|
|
566
|
+
: state === 'history_unsupported'
|
|
567
|
+
? 'Historical application cost is not available for Kubecost yet.'
|
|
517
568
|
: state === 'access_denied'
|
|
518
569
|
? 'Cost data is unavailable because these workloads are not accessible with your current permissions.'
|
|
519
570
|
: state === 'not_found'
|
|
520
571
|
? 'Cost data is unavailable because the referenced workloads no longer exist.'
|
|
521
572
|
: state === 'load_error'
|
|
522
573
|
? 'Could not load application cost data. Check access to these workloads and try again.'
|
|
523
|
-
: '
|
|
574
|
+
: 'No workload cost data was returned for this application by the active cost source.')
|
|
524
575
|
return (
|
|
525
576
|
<div className="flex h-full min-h-[320px] items-center justify-center">
|
|
526
577
|
<div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { OpenCostTrendSeries } from '../../api/client'
|
|
3
|
+
import { kubecostRetentionNote, kubecostTrendIsStale, kubecostTrendUnavailableMessage } from './CostTrendChart'
|
|
4
|
+
|
|
5
|
+
const hour = 60 * 60
|
|
6
|
+
|
|
7
|
+
function series(...timestamps: number[]): OpenCostTrendSeries[] {
|
|
8
|
+
return [{
|
|
9
|
+
namespace: 'default',
|
|
10
|
+
dataPoints: timestamps.map((timestamp) => ({ timestamp, value: 1 })),
|
|
11
|
+
}]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('kubecostRetentionNote', () => {
|
|
15
|
+
it('describes a material retention gap', () => {
|
|
16
|
+
const start = 1_000
|
|
17
|
+
expect(kubecostRetentionNote(series(start + 4 * hour, start + 5 * hour), start, start + 7 * 24 * hour))
|
|
18
|
+
.toContain('Kubecost history is available from')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('stays quiet when the first hourly bucket follows the requested start', () => {
|
|
22
|
+
const start = 1_000
|
|
23
|
+
expect(kubecostRetentionNote(series(start + hour, start + 2 * hour), start, start + 24 * hour)).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('stays quiet when there is only one retained sample', () => {
|
|
27
|
+
const start = 1_000
|
|
28
|
+
expect(kubecostRetentionNote(series(start + 4 * hour), start, start + 24 * hour)).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('allows the normal first daily bucket without calling retention partial', () => {
|
|
32
|
+
const start = 1_000
|
|
33
|
+
expect(kubecostRetentionNote(series(start + 24 * hour, start + 48 * hour), start, start + 7 * 24 * hour)).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('kubecostTrendIsStale', () => {
|
|
38
|
+
it('allows normal hourly ingestion lag', () => {
|
|
39
|
+
const now = Date.parse('2026-08-30T12:00:00Z')
|
|
40
|
+
expect(kubecostTrendIsStale('2026-08-30T10:00:00Z', '6h', now)).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('flags a source beyond the range lag budget', () => {
|
|
44
|
+
const now = Date.parse('2026-08-30T12:00:00Z')
|
|
45
|
+
expect(kubecostTrendIsStale('2026-08-30T02:00:00Z', '24h', now)).toBe(true)
|
|
46
|
+
expect(kubecostTrendIsStale('2026-08-28T02:00:00Z', '7d', now)).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('stays quiet without a valid retained timestamp', () => {
|
|
50
|
+
expect(kubecostTrendIsStale(undefined, '6h')).toBe(false)
|
|
51
|
+
expect(kubecostTrendIsStale('invalid', '6h')).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('kubecostTrendUnavailableMessage', () => {
|
|
56
|
+
it('distinguishes an empty namespace scope from missing Kubecost history', () => {
|
|
57
|
+
expect(kubecostTrendUnavailableMessage('no_metrics', true)).toContain('current namespace scope')
|
|
58
|
+
expect(kubecostTrendUnavailableMessage('no_metrics', false)).toContain('Kubecost has no retained')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('distinguishes insufficient scoped history', () => {
|
|
62
|
+
expect(kubecostTrendUnavailableMessage('insufficient_history', true)).toContain('namespace scope')
|
|
63
|
+
expect(kubecostTrendUnavailableMessage('insufficient_history', false)).toContain('two retained samples')
|
|
64
|
+
})
|
|
65
|
+
})
|