@skyhook-io/radar-app 1.12.2 → 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.
Files changed (60) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +35 -15
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +246 -39
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +21 -1
  10. package/src/components/ConnectionErrorView.tsx +7 -8
  11. package/src/components/applications/ApplicationsView.tsx +10 -9
  12. package/src/components/audit/AuditView.tsx +6 -3
  13. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  14. package/src/components/audit/UpgradeReadinessView.tsx +19 -9
  15. package/src/components/cost/ApplicationCostTab.test.ts +45 -0
  16. package/src/components/cost/ApplicationCostTab.tsx +115 -64
  17. package/src/components/cost/CostTrendChart.test.ts +65 -0
  18. package/src/components/cost/CostTrendChart.tsx +107 -17
  19. package/src/components/cost/CostView.test.ts +36 -1
  20. package/src/components/cost/CostView.tsx +133 -51
  21. package/src/components/cost/CurrentAllocationUse.tsx +8 -4
  22. package/src/components/cost/WorkloadCostTab.test.ts +50 -0
  23. package/src/components/cost/WorkloadCostTab.tsx +109 -61
  24. package/src/components/cost/source.test.ts +33 -0
  25. package/src/components/cost/source.ts +100 -0
  26. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  27. package/src/components/diagnose/parts.test.tsx +12 -4
  28. package/src/components/diagnose/parts.tsx +19 -15
  29. package/src/components/gitops/GitOpsView.tsx +8 -3
  30. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  31. package/src/components/helm/OwnedResources.tsx +10 -2
  32. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  33. package/src/components/home/ClusterHealthCard.tsx +60 -1
  34. package/src/components/home/CostCard.tsx +3 -2
  35. package/src/components/home/HomeView.tsx +36 -11
  36. package/src/components/home/MCPSetupDialog.tsx +5 -4
  37. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  38. package/src/components/home/RadarVersionLine.tsx +137 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  40. package/src/components/resources/ResourcesView.tsx +31 -8
  41. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  42. package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
  43. package/src/components/rightsizing/copy.test.ts +19 -0
  44. package/src/components/settings/SettingsDialog.tsx +687 -107
  45. package/src/components/settings/settings-state.test.ts +42 -0
  46. package/src/components/settings/settings-state.ts +39 -0
  47. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  48. package/src/components/ui/ErrorBoundary.tsx +17 -2
  49. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  50. package/src/components/ui/UpdateNotification.tsx +6 -2
  51. package/src/components/workload/WorkloadView.test.ts +60 -0
  52. package/src/components/workload/WorkloadView.tsx +270 -29
  53. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  54. package/src/contexts/CapabilitiesContext.tsx +7 -3
  55. package/src/index.css +11 -1
  56. package/src/utils/navigation.test.ts +45 -0
  57. package/src/utils/navigation.ts +5 -5
  58. package/src/utils/topology-selection.ts +3 -2
  59. package/src/utils/version.test.ts +37 -0
  60. package/src/utils/version.ts +56 -0
package/src/api/client.ts CHANGED
@@ -11,6 +11,9 @@ import type {
11
11
  CapacityOverviewResponse,
12
12
  CapacityPoolDetailResponse,
13
13
  CapacityPoolListResponse,
14
+ SetWorkloadImagesResult,
15
+ WorkloadImageInventory,
16
+ WorkloadImageUpdate,
14
17
  YamlDocumentIdentity,
15
18
  YamlSchemaLoadResult,
16
19
  } from '@skyhook-io/k8s-ui'
@@ -52,8 +55,9 @@ import type {
52
55
  PodEnvironmentRevealResponse,
53
56
  } from '../types'
54
57
  import type { GitOpsOperationResponse } from '../types/gitops'
55
- import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath, stripBasename } from './config'
56
- import { pluralToKind } from '../utils/navigation'
58
+ import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath, stripBasename } from './config'
59
+ import { apiVersionToGroup, pluralToKind } from '../utils/navigation'
60
+ import type { DeploymentMode } from '../types'
57
61
 
58
62
  // Auto-refresh cadences (ms) — named constants for each polled hook's
59
63
  // refetchInterval below, so the poll rate reads clearly at each call site.
@@ -292,6 +296,9 @@ export interface DashboardCluster {
292
296
  platform: string;
293
297
  version: string;
294
298
  connected: boolean;
299
+ // Newest Kubernetes minor the upgrade-impact catalog covers (e.g. "1.37").
300
+ // Static catalog fact — safe to compare against `version` without a scan.
301
+ upgradeReviewedThrough?: string;
295
302
  }
296
303
 
297
304
  export interface DashboardHealth {
@@ -875,24 +882,36 @@ export interface OpenCostNamespaceCost {
875
882
 
876
883
  export type CostUnavailableReason =
877
884
  | "no_prometheus"
885
+ | "no_cost_source"
878
886
  | "no_metrics"
879
887
  | "query_error"
880
888
  | "access_denied"
881
- | "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";
882
898
 
883
899
  export interface OpenCostSummary {
884
900
  available: boolean;
885
901
  reason?: CostUnavailableReason;
902
+ source?: CostDataSource;
903
+ dataThrough?: string;
886
904
  currency?: string;
887
905
  window?: string;
888
906
  totalHourlyCost?: number;
889
907
  totalStorageCost?: number;
890
908
  totalIdleCost?: number;
891
909
  clusterEfficiency?: number;
910
+ namespaceScope?: string[];
892
911
  namespaces?: OpenCostNamespaceCost[];
893
912
  }
894
913
 
895
- const noPrometheusFirstSeenAt = new Map<string, number>();
914
+ const costDiscoveryFirstSeenAt = new Map<string, number>();
896
915
 
897
916
  function costRefetchInterval(
898
917
  defaultInterval: number | false = COST_REFRESH_INTERVAL_MS,
@@ -908,15 +927,18 @@ function costRefetchInterval(
908
927
  }) => {
909
928
  const data = query.state.data;
910
929
  const queryID = `${contextName ?? "unknown"}:${query.queryHash ?? JSON.stringify(query.queryKey ?? "opencost")}`;
911
- 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
+ ) {
912
934
  const now = Date.now();
913
- const firstSeenAt = noPrometheusFirstSeenAt.get(queryID) ?? now;
914
- noPrometheusFirstSeenAt.set(queryID, firstSeenAt);
935
+ const firstSeenAt = costDiscoveryFirstSeenAt.get(queryID) ?? now;
936
+ costDiscoveryFirstSeenAt.set(queryID, firstSeenAt);
915
937
  return now - firstSeenAt < COST_DISCOVERY_GRACE_MS
916
938
  ? COST_DISCOVERY_RETRY_INTERVAL_MS
917
939
  : defaultInterval;
918
940
  }
919
- noPrometheusFirstSeenAt.delete(queryID);
941
+ costDiscoveryFirstSeenAt.delete(queryID);
920
942
  return defaultInterval;
921
943
  };
922
944
  }
@@ -956,6 +978,9 @@ export interface OpenCostWorkloadCost {
956
978
  export interface OpenCostWorkloadResponse {
957
979
  available: boolean;
958
980
  reason?: CostUnavailableReason;
981
+ source?: CostDataSource;
982
+ window?: string;
983
+ dataThrough?: string;
959
984
  currency?: string;
960
985
  namespace: string;
961
986
  workloads: OpenCostWorkloadCost[];
@@ -984,6 +1009,9 @@ export function useOpenCostWorkloads(
984
1009
  export interface OpenCostWorkloadDetailResponse {
985
1010
  available: boolean;
986
1011
  reason?: CostUnavailableReason;
1012
+ source?: CostDataSource;
1013
+ window?: string;
1014
+ dataThrough?: string;
987
1015
  currency?: string;
988
1016
  namespace: string;
989
1017
  kind: string;
@@ -1029,8 +1057,12 @@ export interface OpenCostTrendSeries {
1029
1057
  export interface OpenCostTrendResponse {
1030
1058
  available: boolean;
1031
1059
  reason?: CostUnavailableReason;
1060
+ source?: CostDataSource;
1032
1061
  currency?: string;
1033
1062
  range: string;
1063
+ windowStart?: number;
1064
+ windowEnd?: number;
1065
+ dataThrough?: string;
1034
1066
  series?: OpenCostTrendSeries[];
1035
1067
  }
1036
1068
 
@@ -1051,6 +1083,7 @@ export function useOpenCostTrend(range_: CostTimeRange = "24h") {
1051
1083
  export interface OpenCostWorkloadTrendResponse {
1052
1084
  available: boolean;
1053
1085
  reason?: CostUnavailableReason;
1086
+ source?: CostDataSource;
1054
1087
  currency?: string;
1055
1088
  namespace: string;
1056
1089
  kind: string;
@@ -1124,6 +1157,9 @@ export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWork
1124
1157
  export interface OpenCostApplicationCostResponse {
1125
1158
  available: boolean;
1126
1159
  reason?: CostUnavailableReason;
1160
+ source?: CostDataSource;
1161
+ window?: string;
1162
+ dataThrough?: string;
1127
1163
  currency?: string;
1128
1164
  partial?: boolean;
1129
1165
  totals: OpenCostApplicationCostTotals;
@@ -1139,6 +1175,7 @@ export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationW
1139
1175
  export interface OpenCostApplicationCostTrendResponse {
1140
1176
  available: boolean;
1141
1177
  reason?: CostUnavailableReason;
1178
+ source?: CostDataSource;
1142
1179
  currency?: string;
1143
1180
  range: string;
1144
1181
  partial?: boolean;
@@ -1231,6 +1268,8 @@ export interface OpenCostNodeCost {
1231
1268
  export interface OpenCostNodeResponse {
1232
1269
  available: boolean;
1233
1270
  reason?: CostUnavailableReason;
1271
+ source?: CostDataSource;
1272
+ dataThrough?: string;
1234
1273
  currency?: string;
1235
1274
  nodes?: OpenCostNodeCost[];
1236
1275
  }
@@ -1510,13 +1549,68 @@ export interface VersionInfo {
1510
1549
  error?: string;
1511
1550
  }
1512
1551
 
1552
+ const UPDATE_CHECK_STORAGE_KEY_PREFIX = 'radar-update-check';
1553
+
1554
+ export function utcDay(now: Date): string {
1555
+ return now.toISOString().slice(0, 10)
1556
+ }
1557
+
1558
+ export function markDailyUpdateCheckAttempt(
1559
+ storage: Pick<Storage, 'getItem' | 'setItem'>,
1560
+ apiBase: string,
1561
+ now: Date,
1562
+ ): boolean {
1563
+ const storageKey = `${UPDATE_CHECK_STORAGE_KEY_PREFIX}:${apiBase}`
1564
+ const day = utcDay(now)
1565
+ try {
1566
+ if (storage.getItem(storageKey) === day) return false
1567
+
1568
+ storage.setItem(storageKey, day)
1569
+ return true
1570
+ } catch {
1571
+ return false
1572
+ }
1573
+ }
1574
+
1575
+ function markUpdateCheckAttempt(): boolean {
1576
+ try {
1577
+ return markDailyUpdateCheckAttempt(localStorage, getApiBase(), new Date())
1578
+ } catch {
1579
+ return false
1580
+ }
1581
+ }
1582
+
1583
+ export async function triggerDailyUpdateCheck(
1584
+ deploymentMode: DeploymentMode | undefined,
1585
+ ): Promise<void> {
1586
+ if (deploymentMode !== 'in-cluster' || !markUpdateCheckAttempt()) return
1587
+ await fetch(apiUrl('/version-check/browser'), {
1588
+ method: 'POST',
1589
+ headers: getAuthHeaders(),
1590
+ credentials: getCredentialsMode(),
1591
+ keepalive: true,
1592
+ })
1593
+ }
1594
+
1513
1595
  export function useVersionCheck() {
1514
- return useQuery<VersionInfo>({
1515
- queryKey: ["version-check"],
1516
- queryFn: () => fetchJSON("/version-check"),
1596
+ const capabilities = useCapabilities()
1597
+ const apiBase = getApiBase()
1598
+ const deploymentMode = capabilities.data
1599
+ ? (capabilities.data.deployment?.mode ?? 'local')
1600
+ : undefined
1601
+
1602
+ const query = useQuery<VersionInfo>({
1603
+ queryKey: ["version-check", apiBase],
1604
+ queryFn: () => fetchJSON('/version-check'),
1517
1605
  staleTime: 60 * 60 * 1000, // 1 hour
1518
1606
  retry: false, // Don't retry on failure
1519
1607
  });
1608
+
1609
+ useEffect(() => {
1610
+ if (query.isSuccess) void triggerDailyUpdateCheck(deploymentMode).catch(() => {})
1611
+ }, [apiBase, deploymentMode, query.isSuccess])
1612
+
1613
+ return query;
1520
1614
  }
1521
1615
 
1522
1616
  // ============================================================================
@@ -1789,6 +1883,11 @@ export const cloudInstallActive = (state: CloudInstallState | undefined): boolea
1789
1883
  export interface CloudConnectInfo {
1790
1884
  assurances?: string[]
1791
1885
  notice?: string
1886
+ // Free-tier terms as a prose fragment completing "Radar Cloud is ___."
1887
+ // (e.g. "free for 3 clusters"). Separate from assurances: chip copy and
1888
+ // sentence copy have different grammar, and reusing one as the other breaks
1889
+ // whenever either is reworded.
1890
+ freeTier?: string
1792
1891
  }
1793
1892
 
1794
1893
  // Deliberately a bare cross-origin GET: no credentials, no identifiers, no
@@ -1836,6 +1935,12 @@ export interface CloudConnectSelf {
1836
1935
  deploymentName?: string
1837
1936
  chart?: string
1838
1937
  controller?: string
1938
+ controllerRef?: {
1939
+ group?: string
1940
+ kind: string
1941
+ namespace?: string
1942
+ name: string
1943
+ }
1839
1944
  wizardUrl?: string
1840
1945
  }
1841
1946
 
@@ -2219,25 +2324,32 @@ export function useArgoRevisionMetadata(
2219
2324
 
2220
2325
  // Generic resource fetching - returns resource with relationships
2221
2326
  // Uses '_' as placeholder for cluster-scoped resources (empty namespace)
2222
- export function useResource<T>(
2327
+ export function fetchResourceWithRelationships<T>(
2223
2328
  kind: string,
2224
2329
  namespace: string,
2225
2330
  name: string,
2226
2331
  group?: string,
2227
- options?: { enabled?: boolean; refetchInterval?: number | false },
2228
- ) {
2229
- // For cluster-scoped resources, use '_' as namespace placeholder
2332
+ ): Promise<ResourceWithRelationships<T>> {
2230
2333
  const ns = namespace || "_";
2231
2334
  const params = new URLSearchParams();
2232
2335
  if (group) params.set("group", group);
2233
2336
  const queryString = params.toString();
2234
2337
 
2338
+ return fetchJSON(
2339
+ `/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ""}`,
2340
+ );
2341
+ }
2342
+
2343
+ export function useResource<T>(
2344
+ kind: string,
2345
+ namespace: string,
2346
+ name: string,
2347
+ group?: string,
2348
+ options?: { enabled?: boolean; refetchInterval?: number | false },
2349
+ ) {
2235
2350
  const query = useQuery<ResourceWithRelationships<T>>({
2236
2351
  queryKey: ["resource", kind, namespace, name, group],
2237
- queryFn: () =>
2238
- fetchJSON(
2239
- `/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ""}`,
2240
- ),
2352
+ queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
2241
2353
  enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
2242
2354
  refetchInterval: options?.refetchInterval,
2243
2355
  });
@@ -2259,17 +2371,9 @@ export function useResourceWithRelationships<T>(
2259
2371
  name: string,
2260
2372
  group?: string,
2261
2373
  ) {
2262
- const ns = namespace || "_";
2263
- const params = new URLSearchParams();
2264
- if (group) params.set("group", group);
2265
- const queryString = params.toString();
2266
-
2267
2374
  return useQuery<ResourceWithRelationships<T>>({
2268
2375
  queryKey: ["resource", kind, namespace, name, group],
2269
- queryFn: () =>
2270
- fetchJSON(
2271
- `/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ""}`,
2272
- ),
2376
+ queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
2273
2377
  enabled: Boolean(kind && name),
2274
2378
  });
2275
2379
  }
@@ -3638,6 +3742,7 @@ export function useUpdateResource() {
3638
3742
  queryKey: ["resources", variables.kind],
3639
3743
  });
3640
3744
  queryClient.invalidateQueries({ queryKey: ["topology"] });
3745
+ queryClient.invalidateQueries({ queryKey: ["applications"] });
3641
3746
  },
3642
3747
  });
3643
3748
  }
@@ -3959,6 +4064,7 @@ export interface ApplyResourceResult {
3959
4064
  name: string;
3960
4065
  namespace: string;
3961
4066
  kind: string;
4067
+ apiVersion: string;
3962
4068
  created: boolean;
3963
4069
  }
3964
4070
 
@@ -4346,6 +4452,90 @@ export function useRestartWorkload() {
4346
4452
  });
4347
4453
  }
4348
4454
 
4455
+ export function fetchWorkloadImages(
4456
+ kind: string,
4457
+ namespace: string,
4458
+ name: string,
4459
+ ): Promise<WorkloadImageInventory> {
4460
+ return fetchJSON(`/workloads/${kind}/${namespace}/${name}/images`);
4461
+ }
4462
+
4463
+ export function setWorkloadImages(params: {
4464
+ kind: string;
4465
+ namespace: string;
4466
+ name: string;
4467
+ updates: WorkloadImageUpdate[];
4468
+ }): Promise<SetWorkloadImagesResult> {
4469
+ return fetchJSON<SetWorkloadImagesResult>(
4470
+ `/workloads/${params.kind}/${params.namespace}/${params.name}/images`,
4471
+ {
4472
+ method: "POST",
4473
+ headers: { "Content-Type": "application/json" },
4474
+ body: JSON.stringify({ updates: params.updates }),
4475
+ },
4476
+ );
4477
+ }
4478
+
4479
+ export function useSetWorkloadImages() {
4480
+ const queryClient = useQueryClient();
4481
+
4482
+ return useMutation({
4483
+ mutationFn: (params: {
4484
+ kind: string;
4485
+ namespace: string;
4486
+ name: string;
4487
+ updates: WorkloadImageUpdate[];
4488
+ }) => setWorkloadImages(params),
4489
+ meta: {
4490
+ successMessage: "Container images updated",
4491
+ },
4492
+ onSuccess: async (result, variables) => {
4493
+ await queryClient.cancelQueries({
4494
+ queryKey: ["resource", result.target.resource, result.target.namespace, result.target.name],
4495
+ });
4496
+ queryClient.setQueriesData<ResourceWithRelationships<Record<string, unknown>>>(
4497
+ {
4498
+ predicate: (query) => {
4499
+ const [scope, resource, namespace, name, group] = query.queryKey;
4500
+ return scope === "resource" &&
4501
+ resource === result.target.resource &&
4502
+ namespace === result.target.namespace &&
4503
+ name === result.target.name &&
4504
+ (group === result.target.group || group === undefined || group === "");
4505
+ },
4506
+ },
4507
+ (current) => {
4508
+ if (!current) return current;
4509
+ const apiVersion = current.resource.apiVersion;
4510
+ if (typeof apiVersion !== "string" || apiVersionToGroup(apiVersion) !== result.target.group) {
4511
+ return current;
4512
+ }
4513
+ return { ...current, resource: result.object };
4514
+ },
4515
+ );
4516
+ if (result.target.resource !== variables.kind || result.target.name !== variables.name) {
4517
+ queryClient.invalidateQueries({
4518
+ queryKey: ["resource", variables.kind, variables.namespace, variables.name],
4519
+ });
4520
+ }
4521
+ queryClient.invalidateQueries({ queryKey: ["resources", variables.kind] });
4522
+ if (result.target.resource !== variables.kind) {
4523
+ queryClient.invalidateQueries({ queryKey: ["resources", result.target.resource] });
4524
+ }
4525
+ queryClient.invalidateQueries({ queryKey: ["applications"] });
4526
+ queryClient.invalidateQueries({
4527
+ queryKey: [
4528
+ "workload-revisions",
4529
+ variables.kind,
4530
+ variables.namespace,
4531
+ variables.name,
4532
+ ],
4533
+ });
4534
+ queryClient.invalidateQueries({ queryKey: ["topology"] });
4535
+ },
4536
+ });
4537
+ }
4538
+
4349
4539
  // Scale a workload (Deployment, StatefulSet)
4350
4540
  export function useScaleWorkload() {
4351
4541
  const queryClient = useQueryClient();
@@ -4509,6 +4699,7 @@ export interface RolloutCapabilities {
4509
4699
  skipStep: boolean;
4510
4700
  rollback: boolean;
4511
4701
  restart: boolean;
4702
+ setImage?: boolean;
4512
4703
  strategy: string;
4513
4704
  terminating: boolean;
4514
4705
  }
@@ -4525,33 +4716,35 @@ export function useRolloutCapabilities(
4525
4716
  });
4526
4717
  }
4527
4718
 
4719
+ // Fallbacks only. The server reports what it actually did — including when it found
4720
+ // nothing to do — so its message is preferred over anything asserted here.
4528
4721
  const ROLLOUT_ACTION_MESSAGES: Record<
4529
4722
  RolloutAction,
4530
4723
  { errorMessage: string; successMessage: string }
4531
4724
  > = {
4532
4725
  abort: {
4533
4726
  errorMessage: "Failed to abort rollout",
4534
- successMessage: "Rollout aborted — traffic reverted to the stable version",
4727
+ successMessage: "Abort sent",
4535
4728
  },
4536
4729
  retry: {
4537
4730
  errorMessage: "Failed to retry rollout",
4538
- successMessage: "Rollout retried",
4731
+ successMessage: "Retry sent",
4539
4732
  },
4540
4733
  promote: {
4541
4734
  errorMessage: "Failed to promote rollout",
4542
- successMessage: "Rollout promoted",
4735
+ successMessage: "Promote sent",
4543
4736
  },
4544
4737
  "promote-full": {
4545
4738
  errorMessage: "Failed to promote rollout",
4546
- successMessage: "Rollout promoted to full — remaining steps skipped",
4739
+ successMessage: "Promote full sent",
4547
4740
  },
4548
4741
  "skip-step": {
4549
4742
  errorMessage: "Failed to skip step",
4550
- successMessage: "Skipped to the next step",
4743
+ successMessage: "Skip step sent",
4551
4744
  },
4552
4745
  };
4553
4746
 
4554
- export function useRolloutAction() {
4747
+ export function useRolloutAction(options?: { reportErrors?: boolean }) {
4555
4748
  const queryClient = useQueryClient();
4556
4749
  return useMutation({
4557
4750
  mutationFn: async ({
@@ -4571,14 +4764,26 @@ export function useRolloutAction() {
4571
4764
  const error = await response
4572
4765
  .json()
4573
4766
  .catch(() => ({ error: "Unknown error" }));
4574
- throw new Error(error.error || `HTTP ${response.status}`);
4767
+ // The code, not the status, says whether retrying can help: a lost cluster
4768
+ // connection answers 503 on this route too.
4769
+ throw new ApiError(
4770
+ error.error || `HTTP ${response.status}`,
4771
+ response.status,
4772
+ error,
4773
+ );
4575
4774
  }
4576
4775
  return response.json();
4577
4776
  },
4578
- // meta is static, so per-action success wording goes through onSuccess.
4579
- meta: { errorMessage: "Rollout action failed" },
4580
- onSuccess: (_, variables) => {
4581
- showApiSuccess(ROLLOUT_ACTION_MESSAGES[variables.action].successMessage);
4777
+ // meta is static, so per-action success wording goes through onSuccess. Callers that
4778
+ // render the failure themselves opt out, so the toast handler stays silent.
4779
+ meta:
4780
+ options?.reportErrors === false
4781
+ ? undefined
4782
+ : { errorMessage: "Rollout action failed" },
4783
+ onSuccess: (data, variables) => {
4784
+ showApiSuccess(
4785
+ data?.message || ROLLOUT_ACTION_MESSAGES[variables.action].successMessage,
4786
+ );
4582
4787
  queryClient.invalidateQueries({ queryKey: ["resources", "rollouts"] });
4583
4788
  queryClient.invalidateQueries({
4584
4789
  queryKey: ["resource", "rollouts", variables.namespace, variables.name],
@@ -5966,6 +6171,8 @@ export interface NamespaceScope {
5966
6171
  */
5967
6172
  mode: "cluster-wide" | "namespace" | "restricted";
5968
6173
  accessibleNamespaces: string[];
6174
+ /** Namespaces where Radar cannot list pods or deployments. */
6175
+ deniedNamespaces: string[];
5969
6176
  /** false when accessibleNamespaces is a best-effort short list (no list perm). */
5970
6177
  authoritative: boolean;
5971
6178
  /** false when clearing would leave no usable namespace fallback. */
@@ -10,8 +10,8 @@ describe('formatApplyResourceError', () => {
10
10
  failedIndex: 2,
11
11
  total: 4,
12
12
  results: [
13
- { kind: 'Namespace', namespace: '', name: 'checkout', created: true },
14
- { kind: 'Deployment', namespace: 'checkout', name: 'api', created: true },
13
+ { apiVersion: 'v1', kind: 'Namespace', namespace: '', name: 'checkout', created: true },
14
+ { apiVersion: 'apps/v1', kind: 'Deployment', namespace: 'checkout', name: 'api', created: true },
15
15
  ],
16
16
  },
17
17
  422,
@@ -26,7 +26,7 @@ describe('formatApplyResourceError', () => {
26
26
  })
27
27
 
28
28
  it('preserves structured partial-apply results for recovery', () => {
29
- const result = { kind: 'Namespace', namespace: '', name: 'checkout', created: true }
29
+ const result = { apiVersion: 'v1', kind: 'Namespace', namespace: '', name: 'checkout', created: true }
30
30
  const error = new ApplyResourceError(
31
31
  {
32
32
  error: 'document 2: admission denied',
@@ -0,0 +1,78 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { markDailyUpdateCheckAttempt, triggerDailyUpdateCheck, utcDay } from './client'
3
+
4
+ function memoryStorage() {
5
+ const values = new Map<string, string>()
6
+ return {
7
+ getItem: (key: string) => values.get(key) ?? null,
8
+ setItem: (key: string, value: string) => values.set(key, value),
9
+ }
10
+ }
11
+
12
+ describe('daily update checks', () => {
13
+ it('uses UTC days', () => {
14
+ expect(utcDay(new Date('2026-08-29T23:59:59-07:00'))).toBe('2026-08-30')
15
+ })
16
+
17
+ it('attempts once per API base and UTC day', () => {
18
+ const storage = memoryStorage()
19
+ expect(markDailyUpdateCheckAttempt(storage, '/api', new Date('2026-08-29T10:00:00Z'))).toBe(true)
20
+ expect(markDailyUpdateCheckAttempt(storage, '/api', new Date('2026-08-29T20:00:00Z'))).toBe(false)
21
+ expect(markDailyUpdateCheckAttempt(storage, '/api', new Date('2026-08-30T10:00:00Z'))).toBe(true)
22
+ expect(markDailyUpdateCheckAttempt(storage, '/c/cluster-a/api', new Date('2026-08-30T10:00:00Z'))).toBe(true)
23
+ })
24
+
25
+ it('skips the attempt when storage is unavailable', () => {
26
+ const storage = {
27
+ getItem: () => null,
28
+ setItem: () => { throw new Error('blocked') },
29
+ }
30
+ expect(markDailyUpdateCheckAttempt(storage, '/api', new Date('2026-08-29T10:00:00Z'))).toBe(false)
31
+ })
32
+ })
33
+
34
+ describe('triggerDailyUpdateCheck', () => {
35
+ beforeEach(() => {
36
+ const values = new Map<string, string>()
37
+ vi.stubGlobal('localStorage', {
38
+ getItem: (key: string) => values.get(key) ?? null,
39
+ setItem: (key: string, value: string) => values.set(key, value),
40
+ })
41
+ })
42
+
43
+ afterEach(() => {
44
+ vi.unstubAllGlobals()
45
+ })
46
+
47
+ it.each(['local', 'cloud', undefined] as const)('does not report in %s mode', async (mode) => {
48
+ const fetch = vi.fn<typeof globalThis.fetch>(async () => new Response(null, { status: 204 }))
49
+ vi.stubGlobal('fetch', fetch)
50
+ await triggerDailyUpdateCheck(mode)
51
+ expect(fetch).not.toHaveBeenCalled()
52
+ })
53
+
54
+ it('sends one bodyless in-cluster attempt per day', async () => {
55
+ const fetch = vi.fn<typeof globalThis.fetch>(async () => new Response(null, { status: 204 }))
56
+ vi.stubGlobal('fetch', fetch)
57
+
58
+ await triggerDailyUpdateCheck('in-cluster')
59
+ await triggerDailyUpdateCheck('in-cluster')
60
+
61
+ expect(fetch).toHaveBeenCalledOnce()
62
+ expect(fetch).toHaveBeenCalledWith('/api/version-check/browser', expect.objectContaining({
63
+ method: 'POST',
64
+ credentials: 'same-origin',
65
+ keepalive: true,
66
+ }))
67
+ expect(fetch.mock.calls[0][1]).not.toHaveProperty('body')
68
+ })
69
+
70
+ it('does not retry a failed daily attempt', async () => {
71
+ const fetch = vi.fn<typeof globalThis.fetch>(async () => { throw new Error('offline') })
72
+ vi.stubGlobal('fetch', fetch)
73
+
74
+ await expect(triggerDailyUpdateCheck('in-cluster')).rejects.toThrow('offline')
75
+ await triggerDailyUpdateCheck('in-cluster')
76
+ expect(fetch).toHaveBeenCalledOnce()
77
+ })
78
+ })