@skyhook-io/radar-app 1.11.0 → 1.12.3

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 (67) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +37 -17
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +244 -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 +74 -1
  10. package/src/components/ConnectionErrorView.tsx +22 -20
  11. package/src/components/ContextSwitcher.tsx +10 -13
  12. package/src/components/applications/ApplicationsView.tsx +10 -9
  13. package/src/components/audit/AuditView.tsx +6 -3
  14. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  15. package/src/components/audit/UpgradeReadinessView.tsx +22 -12
  16. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  17. package/src/components/capacity/schedulingBar.test.ts +10 -0
  18. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  19. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  20. package/src/components/cost/CostTrendChart.tsx +20 -10
  21. package/src/components/cost/CostView.tsx +79 -28
  22. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  23. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  24. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  25. package/src/components/cost/format.test.ts +27 -8
  26. package/src/components/cost/format.ts +78 -27
  27. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  28. package/src/components/gitops/GitOpsView.tsx +8 -3
  29. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  30. package/src/components/helm/OwnedResources.tsx +10 -2
  31. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  32. package/src/components/home/ClusterHealthCard.tsx +63 -1
  33. package/src/components/home/CostCard.tsx +12 -7
  34. package/src/components/home/HomeView.tsx +36 -11
  35. package/src/components/home/MCPSetupDialog.tsx +5 -4
  36. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  37. package/src/components/home/RadarVersionLine.tsx +137 -0
  38. package/src/components/home/mcpToolCatalog.ts +12 -0
  39. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  40. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  41. package/src/components/resources/ResourcesView.tsx +31 -8
  42. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +181 -40
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/ui/DiagnosticsOverlay.test.ts +40 -0
  48. package/src/components/ui/DiagnosticsOverlay.tsx +18 -6
  49. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  50. package/src/components/ui/ErrorBoundary.tsx +17 -2
  51. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  52. package/src/components/ui/UpdateNotification.tsx +6 -2
  53. package/src/components/ui/command-items.ts +4 -14
  54. package/src/components/workload/WorkloadView.test.ts +60 -0
  55. package/src/components/workload/WorkloadView.tsx +272 -30
  56. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  57. package/src/contexts/CapabilitiesContext.tsx +7 -3
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.test.ts +45 -0
  62. package/src/utils/navigation.ts +5 -5
  63. package/src/utils/topology-selection.ts +3 -2
  64. package/src/utils/version.test.ts +37 -0
  65. package/src/utils/version.ts +56 -0
  66. package/src/utils/wails-clipboard.test.ts +109 -0
  67. package/src/utils/wails-clipboard.ts +127 -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 {
@@ -481,6 +488,12 @@ export interface UpgradeReadinessResponse {
481
488
  currentVersion: string
482
489
  targetVersion: string
483
490
  reviewedThrough: string
491
+ // When the underlying scan ran. The server memoizes scans (~60s), so a
492
+ // response can be older than the fetch that returned it — surface this,
493
+ // not dataUpdatedAt, as the scan time.
494
+ observedAt: string
495
+ scanId: string
496
+ fromCache?: boolean
484
497
  verdict: UpgradeReadinessVerdict
485
498
  summary: { blocked: number; warnings: number; reviews: number; passed: number; unknown: number; notApplicable: number; findings: number }
486
499
  checks: UpgradeReadinessCheck[]
@@ -590,15 +603,28 @@ export function useAudit(namespaces: string[] = []) {
590
603
  }
591
604
 
592
605
  export function useUpgradeReadiness(target?: string) {
593
- const params = new URLSearchParams()
594
- if (target) params.set('target', target)
595
- const query = params.toString()
596
- return useQuery<UpgradeReadinessResponse>({
606
+ // The server memoizes scans per caller (~60s). Ordinary and background
607
+ // refetches read that memo; only the explicit manual refresh passes
608
+ // refresh=true to force a fresh live scan (e.g. after fixing a finding).
609
+ const forceRefresh = useRef(false)
610
+ const query = useQuery<UpgradeReadinessResponse>({
597
611
  queryKey: ['upgrade-readiness', target ?? 'next'],
598
- queryFn: ({ signal }) => fetchJSON(`/upgrade-readiness${query ? `?${query}` : ''}`, signal),
612
+ queryFn: ({ signal }) => {
613
+ const params = new URLSearchParams()
614
+ if (target) params.set('target', target)
615
+ if (forceRefresh.current) params.set('refresh', 'true')
616
+ forceRefresh.current = false
617
+ const qs = params.toString()
618
+ return fetchJSON(`/upgrade-readiness${qs ? `?${qs}` : ''}`, signal)
619
+ },
599
620
  staleTime: 30000,
600
621
  placeholderData: (previous) => previous,
601
622
  })
623
+ const refreshScan = () => {
624
+ forceRefresh.current = true
625
+ return query.refetch()
626
+ }
627
+ return { ...query, refreshScan }
602
628
  }
603
629
 
604
630
  // Live cluster Issues — the grouped triage queue (radar's /api/issues =
@@ -937,6 +963,7 @@ export interface OpenCostWorkloadCost {
937
963
  export interface OpenCostWorkloadResponse {
938
964
  available: boolean;
939
965
  reason?: CostUnavailableReason;
966
+ currency?: string;
940
967
  namespace: string;
941
968
  workloads: OpenCostWorkloadCost[];
942
969
  }
@@ -964,6 +991,7 @@ export function useOpenCostWorkloads(
964
991
  export interface OpenCostWorkloadDetailResponse {
965
992
  available: boolean;
966
993
  reason?: CostUnavailableReason;
994
+ currency?: string;
967
995
  namespace: string;
968
996
  kind: string;
969
997
  name: string;
@@ -1008,6 +1036,7 @@ export interface OpenCostTrendSeries {
1008
1036
  export interface OpenCostTrendResponse {
1009
1037
  available: boolean;
1010
1038
  reason?: CostUnavailableReason;
1039
+ currency?: string;
1011
1040
  range: string;
1012
1041
  series?: OpenCostTrendSeries[];
1013
1042
  }
@@ -1029,6 +1058,7 @@ export function useOpenCostTrend(range_: CostTimeRange = "24h") {
1029
1058
  export interface OpenCostWorkloadTrendResponse {
1030
1059
  available: boolean;
1031
1060
  reason?: CostUnavailableReason;
1061
+ currency?: string;
1032
1062
  namespace: string;
1033
1063
  kind: string;
1034
1064
  name: string;
@@ -1101,6 +1131,7 @@ export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWork
1101
1131
  export interface OpenCostApplicationCostResponse {
1102
1132
  available: boolean;
1103
1133
  reason?: CostUnavailableReason;
1134
+ currency?: string;
1104
1135
  partial?: boolean;
1105
1136
  totals: OpenCostApplicationCostTotals;
1106
1137
  coverage: OpenCostApplicationCostCoverage;
@@ -1115,6 +1146,7 @@ export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationW
1115
1146
  export interface OpenCostApplicationCostTrendResponse {
1116
1147
  available: boolean;
1117
1148
  reason?: CostUnavailableReason;
1149
+ currency?: string;
1118
1150
  range: string;
1119
1151
  partial?: boolean;
1120
1152
  windowTotalCost?: number;
@@ -1206,6 +1238,7 @@ export interface OpenCostNodeCost {
1206
1238
  export interface OpenCostNodeResponse {
1207
1239
  available: boolean;
1208
1240
  reason?: CostUnavailableReason;
1241
+ currency?: string;
1209
1242
  nodes?: OpenCostNodeCost[];
1210
1243
  }
1211
1244
 
@@ -1484,13 +1517,68 @@ export interface VersionInfo {
1484
1517
  error?: string;
1485
1518
  }
1486
1519
 
1520
+ const UPDATE_CHECK_STORAGE_KEY_PREFIX = 'radar-update-check';
1521
+
1522
+ export function utcDay(now: Date): string {
1523
+ return now.toISOString().slice(0, 10)
1524
+ }
1525
+
1526
+ export function markDailyUpdateCheckAttempt(
1527
+ storage: Pick<Storage, 'getItem' | 'setItem'>,
1528
+ apiBase: string,
1529
+ now: Date,
1530
+ ): boolean {
1531
+ const storageKey = `${UPDATE_CHECK_STORAGE_KEY_PREFIX}:${apiBase}`
1532
+ const day = utcDay(now)
1533
+ try {
1534
+ if (storage.getItem(storageKey) === day) return false
1535
+
1536
+ storage.setItem(storageKey, day)
1537
+ return true
1538
+ } catch {
1539
+ return false
1540
+ }
1541
+ }
1542
+
1543
+ function markUpdateCheckAttempt(): boolean {
1544
+ try {
1545
+ return markDailyUpdateCheckAttempt(localStorage, getApiBase(), new Date())
1546
+ } catch {
1547
+ return false
1548
+ }
1549
+ }
1550
+
1551
+ export async function triggerDailyUpdateCheck(
1552
+ deploymentMode: DeploymentMode | undefined,
1553
+ ): Promise<void> {
1554
+ if (deploymentMode !== 'in-cluster' || !markUpdateCheckAttempt()) return
1555
+ await fetch(apiUrl('/version-check/browser'), {
1556
+ method: 'POST',
1557
+ headers: getAuthHeaders(),
1558
+ credentials: getCredentialsMode(),
1559
+ keepalive: true,
1560
+ })
1561
+ }
1562
+
1487
1563
  export function useVersionCheck() {
1488
- return useQuery<VersionInfo>({
1489
- queryKey: ["version-check"],
1490
- queryFn: () => fetchJSON("/version-check"),
1564
+ const capabilities = useCapabilities()
1565
+ const apiBase = getApiBase()
1566
+ const deploymentMode = capabilities.data
1567
+ ? (capabilities.data.deployment?.mode ?? 'local')
1568
+ : undefined
1569
+
1570
+ const query = useQuery<VersionInfo>({
1571
+ queryKey: ["version-check", apiBase],
1572
+ queryFn: () => fetchJSON('/version-check'),
1491
1573
  staleTime: 60 * 60 * 1000, // 1 hour
1492
1574
  retry: false, // Don't retry on failure
1493
1575
  });
1576
+
1577
+ useEffect(() => {
1578
+ if (query.isSuccess) void triggerDailyUpdateCheck(deploymentMode).catch(() => {})
1579
+ }, [apiBase, deploymentMode, query.isSuccess])
1580
+
1581
+ return query;
1494
1582
  }
1495
1583
 
1496
1584
  // ============================================================================
@@ -1763,6 +1851,11 @@ export const cloudInstallActive = (state: CloudInstallState | undefined): boolea
1763
1851
  export interface CloudConnectInfo {
1764
1852
  assurances?: string[]
1765
1853
  notice?: string
1854
+ // Free-tier terms as a prose fragment completing "Radar Cloud is ___."
1855
+ // (e.g. "free for 3 clusters"). Separate from assurances: chip copy and
1856
+ // sentence copy have different grammar, and reusing one as the other breaks
1857
+ // whenever either is reworded.
1858
+ freeTier?: string
1766
1859
  }
1767
1860
 
1768
1861
  // Deliberately a bare cross-origin GET: no credentials, no identifiers, no
@@ -1810,6 +1903,12 @@ export interface CloudConnectSelf {
1810
1903
  deploymentName?: string
1811
1904
  chart?: string
1812
1905
  controller?: string
1906
+ controllerRef?: {
1907
+ group?: string
1908
+ kind: string
1909
+ namespace?: string
1910
+ name: string
1911
+ }
1813
1912
  wizardUrl?: string
1814
1913
  }
1815
1914
 
@@ -2193,25 +2292,32 @@ export function useArgoRevisionMetadata(
2193
2292
 
2194
2293
  // Generic resource fetching - returns resource with relationships
2195
2294
  // Uses '_' as placeholder for cluster-scoped resources (empty namespace)
2196
- export function useResource<T>(
2295
+ export function fetchResourceWithRelationships<T>(
2197
2296
  kind: string,
2198
2297
  namespace: string,
2199
2298
  name: string,
2200
2299
  group?: string,
2201
- options?: { enabled?: boolean; refetchInterval?: number | false },
2202
- ) {
2203
- // For cluster-scoped resources, use '_' as namespace placeholder
2300
+ ): Promise<ResourceWithRelationships<T>> {
2204
2301
  const ns = namespace || "_";
2205
2302
  const params = new URLSearchParams();
2206
2303
  if (group) params.set("group", group);
2207
2304
  const queryString = params.toString();
2208
2305
 
2306
+ return fetchJSON(
2307
+ `/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ""}`,
2308
+ );
2309
+ }
2310
+
2311
+ export function useResource<T>(
2312
+ kind: string,
2313
+ namespace: string,
2314
+ name: string,
2315
+ group?: string,
2316
+ options?: { enabled?: boolean; refetchInterval?: number | false },
2317
+ ) {
2209
2318
  const query = useQuery<ResourceWithRelationships<T>>({
2210
2319
  queryKey: ["resource", kind, namespace, name, group],
2211
- queryFn: () =>
2212
- fetchJSON(
2213
- `/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ""}`,
2214
- ),
2320
+ queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
2215
2321
  enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
2216
2322
  refetchInterval: options?.refetchInterval,
2217
2323
  });
@@ -2233,17 +2339,9 @@ export function useResourceWithRelationships<T>(
2233
2339
  name: string,
2234
2340
  group?: string,
2235
2341
  ) {
2236
- const ns = namespace || "_";
2237
- const params = new URLSearchParams();
2238
- if (group) params.set("group", group);
2239
- const queryString = params.toString();
2240
-
2241
2342
  return useQuery<ResourceWithRelationships<T>>({
2242
2343
  queryKey: ["resource", kind, namespace, name, group],
2243
- queryFn: () =>
2244
- fetchJSON(
2245
- `/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ""}`,
2246
- ),
2344
+ queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
2247
2345
  enabled: Boolean(kind && name),
2248
2346
  });
2249
2347
  }
@@ -3104,6 +3202,7 @@ export interface ArgoStatus {
3104
3202
  configured: boolean;
3105
3203
  connected: boolean;
3106
3204
  address?: string;
3205
+ reason?: string;
3107
3206
  }
3108
3207
 
3109
3208
  export function useArgoStatus(enabled = true) {
@@ -3611,6 +3710,7 @@ export function useUpdateResource() {
3611
3710
  queryKey: ["resources", variables.kind],
3612
3711
  });
3613
3712
  queryClient.invalidateQueries({ queryKey: ["topology"] });
3713
+ queryClient.invalidateQueries({ queryKey: ["applications"] });
3614
3714
  },
3615
3715
  });
3616
3716
  }
@@ -3932,6 +4032,7 @@ export interface ApplyResourceResult {
3932
4032
  name: string;
3933
4033
  namespace: string;
3934
4034
  kind: string;
4035
+ apiVersion: string;
3935
4036
  created: boolean;
3936
4037
  }
3937
4038
 
@@ -4319,6 +4420,90 @@ export function useRestartWorkload() {
4319
4420
  });
4320
4421
  }
4321
4422
 
4423
+ export function fetchWorkloadImages(
4424
+ kind: string,
4425
+ namespace: string,
4426
+ name: string,
4427
+ ): Promise<WorkloadImageInventory> {
4428
+ return fetchJSON(`/workloads/${kind}/${namespace}/${name}/images`);
4429
+ }
4430
+
4431
+ export function setWorkloadImages(params: {
4432
+ kind: string;
4433
+ namespace: string;
4434
+ name: string;
4435
+ updates: WorkloadImageUpdate[];
4436
+ }): Promise<SetWorkloadImagesResult> {
4437
+ return fetchJSON<SetWorkloadImagesResult>(
4438
+ `/workloads/${params.kind}/${params.namespace}/${params.name}/images`,
4439
+ {
4440
+ method: "POST",
4441
+ headers: { "Content-Type": "application/json" },
4442
+ body: JSON.stringify({ updates: params.updates }),
4443
+ },
4444
+ );
4445
+ }
4446
+
4447
+ export function useSetWorkloadImages() {
4448
+ const queryClient = useQueryClient();
4449
+
4450
+ return useMutation({
4451
+ mutationFn: (params: {
4452
+ kind: string;
4453
+ namespace: string;
4454
+ name: string;
4455
+ updates: WorkloadImageUpdate[];
4456
+ }) => setWorkloadImages(params),
4457
+ meta: {
4458
+ successMessage: "Container images updated",
4459
+ },
4460
+ onSuccess: async (result, variables) => {
4461
+ await queryClient.cancelQueries({
4462
+ queryKey: ["resource", result.target.resource, result.target.namespace, result.target.name],
4463
+ });
4464
+ queryClient.setQueriesData<ResourceWithRelationships<Record<string, unknown>>>(
4465
+ {
4466
+ predicate: (query) => {
4467
+ const [scope, resource, namespace, name, group] = query.queryKey;
4468
+ return scope === "resource" &&
4469
+ resource === result.target.resource &&
4470
+ namespace === result.target.namespace &&
4471
+ name === result.target.name &&
4472
+ (group === result.target.group || group === undefined || group === "");
4473
+ },
4474
+ },
4475
+ (current) => {
4476
+ if (!current) return current;
4477
+ const apiVersion = current.resource.apiVersion;
4478
+ if (typeof apiVersion !== "string" || apiVersionToGroup(apiVersion) !== result.target.group) {
4479
+ return current;
4480
+ }
4481
+ return { ...current, resource: result.object };
4482
+ },
4483
+ );
4484
+ if (result.target.resource !== variables.kind || result.target.name !== variables.name) {
4485
+ queryClient.invalidateQueries({
4486
+ queryKey: ["resource", variables.kind, variables.namespace, variables.name],
4487
+ });
4488
+ }
4489
+ queryClient.invalidateQueries({ queryKey: ["resources", variables.kind] });
4490
+ if (result.target.resource !== variables.kind) {
4491
+ queryClient.invalidateQueries({ queryKey: ["resources", result.target.resource] });
4492
+ }
4493
+ queryClient.invalidateQueries({ queryKey: ["applications"] });
4494
+ queryClient.invalidateQueries({
4495
+ queryKey: [
4496
+ "workload-revisions",
4497
+ variables.kind,
4498
+ variables.namespace,
4499
+ variables.name,
4500
+ ],
4501
+ });
4502
+ queryClient.invalidateQueries({ queryKey: ["topology"] });
4503
+ },
4504
+ });
4505
+ }
4506
+
4322
4507
  // Scale a workload (Deployment, StatefulSet)
4323
4508
  export function useScaleWorkload() {
4324
4509
  const queryClient = useQueryClient();
@@ -4482,6 +4667,7 @@ export interface RolloutCapabilities {
4482
4667
  skipStep: boolean;
4483
4668
  rollback: boolean;
4484
4669
  restart: boolean;
4670
+ setImage?: boolean;
4485
4671
  strategy: string;
4486
4672
  terminating: boolean;
4487
4673
  }
@@ -4498,33 +4684,35 @@ export function useRolloutCapabilities(
4498
4684
  });
4499
4685
  }
4500
4686
 
4687
+ // Fallbacks only. The server reports what it actually did — including when it found
4688
+ // nothing to do — so its message is preferred over anything asserted here.
4501
4689
  const ROLLOUT_ACTION_MESSAGES: Record<
4502
4690
  RolloutAction,
4503
4691
  { errorMessage: string; successMessage: string }
4504
4692
  > = {
4505
4693
  abort: {
4506
4694
  errorMessage: "Failed to abort rollout",
4507
- successMessage: "Rollout aborted — traffic reverted to the stable version",
4695
+ successMessage: "Abort sent",
4508
4696
  },
4509
4697
  retry: {
4510
4698
  errorMessage: "Failed to retry rollout",
4511
- successMessage: "Rollout retried",
4699
+ successMessage: "Retry sent",
4512
4700
  },
4513
4701
  promote: {
4514
4702
  errorMessage: "Failed to promote rollout",
4515
- successMessage: "Rollout promoted",
4703
+ successMessage: "Promote sent",
4516
4704
  },
4517
4705
  "promote-full": {
4518
4706
  errorMessage: "Failed to promote rollout",
4519
- successMessage: "Rollout promoted to full — remaining steps skipped",
4707
+ successMessage: "Promote full sent",
4520
4708
  },
4521
4709
  "skip-step": {
4522
4710
  errorMessage: "Failed to skip step",
4523
- successMessage: "Skipped to the next step",
4711
+ successMessage: "Skip step sent",
4524
4712
  },
4525
4713
  };
4526
4714
 
4527
- export function useRolloutAction() {
4715
+ export function useRolloutAction(options?: { reportErrors?: boolean }) {
4528
4716
  const queryClient = useQueryClient();
4529
4717
  return useMutation({
4530
4718
  mutationFn: async ({
@@ -4544,14 +4732,26 @@ export function useRolloutAction() {
4544
4732
  const error = await response
4545
4733
  .json()
4546
4734
  .catch(() => ({ error: "Unknown error" }));
4547
- throw new Error(error.error || `HTTP ${response.status}`);
4735
+ // The code, not the status, says whether retrying can help: a lost cluster
4736
+ // connection answers 503 on this route too.
4737
+ throw new ApiError(
4738
+ error.error || `HTTP ${response.status}`,
4739
+ response.status,
4740
+ error,
4741
+ );
4548
4742
  }
4549
4743
  return response.json();
4550
4744
  },
4551
- // meta is static, so per-action success wording goes through onSuccess.
4552
- meta: { errorMessage: "Rollout action failed" },
4553
- onSuccess: (_, variables) => {
4554
- showApiSuccess(ROLLOUT_ACTION_MESSAGES[variables.action].successMessage);
4745
+ // meta is static, so per-action success wording goes through onSuccess. Callers that
4746
+ // render the failure themselves opt out, so the toast handler stays silent.
4747
+ meta:
4748
+ options?.reportErrors === false
4749
+ ? undefined
4750
+ : { errorMessage: "Rollout action failed" },
4751
+ onSuccess: (data, variables) => {
4752
+ showApiSuccess(
4753
+ data?.message || ROLLOUT_ACTION_MESSAGES[variables.action].successMessage,
4754
+ );
4555
4755
  queryClient.invalidateQueries({ queryKey: ["resources", "rollouts"] });
4556
4756
  queryClient.invalidateQueries({
4557
4757
  queryKey: ["resource", "rollouts", variables.namespace, variables.name],
@@ -5939,6 +6139,8 @@ export interface NamespaceScope {
5939
6139
  */
5940
6140
  mode: "cluster-wide" | "namespace" | "restricted";
5941
6141
  accessibleNamespaces: string[];
6142
+ /** Namespaces where Radar cannot list pods or deployments. */
6143
+ deniedNamespaces: string[];
5942
6144
  /** false when accessibleNamespaces is a best-effort short list (no list perm). */
5943
6145
  authoritative: boolean;
5944
6146
  /** false when clearing would leave no usable namespace fallback. */
@@ -6405,10 +6607,13 @@ export interface DiagnosticsSnapshot {
6405
6607
  errorType?: string;
6406
6608
  };
6407
6609
  kubeconfig?: {
6408
- mode: "" | "in-cluster" | "single" | "multi-env" | "multi-dir";
6610
+ mode: "" | "in-cluster" | "single" | "multi-env" | "multi-dir" | "multi-source";
6409
6611
  fileCount: number;
6612
+ directoryFileCount: number;
6410
6613
  contextCount: number;
6411
6614
  enrichedFromShell: boolean;
6615
+ kubeconfigEnvIgnored: boolean;
6616
+ kubeconfigEnvIgnoredReason: string;
6412
6617
  currentContextUsesExec: boolean;
6413
6618
  execPluginsPresent?: string[];
6414
6619
  execPluginsMissing?: string[];
@@ -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
+ })