@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.
- package/package.json +7 -7
- package/src/App.tsx +37 -17
- package/src/api/client.images.test.ts +63 -0
- package/src/api/client.ts +244 -39
- package/src/api/client.yaml.test.ts +3 -3
- package/src/api/version-check.test.ts +78 -0
- package/src/components/CloudConnectFlow.tsx +46 -26
- package/src/components/CloudFunnelButton.tsx +166 -129
- package/src/components/ConnectionErrorView.test.tsx +74 -1
- package/src/components/ConnectionErrorView.tsx +22 -20
- package/src/components/ContextSwitcher.tsx +10 -13
- package/src/components/applications/ApplicationsView.tsx +10 -9
- package/src/components/audit/AuditView.tsx +6 -3
- package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
- package/src/components/audit/UpgradeReadinessView.tsx +22 -12
- package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
- package/src/components/capacity/schedulingBar.test.ts +10 -0
- package/src/components/cost/ApplicationCostTab.test.ts +6 -0
- package/src/components/cost/ApplicationCostTab.tsx +28 -16
- package/src/components/cost/CostTrendChart.tsx +20 -10
- package/src/components/cost/CostView.tsx +79 -28
- package/src/components/cost/CurrentAllocationUse.tsx +6 -4
- package/src/components/cost/WorkloadCostTab.test.ts +10 -0
- package/src/components/cost/WorkloadCostTab.tsx +24 -12
- package/src/components/cost/format.test.ts +27 -8
- package/src/components/cost/format.ts +78 -27
- package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
- package/src/components/gitops/GitOpsView.tsx +8 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
- package/src/components/helm/OwnedResources.tsx +10 -2
- package/src/components/home/ClusterHealthCard.test.ts +31 -0
- package/src/components/home/ClusterHealthCard.tsx +63 -1
- package/src/components/home/CostCard.tsx +12 -7
- package/src/components/home/HomeView.tsx +36 -11
- package/src/components/home/MCPSetupDialog.tsx +5 -4
- package/src/components/home/RadarVersionLine.test.tsx +145 -0
- package/src/components/home/RadarVersionLine.tsx +137 -0
- package/src/components/home/mcpToolCatalog.ts +12 -0
- package/src/components/nav/PrimaryNavRail.tsx +2 -2
- package/src/components/resources/PodFilesystemModal.tsx +54 -2
- package/src/components/resources/ResourcesView.tsx +31 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
- package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
- package/src/components/settings/SettingsDialog.tsx +181 -40
- package/src/components/settings/currency-options.test.ts +49 -0
- package/src/components/settings/currency-options.ts +38 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +40 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +18 -6
- package/src/components/ui/ErrorBoundary.test.tsx +55 -0
- package/src/components/ui/ErrorBoundary.tsx +17 -2
- package/src/components/ui/UpdateNotification.test.tsx +49 -0
- package/src/components/ui/UpdateNotification.tsx +6 -2
- package/src/components/ui/command-items.ts +4 -14
- package/src/components/workload/WorkloadView.test.ts +60 -0
- package/src/components/workload/WorkloadView.tsx +272 -30
- package/src/contexts/CapabilitiesContext.test.tsx +29 -0
- package/src/contexts/CapabilitiesContext.tsx +7 -3
- package/src/main.tsx +4 -114
- package/src/utils/context-name.test.ts +63 -0
- package/src/utils/context-name.ts +22 -0
- package/src/utils/navigation.test.ts +45 -0
- package/src/utils/navigation.ts +5 -5
- package/src/utils/topology-selection.ts +3 -2
- package/src/utils/version.test.ts +37 -0
- package/src/utils/version.ts +56 -0
- package/src/utils/wails-clipboard.test.ts +109 -0
- 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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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 }) =>
|
|
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
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
|
2295
|
+
export function fetchResourceWithRelationships<T>(
|
|
2197
2296
|
kind: string,
|
|
2198
2297
|
namespace: string,
|
|
2199
2298
|
name: string,
|
|
2200
2299
|
group?: string,
|
|
2201
|
-
|
|
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: "
|
|
4695
|
+
successMessage: "Abort sent",
|
|
4508
4696
|
},
|
|
4509
4697
|
retry: {
|
|
4510
4698
|
errorMessage: "Failed to retry rollout",
|
|
4511
|
-
successMessage: "
|
|
4699
|
+
successMessage: "Retry sent",
|
|
4512
4700
|
},
|
|
4513
4701
|
promote: {
|
|
4514
4702
|
errorMessage: "Failed to promote rollout",
|
|
4515
|
-
successMessage: "
|
|
4703
|
+
successMessage: "Promote sent",
|
|
4516
4704
|
},
|
|
4517
4705
|
"promote-full": {
|
|
4518
4706
|
errorMessage: "Failed to promote rollout",
|
|
4519
|
-
successMessage: "
|
|
4707
|
+
successMessage: "Promote full sent",
|
|
4520
4708
|
},
|
|
4521
4709
|
"skip-step": {
|
|
4522
4710
|
errorMessage: "Failed to skip step",
|
|
4523
|
-
successMessage: "
|
|
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
|
-
|
|
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
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
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
|
+
})
|