@skyhook-io/radar-app 1.12.3 → 1.13.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 (38) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +37 -8
  3. package/src/RadarApp.tsx +1 -1
  4. package/src/api/client.ts +38 -6
  5. package/src/api/diagnose.ts +45 -5
  6. package/src/components/cost/ApplicationCostTab.test.ts +45 -0
  7. package/src/components/cost/ApplicationCostTab.tsx +115 -64
  8. package/src/components/cost/CostTrendChart.test.ts +65 -0
  9. package/src/components/cost/CostTrendChart.tsx +107 -17
  10. package/src/components/cost/CostView.test.ts +36 -1
  11. package/src/components/cost/CostView.tsx +133 -51
  12. package/src/components/cost/CurrentAllocationUse.tsx +8 -4
  13. package/src/components/cost/WorkloadCostTab.test.ts +50 -0
  14. package/src/components/cost/WorkloadCostTab.tsx +109 -61
  15. package/src/components/cost/source.test.ts +33 -0
  16. package/src/components/cost/source.ts +100 -0
  17. package/src/components/diagnose/AISettings.tsx +9 -4
  18. package/src/components/diagnose/DiagnoseContext.tsx +216 -48
  19. package/src/components/diagnose/DiagnoseSurface.test.tsx +118 -5
  20. package/src/components/diagnose/DiagnoseSurface.tsx +190 -32
  21. package/src/components/diagnose/Home.tsx +93 -64
  22. package/src/components/diagnose/InvestigationView.tsx +190 -124
  23. package/src/components/diagnose/parts.test.tsx +12 -4
  24. package/src/components/diagnose/parts.tsx +29 -17
  25. package/src/components/execution/BatchExecutionView.render.test.tsx +35 -0
  26. package/src/components/execution/BatchExecutionView.tsx +2 -3
  27. package/src/components/execution/execution-definition.test.ts +19 -0
  28. package/src/components/execution/execution-definition.ts +2 -0
  29. package/src/components/gitops/GitOpsView.tsx +25 -3
  30. package/src/components/home/CostCard.tsx +3 -2
  31. package/src/components/nav/navigation.test.ts +26 -0
  32. package/src/components/nav/navigation.ts +10 -0
  33. package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
  34. package/src/components/rightsizing/copy.test.ts +19 -0
  35. package/src/components/settings/SettingsDialog.tsx +666 -103
  36. package/src/components/settings/settings-state.test.ts +42 -0
  37. package/src/components/settings/settings-state.ts +39 -0
  38. 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.12.3",
3
+ "version": "1.13.1",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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.11",
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.9.0",
73
- "lucide-react": "^1.33.0",
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.1"
83
+ "vite": "^8.2.2"
84
84
  },
85
85
  "sideEffects": [
86
86
  "*.css"
package/src/App.tsx CHANGED
@@ -37,6 +37,7 @@ import { CloudFunnelButton } from './components/CloudFunnelButton'
37
37
  import { useNavCustomization } from './context/NavCustomization'
38
38
  import type { FleetTakeoverTarget } from './context/NavCustomization'
39
39
  import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
40
+ import { navigateFromPrimaryRail } from './components/nav/navigation'
40
41
  import { useNavRailPinned } from './hooks/useNavRailPinned'
41
42
  import { useMediaQuery } from './hooks/useMediaQuery'
42
43
  import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
@@ -322,7 +323,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
322
323
  // The AI panel is an absolute slot in the body frame (the column under the header):
323
324
  // it reserves a right gutter on the CONTENT only, so the navbar + nav rail stay
324
325
  // static. contentGutter is the docked panel width (0 when closed/overlay/maximized).
325
- const { open: diagnoseOpen, contentGutter } = useDiagnoseLayout()
326
+ const {
327
+ open: diagnoseOpen,
328
+ close: closeDiagnose,
329
+ contentGutter,
330
+ maximized: diagnoseMaximized,
331
+ } = useDiagnoseLayout()
326
332
  // Hand off to a host-owned URL. The host's `onHostNavigate` (Radar Cloud's
327
333
  // cross-tree swap) navigates same-document so the chrome morphs instead of
328
334
  // cold-booting; without it we fall back to a hard `window.location` nav.
@@ -468,12 +474,19 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
468
474
 
469
475
  const path = view === 'home' ? '/' : `/${view}`
470
476
 
471
- // Start fresh — keep only cross-view params (namespaces), discard all view-specific ones
477
+ // Start fresh — keep only cross-view params, discard view-specific ones.
478
+ // Read the live location because Diagnose owns `ai-run` through the History
479
+ // API; React Router's searchParams snapshot does not update for that write.
472
480
  const newParams = new URLSearchParams()
473
- const globalNamespaces = searchParams.get('namespaces')
481
+ const currentParams = new URLSearchParams(window.location.search)
482
+ const globalNamespaces = currentParams.get('namespaces')
474
483
  if (globalNamespaces) {
475
484
  newParams.set('namespaces', globalNamespaces)
476
485
  }
486
+ const diagnoseRun = currentParams.get('ai-run')
487
+ if (diagnoseRun) {
488
+ newParams.set('ai-run', diagnoseRun)
489
+ }
477
490
 
478
491
  // Add any new params
479
492
  if (params) {
@@ -483,7 +496,18 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
483
496
  }
484
497
 
485
498
  navigate({ pathname: path, search: newParams.toString() })
486
- }, [navigate, searchParams, takeover, goHost])
499
+ }, [navigate, takeover, goHost])
500
+
501
+ // The standalone rail expresses intent to leave the full-width investigation
502
+ // workspace. Close it before routing so the destination is immediately visible;
503
+ // docked investigations stay open across views as a persistent side panel.
504
+ const handlePrimaryNavigate = useCallback((view: ExtendedMainView) => {
505
+ navigateFromPrimaryRail(
506
+ diagnoseOpen && diagnoseMaximized,
507
+ closeDiagnose,
508
+ () => setMainView(view),
509
+ )
510
+ }, [diagnoseOpen, diagnoseMaximized, closeDiagnose, setMainView])
487
511
 
488
512
  // Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
489
513
  // own fleet pages scoped to this cluster — owned by the host's left rail — so
@@ -1135,9 +1159,13 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1135
1159
  setSelectedResource(null)
1136
1160
  setSelectedHelmRelease(null)
1137
1161
 
1138
- // Reset URL to current view with no resource-specific params.
1139
- // Old cluster's selected pod/resource/kind don't exist on the new cluster.
1140
- navigate({ pathname: location.pathname, search: '' }, { replace: true })
1162
+ // Reset resource-specific params while retaining the durable investigation
1163
+ // focus. Diagnose resolves runs by id and owns whether the focused run is
1164
+ // still readable after the context switch.
1165
+ const nextParams = new URLSearchParams()
1166
+ const diagnoseRun = new URLSearchParams(window.location.search).get('ai-run')
1167
+ if (diagnoseRun) nextParams.set('ai-run', diagnoseRun)
1168
+ navigate({ pathname: location.pathname, search: nextParams.toString() }, { replace: true })
1141
1169
 
1142
1170
  // Auto-unpause so the new cluster's topology loads immediately
1143
1171
  setTopologyPaused(false)
@@ -1658,7 +1686,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1658
1686
  {showNavRail && (
1659
1687
  <PrimaryNavRail
1660
1688
  activeView={navActiveView}
1661
- onNavigate={setMainView}
1689
+ onNavigate={handlePrimaryNavigate}
1662
1690
  pinned={navRailEffectivePinned}
1663
1691
  onTogglePinned={toggleNavRailPinned}
1664
1692
  showPinToggle={!railForcedSlim}
@@ -2507,6 +2535,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2507
2535
  open={showSettings}
2508
2536
  initialSection={settingsSection}
2509
2537
  onClose={() => setShowSettings(false)}
2538
+ onNavigateToResource={navigateToResourceList}
2510
2539
  />
2511
2540
 
2512
2541
  {/* Debug overlay — dev mode, standalone only. Embedded hosts (Radar Hub)
package/src/RadarApp.tsx CHANGED
@@ -228,7 +228,7 @@ export function RadarApp({
228
228
  value={renderDiagnoseAction ?? defaultDiagnoseAction}
229
229
  consentCopy={diagnoseConsent}
230
230
  >
231
- <DiagnoseProvider>
231
+ <DiagnoseProvider browserURLState={router !== "memory"}>
232
232
  <App
233
233
  manageDocumentTitle={manageDocumentTitle}
234
234
  documentTitleSuffix={documentTitleSuffix}
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 noPrometheusFirstSeenAt = new Map<string, number>();
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 (data?.available === false && data.reason === "no_prometheus") {
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 = noPrometheusFirstSeenAt.get(queryID) ?? now;
921
- noPrometheusFirstSeenAt.set(queryID, firstSeenAt);
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
- noPrometheusFirstSeenAt.delete(queryID);
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
  }
@@ -1,7 +1,7 @@
1
1
  // Client for the local AI-diagnose engine (OSS BYO-agent). The agent CLI runs
2
2
  // on the user's own machine/subscription against Radar's MCP; this just starts
3
3
  // the investigation and consumes its SSE event stream.
4
- import { getApiBase, getCredentialsMode } from "./config";
4
+ import { getApiBase, getAuthHeaders, getCredentialsMode } from "./config";
5
5
 
6
6
  export interface AgentInfo {
7
7
  name: string;
@@ -81,6 +81,7 @@ export interface DiagnoseStreamEvent {
81
81
  error?: string;
82
82
  question?: string; // on "turn"
83
83
  apply?: boolean; // on "turn"
84
+ actor?: string; // human author on shared hosted transcripts
84
85
  }
85
86
 
86
87
  // A run is a durable, server-owned investigation. Its lifetime is independent of
@@ -101,11 +102,17 @@ export interface RunSummary {
101
102
  effort?: string;
102
103
  managedBy?: string; // GitOps/Helm owner of the target ("Argo CD"/"Flux"/"Helm"), for the Apply warning
103
104
  health?: ResourceHealthSignal;
104
- status: "running" | "done" | "error" | "stopped" | "stale";
105
+ status: "running" | "stopping" | "done" | "error" | "stopped" | "stale";
105
106
  sessionId?: string;
106
107
  preview?: string;
107
108
  createdAt: string;
108
109
  updatedAt: string;
110
+ visibility?: "private" | "organization";
111
+ ownedByMe?: boolean;
112
+ canManageVisibility?: boolean;
113
+ canContinue?: boolean;
114
+ trigger?: "interactive" | "background";
115
+ radarUrl?: string;
109
116
  }
110
117
 
111
118
  export async function fetchAgents(
@@ -113,6 +120,7 @@ export async function fetchAgents(
113
120
  ): Promise<AgentsResponse> {
114
121
  const res = await fetch(`${getApiBase()}/agents`, {
115
122
  credentials: getCredentialsMode(),
123
+ headers: getAuthHeaders(),
116
124
  signal,
117
125
  });
118
126
  if (!res.ok) throw new Error(`agents: ${res.status}`);
@@ -167,7 +175,7 @@ export async function createRun(
167
175
  const res = await fetch(RUNS(), {
168
176
  method: "POST",
169
177
  credentials: getCredentialsMode(),
170
- headers: { "Content-Type": "application/json" },
178
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
171
179
  body: JSON.stringify({ ...target, ...opts }),
172
180
  });
173
181
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
@@ -186,6 +194,7 @@ export interface RunsResponse {
186
194
  export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
187
195
  const res = await fetch(RUNS(), {
188
196
  credentials: getCredentialsMode(),
197
+ headers: getAuthHeaders(),
189
198
  signal,
190
199
  });
191
200
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
@@ -193,12 +202,41 @@ export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
193
202
  return { runs: d.runs ?? [], historyDegraded: !!d.historyDegraded };
194
203
  }
195
204
 
205
+ // getRun resolves a stable run id directly. Deep links use this rather than
206
+ // relying on a bounded history page to happen to contain the target.
207
+ export async function getRun(
208
+ id: string,
209
+ signal?: AbortSignal,
210
+ ): Promise<RunSummary> {
211
+ const res = await fetch(`${RUNS()}/${encodeURIComponent(id)}`, {
212
+ credentials: getCredentialsMode(),
213
+ headers: getAuthHeaders(),
214
+ signal,
215
+ });
216
+ if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
217
+ return res.json();
218
+ }
219
+
220
+ export async function updateRunVisibility(
221
+ id: string,
222
+ visibility: "private" | "organization",
223
+ ): Promise<RunSummary> {
224
+ const res = await fetch(`${RUNS()}/${encodeURIComponent(id)}`, {
225
+ method: "PATCH",
226
+ credentials: getCredentialsMode(),
227
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
228
+ body: JSON.stringify({ visibility }),
229
+ });
230
+ if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
231
+ return res.json();
232
+ }
233
+
196
234
  // recordConsent acknowledges the current disclosure for an execution profile, server-side.
197
235
  export async function recordConsent(surface: string): Promise<void> {
198
236
  const res = await fetch(`${getApiBase()}/diagnose/consent`, {
199
237
  method: "POST",
200
238
  credentials: getCredentialsMode(),
201
- headers: { "Content-Type": "application/json" },
239
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
202
240
  body: JSON.stringify({ surface }),
203
241
  });
204
242
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
@@ -210,6 +248,7 @@ export async function clearHistory(): Promise<void> {
210
248
  const res = await fetch(`${getApiBase()}/diagnose/history/clear`, {
211
249
  method: "POST",
212
250
  credentials: getCredentialsMode(),
251
+ headers: getAuthHeaders(),
213
252
  });
214
253
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
215
254
  }
@@ -222,7 +261,7 @@ export async function addTurn(
222
261
  const res = await fetch(`${RUNS()}/${id}/turns`, {
223
262
  method: "POST",
224
263
  credentials: getCredentialsMode(),
225
- headers: { "Content-Type": "application/json" },
264
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
226
265
  body: JSON.stringify(body),
227
266
  });
228
267
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
@@ -233,6 +272,7 @@ export async function stopRun(id: string): Promise<void> {
233
272
  await fetch(`${RUNS()}/${id}/stop`, {
234
273
  method: "POST",
235
274
  credentials: getCredentialsMode(),
275
+ headers: getAuthHeaders(),
236
276
  }).catch(() => {});
237
277
  }
238
278
 
@@ -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
  })