@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.
- package/package.json +6 -6
- package/src/App.tsx +35 -15
- package/src/api/client.images.test.ts +63 -0
- package/src/api/client.ts +246 -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 +21 -1
- package/src/components/ConnectionErrorView.tsx +7 -8
- 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 +19 -9
- package/src/components/cost/ApplicationCostTab.test.ts +45 -0
- package/src/components/cost/ApplicationCostTab.tsx +115 -64
- package/src/components/cost/CostTrendChart.test.ts +65 -0
- package/src/components/cost/CostTrendChart.tsx +107 -17
- package/src/components/cost/CostView.test.ts +36 -1
- package/src/components/cost/CostView.tsx +133 -51
- package/src/components/cost/CurrentAllocationUse.tsx +8 -4
- package/src/components/cost/WorkloadCostTab.test.ts +50 -0
- package/src/components/cost/WorkloadCostTab.tsx +109 -61
- package/src/components/cost/source.test.ts +33 -0
- package/src/components/cost/source.ts +100 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
- package/src/components/diagnose/parts.test.tsx +12 -4
- package/src/components/diagnose/parts.tsx +19 -15
- 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 +60 -1
- package/src/components/home/CostCard.tsx +3 -2
- 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/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 +36 -12
- package/src/components/rightsizing/copy.test.ts +19 -0
- package/src/components/settings/SettingsDialog.tsx +687 -107
- package/src/components/settings/settings-state.test.ts +42 -0
- package/src/components/settings/settings-state.ts +39 -0
- 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/workload/WorkloadView.test.ts +60 -0
- package/src/components/workload/WorkloadView.tsx +270 -29
- package/src/contexts/CapabilitiesContext.test.tsx +29 -0
- package/src/contexts/CapabilitiesContext.tsx +7 -3
- package/src/index.css +11 -1
- 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/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
|
|
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 (
|
|
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 =
|
|
914
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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
|
|
2327
|
+
export function fetchResourceWithRelationships<T>(
|
|
2223
2328
|
kind: string,
|
|
2224
2329
|
namespace: string,
|
|
2225
2330
|
name: string,
|
|
2226
2331
|
group?: string,
|
|
2227
|
-
|
|
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: "
|
|
4727
|
+
successMessage: "Abort sent",
|
|
4535
4728
|
},
|
|
4536
4729
|
retry: {
|
|
4537
4730
|
errorMessage: "Failed to retry rollout",
|
|
4538
|
-
successMessage: "
|
|
4731
|
+
successMessage: "Retry sent",
|
|
4539
4732
|
},
|
|
4540
4733
|
promote: {
|
|
4541
4734
|
errorMessage: "Failed to promote rollout",
|
|
4542
|
-
successMessage: "
|
|
4735
|
+
successMessage: "Promote sent",
|
|
4543
4736
|
},
|
|
4544
4737
|
"promote-full": {
|
|
4545
4738
|
errorMessage: "Failed to promote rollout",
|
|
4546
|
-
successMessage: "
|
|
4739
|
+
successMessage: "Promote full sent",
|
|
4547
4740
|
},
|
|
4548
4741
|
"skip-step": {
|
|
4549
4742
|
errorMessage: "Failed to skip step",
|
|
4550
|
-
successMessage: "
|
|
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
|
-
|
|
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
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
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
|
+
})
|