@skyhook-io/radar-app 1.16.2 → 1.17.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 +5 -5
- package/src/App.tsx +60 -12
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +168 -55
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +19 -1
- package/src/api/radarFeatures.ts +3 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
- package/src/components/capacity/CapacityView.test.tsx +31 -1
- package/src/components/capacity/shared.tsx +12 -21
- package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
- package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
- package/src/components/cnpg/CNPGOperator.tsx +234 -0
- package/src/components/cnpg/CNPGOverview.tsx +307 -0
- package/src/components/cnpg/CNPGPooling.tsx +116 -0
- package/src/components/cnpg/CNPGProtection.tsx +320 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
- package/src/components/cnpg/CNPGView.tsx +156 -0
- package/src/components/cnpg/paths.ts +13 -0
- package/src/components/cnpg/routes.test.ts +60 -0
- package/src/components/cnpg/routes.ts +112 -0
- package/src/components/cnpg/shared.tsx +290 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/helm/ChartBrowser.tsx +9 -10
- package/src/components/helm/HelmCompareRoute.tsx +108 -115
- package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
- package/src/components/helm/InstallWizard.tsx +5 -2
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/SettingsDialog.tsx +122 -19
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +60 -11
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +16 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/whats-new/WhatsNew.test.ts +107 -28
- package/src/components/whats-new/WhatsNew.tsx +146 -86
- package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
- package/src/components/whats-new/releaseNotes.ts +155 -24
- package/src/components/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +165 -294
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- package/src/hooks/useResourceCounts.ts +49 -0
- package/src/components/helm/RoleGatedPanel.tsx +0 -47
package/src/api/client.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { canonicalResourceGroup } from '@skyhook-io/k8s-ui/utils/api-resources'
|
|
|
2
2
|
import { knownKindForPluralWithGroup, pluralToKind } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
3
3
|
import { useEffect, useRef } from 'react'
|
|
4
4
|
import type { KueueAdmissionResponse } from '@skyhook-io/k8s-ui/types/scheduling'
|
|
5
|
+
import type { GitOpsWriteEvidence } from '@skyhook-io/k8s-ui/utils/gitops-write-guard'
|
|
5
6
|
import type {
|
|
6
7
|
AppHistory,
|
|
7
8
|
AppRow,
|
|
@@ -22,7 +23,7 @@ import type {
|
|
|
22
23
|
} from '@skyhook-io/k8s-ui'
|
|
23
24
|
import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
|
|
24
25
|
import { showApiError, showApiSuccess } from '../components/ui/Toast'
|
|
25
|
-
import {
|
|
26
|
+
import { useIsAuthEnabled, useNamespacedCapabilities } from '../contexts/CapabilitiesContext'
|
|
26
27
|
import type {
|
|
27
28
|
Topology,
|
|
28
29
|
ClusterInfo,
|
|
@@ -439,7 +440,7 @@ export interface DashboardResourceCounts {
|
|
|
439
440
|
ingresses: number;
|
|
440
441
|
gateways?: number;
|
|
441
442
|
routes?: number;
|
|
442
|
-
nodes: { total: number; ready: number; notReady: number; cordoned: number };
|
|
443
|
+
nodes: { total: number; ready: number; notReady: number; cordoned: number; removing?: number; removingUnhealthy?: number };
|
|
443
444
|
namespaces: number;
|
|
444
445
|
jobs: { total: number; active: number; succeeded: number; failed: number };
|
|
445
446
|
cronJobs: { total: number; active: number; suspended: number };
|
|
@@ -2256,6 +2257,9 @@ export interface AuthMe {
|
|
|
2256
2257
|
* When false, logout clears Radar's cookie but the proxy may re-auth
|
|
2257
2258
|
* the same user on the next request. */
|
|
2258
2259
|
proxyLogoutConfigured?: boolean;
|
|
2260
|
+
/** Connected and the user's access is known: true when their RBAC allows
|
|
2261
|
+
* reading no namespace at all. Absent when access couldn't be determined. */
|
|
2262
|
+
noNamespaceAccess?: boolean;
|
|
2259
2263
|
}
|
|
2260
2264
|
|
|
2261
2265
|
export function useAuthMe() {
|
|
@@ -2266,6 +2270,24 @@ export function useAuthMe() {
|
|
|
2266
2270
|
});
|
|
2267
2271
|
}
|
|
2268
2272
|
|
|
2273
|
+
/**
|
|
2274
|
+
* useNamespaceAccess asks the server to run namespace discovery for the caller,
|
|
2275
|
+
* so the answer never depends on whether the permission cache happens to hold
|
|
2276
|
+
* an entry. A user with no access is waiting on an admin; re-check every
|
|
2277
|
+
* minute so the banner clears once a binding lands.
|
|
2278
|
+
*/
|
|
2279
|
+
export function useNamespaceAccess(enabled: boolean) {
|
|
2280
|
+
return useQuery<AuthMe>({
|
|
2281
|
+
queryKey: ["namespace-access"],
|
|
2282
|
+
queryFn: () => fetchJSON("/auth/me?check=namespaces"),
|
|
2283
|
+
enabled,
|
|
2284
|
+
staleTime: 60000,
|
|
2285
|
+
// Stop only on an explicit false: an absent field means discovery couldn't
|
|
2286
|
+
// answer this time, which says nothing about the user's access.
|
|
2287
|
+
refetchInterval: (query) => (query.state.data?.noNamespaceAccess === false ? false : 60000),
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2269
2291
|
// Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
|
|
2270
2292
|
// CloudRole.AtLeast — the frontend must agree with the backend on what
|
|
2271
2293
|
// "member-or-higher" means; otherwise we'd hide a button the
|
|
@@ -2282,16 +2304,12 @@ const CLOUD_ROLE_RANK: Record<string, number> = {
|
|
|
2282
2304
|
* present (OSS, OIDC, no role group, OR auth/me is still loading),
|
|
2283
2305
|
* `canAtLeast` returns true — the gate is strictly additive for
|
|
2284
2306
|
* Cloud-attributed users, mirroring the backend's `requireCloudRole`
|
|
2285
|
-
* semantics.
|
|
2286
|
-
*
|
|
2287
|
-
* need fail-closed behavior during the auth/me round-trip to prevent
|
|
2288
|
-
* a viewer from clicking through during the loading window.
|
|
2307
|
+
* semantics. It gates Radar's own features (settings, integrations), never
|
|
2308
|
+
* operations that run as the user, which Kubernetes RBAC decides.
|
|
2289
2309
|
*
|
|
2290
|
-
* Why optimistic during load:
|
|
2291
|
-
*
|
|
2292
|
-
*
|
|
2293
|
-
* a content tab populate for a tick before being gated out. Click-
|
|
2294
|
-
* prevention belongs in the action-button hook, not here.
|
|
2310
|
+
* Why optimistic during load: a gated empty state rendered briefly to
|
|
2311
|
+
* OSS / kubectl-plugin users before auth/me resolves is a worse regression
|
|
2312
|
+
* than a Cloud viewer seeing content for a tick before being gated out.
|
|
2295
2313
|
*/
|
|
2296
2314
|
export function useCloudRole() {
|
|
2297
2315
|
const { data, isLoading } = useAuthMe();
|
|
@@ -2308,42 +2326,35 @@ export function useCloudRole() {
|
|
|
2308
2326
|
}
|
|
2309
2327
|
|
|
2310
2328
|
/**
|
|
2311
|
-
* useCanHelmAct
|
|
2312
|
-
*
|
|
2313
|
-
*
|
|
2314
|
-
*
|
|
2315
|
-
*
|
|
2316
|
-
*
|
|
2317
|
-
* users — telling them "Helm write permissions required" is wrong if
|
|
2318
|
-
* the chart is fine and the actual gate is their viewer role.
|
|
2329
|
+
* useCanHelmAct answers whether Helm write buttons are usable for a release in
|
|
2330
|
+
* `namespace`. With auth on, Helm runs as the signed-in user, so the check is
|
|
2331
|
+
* the user's own Kubernetes RBAC in that namespace (Helm writes each release as
|
|
2332
|
+
* a Secret there); the Cloud role says nothing about cluster access when IdP
|
|
2333
|
+
* groups grant it. Without auth, Helm runs as Radar's ServiceAccount, which
|
|
2334
|
+
* needs the chart's rbac.helm=true.
|
|
2319
2335
|
*/
|
|
2320
|
-
export function useCanHelmAct(): { allowed: boolean; reason?: string } {
|
|
2321
|
-
const
|
|
2322
|
-
const
|
|
2323
|
-
//
|
|
2324
|
-
//
|
|
2325
|
-
//
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
// canAtLeast (which is optimistic during loading) because passive
|
|
2329
|
-
// content gates don't have a click-handler to misfire.
|
|
2330
|
-
if (isLoading) {
|
|
2331
|
-
return { allowed: false, reason: "Loading permissions…" };
|
|
2336
|
+
export function useCanHelmAct(namespace?: string): { allowed: boolean; reason?: string } {
|
|
2337
|
+
const { canHelmWrite, helmWriteUnknown } = useNamespacedCapabilities(namespace);
|
|
2338
|
+
const authEnabled = useIsAuthEnabled();
|
|
2339
|
+
// Without a namespace answer (still loading, or the check failed) the global
|
|
2340
|
+
// value only says the user can create Secrets somewhere, which would enable
|
|
2341
|
+
// actions the server then refuses.
|
|
2342
|
+
if (authEnabled && helmWriteUnknown) {
|
|
2343
|
+
return { allowed: false, reason: `Couldn't confirm your permissions in ${namespace} yet.` };
|
|
2332
2344
|
}
|
|
2333
|
-
if (
|
|
2345
|
+
if (canHelmWrite) return { allowed: true };
|
|
2346
|
+
if (authEnabled) {
|
|
2334
2347
|
return {
|
|
2335
2348
|
allowed: false,
|
|
2336
|
-
reason:
|
|
2349
|
+
reason: namespace
|
|
2350
|
+
? `Your Kubernetes permissions don't allow managing Helm releases in ${namespace} (creating Secrets there).`
|
|
2351
|
+
: "Your Kubernetes permissions don't allow managing Helm releases.",
|
|
2337
2352
|
};
|
|
2338
2353
|
}
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
"Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.",
|
|
2344
|
-
};
|
|
2345
|
-
}
|
|
2346
|
-
return { allowed: true };
|
|
2354
|
+
return {
|
|
2355
|
+
allowed: false,
|
|
2356
|
+
reason: "Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.",
|
|
2357
|
+
};
|
|
2347
2358
|
}
|
|
2348
2359
|
|
|
2349
2360
|
// Namespaces
|
|
@@ -2498,6 +2509,24 @@ export function useGitOpsInsights(
|
|
|
2498
2509
|
});
|
|
2499
2510
|
}
|
|
2500
2511
|
|
|
2512
|
+
// Field-level evidence behind the GitOps write guard: whether each path is in
|
|
2513
|
+
// the object's last client-side apply or owned by its GitOps controller, plus
|
|
2514
|
+
// the owner's sync policy. Read server-side because Radar's caches strip both.
|
|
2515
|
+
export function fetchGitOpsWriteEvidence(body: {
|
|
2516
|
+
kind: string;
|
|
2517
|
+
group: string;
|
|
2518
|
+
namespace: string;
|
|
2519
|
+
name: string;
|
|
2520
|
+
paths: string[];
|
|
2521
|
+
owner?: { kind: string; group: string; namespace: string; name: string };
|
|
2522
|
+
}): Promise<GitOpsWriteEvidence> {
|
|
2523
|
+
return fetchJSON<GitOpsWriteEvidence>("/gitops/write-evidence", {
|
|
2524
|
+
method: "POST",
|
|
2525
|
+
headers: { "Content-Type": "application/json" },
|
|
2526
|
+
body: JSON.stringify(body),
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2501
2530
|
// Full Git-rendered desired-vs-live diff for one Argo CD managed resource.
|
|
2502
2531
|
// ns/name identify the Application; the ref identifies the managed resource.
|
|
2503
2532
|
// Fetched on demand — the caller mounts this only when the user opens "Full
|
|
@@ -2587,18 +2616,55 @@ export function fetchResourceWithRelationships<T>(
|
|
|
2587
2616
|
);
|
|
2588
2617
|
}
|
|
2589
2618
|
|
|
2619
|
+
/**
|
|
2620
|
+
* How often an open detail view re-checks a resource the server said is gone.
|
|
2621
|
+
* Watch events stop refetching it (see refetchOnResourceEvents), so this is
|
|
2622
|
+
* what finds one recreated under the same name when no event names it, such
|
|
2623
|
+
* as a ConfigMap: the server streams adds only for pods.
|
|
2624
|
+
*/
|
|
2625
|
+
const GONE_RECHECK_MS = 60_000;
|
|
2626
|
+
|
|
2627
|
+
export function goneRecheckInterval(
|
|
2628
|
+
query: { state: { error: unknown } },
|
|
2629
|
+
otherwise: number | false | undefined,
|
|
2630
|
+
): number | false | undefined {
|
|
2631
|
+
return isNotFoundError(query.state.error) ? GONE_RECHECK_MS : otherwise;
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
export function resourceEventKey(kind: string, namespace: string, name: string): string {
|
|
2635
|
+
return `${kind}/${namespace}/${name}`;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
/**
|
|
2639
|
+
* Whether a batch of watch events for `kind` refetches this ['resource', kind,
|
|
2640
|
+
* namespace, name, group] query. One the server answered 404 for is refetched
|
|
2641
|
+
* only when an event in the batch names its object; otherwise every change to
|
|
2642
|
+
* the kind would ask again for something that is gone.
|
|
2643
|
+
*/
|
|
2644
|
+
export function refetchOnResourceEvents(
|
|
2645
|
+
query: { queryKey: readonly unknown[]; state: { error: unknown } },
|
|
2646
|
+
kind: string,
|
|
2647
|
+
namedObjects: ReadonlySet<string>,
|
|
2648
|
+
): boolean {
|
|
2649
|
+
if (!isNotFoundError(query.state.error)) return true;
|
|
2650
|
+
const [, , namespace, name] = query.queryKey;
|
|
2651
|
+
return namedObjects.has(resourceEventKey(kind, String(namespace ?? ""), String(name ?? "")));
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// `cacheScope` keeps a caller's copy apart from the shared one, for hosts
|
|
2655
|
+
// that point one QueryClient at several clusters (see useGitOpsWriteGuard).
|
|
2590
2656
|
export function useResource<T>(
|
|
2591
2657
|
kind: string,
|
|
2592
2658
|
namespace: string,
|
|
2593
2659
|
name: string,
|
|
2594
2660
|
group?: string,
|
|
2595
|
-
options?: { enabled?: boolean; refetchInterval?: number | false },
|
|
2661
|
+
options?: { enabled?: boolean; refetchInterval?: number | false; cacheScope?: string },
|
|
2596
2662
|
) {
|
|
2597
2663
|
const query = useQuery<ResourceWithRelationships<T>>({
|
|
2598
|
-
queryKey: ["resource", kind, namespace, name, group],
|
|
2664
|
+
queryKey: ["resource", kind, namespace, name, group, ...(options?.cacheScope ? [options.cacheScope] : [])],
|
|
2599
2665
|
queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
|
|
2600
2666
|
enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
|
|
2601
|
-
refetchInterval: options?.refetchInterval,
|
|
2667
|
+
refetchInterval: (query) => goneRecheckInterval(query, options?.refetchInterval),
|
|
2602
2668
|
// Kind still completing its initial sync: stay in loading and poll until
|
|
2603
2669
|
// it becomes readable instead of erroring out (deep links during startup).
|
|
2604
2670
|
retry: (failureCount, error) => {
|
|
@@ -2626,11 +2692,13 @@ export function useResourceWithRelationships<T>(
|
|
|
2626
2692
|
namespace: string,
|
|
2627
2693
|
name: string,
|
|
2628
2694
|
group?: string,
|
|
2695
|
+
options?: { cacheScope?: string },
|
|
2629
2696
|
) {
|
|
2630
2697
|
return useQuery<ResourceWithRelationships<T>>({
|
|
2631
|
-
queryKey: ["resource", kind, namespace, name, group],
|
|
2698
|
+
queryKey: ["resource", kind, namespace, name, group, ...(options?.cacheScope ? [options.cacheScope] : [])],
|
|
2632
2699
|
queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
|
|
2633
2700
|
enabled: Boolean(kind && name),
|
|
2701
|
+
refetchInterval: (query) => goneRecheckInterval(query, undefined),
|
|
2634
2702
|
// Deep-linked detail views can mount while the kind's informer is still
|
|
2635
2703
|
// completing its initial sync: keep polling instead of erroring out.
|
|
2636
2704
|
retry: (failureCount, error) => {
|
|
@@ -2648,7 +2716,7 @@ export function useResources<T>(
|
|
|
2648
2716
|
kind: string,
|
|
2649
2717
|
namespace?: string,
|
|
2650
2718
|
group?: string,
|
|
2651
|
-
options?: { enabled?: boolean; refetchInterval?: number | false },
|
|
2719
|
+
options?: { enabled?: boolean; refetchInterval?: number | false; cacheScope?: string },
|
|
2652
2720
|
) {
|
|
2653
2721
|
const params = new URLSearchParams();
|
|
2654
2722
|
if (namespace) params.set("namespace", namespace);
|
|
@@ -2656,7 +2724,7 @@ export function useResources<T>(
|
|
|
2656
2724
|
const queryString = params.toString();
|
|
2657
2725
|
|
|
2658
2726
|
return useQuery<T[]>({
|
|
2659
|
-
queryKey: ["resources", kind, group, namespace],
|
|
2727
|
+
queryKey: ["resources", kind, group, namespace, ...(options?.cacheScope ? [options.cacheScope] : [])],
|
|
2660
2728
|
queryFn: () =>
|
|
2661
2729
|
fetchJSON(`/resources/${kind}${queryString ? `?${queryString}` : ""}`),
|
|
2662
2730
|
enabled: (options?.enabled ?? true) && Boolean(kind),
|
|
@@ -2971,6 +3039,38 @@ export function useWorkloadHistory(kind: string, namespace: string, name: string
|
|
|
2971
3039
|
return query;
|
|
2972
3040
|
}
|
|
2973
3041
|
|
|
3042
|
+
export interface ServiceEndpointSlicesResult {
|
|
3043
|
+
items: any[];
|
|
3044
|
+
/** More slices are labeled for the Service than the server returns in one page. */
|
|
3045
|
+
truncated: boolean;
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
// The EndpointSlices published for one Service, selected server-side by the
|
|
3049
|
+
// kubernetes.io/service-name label. A Radar that predates the endpoint settles
|
|
3050
|
+
// as unsupported; ServiceRenderer then falls back to the namespace list.
|
|
3051
|
+
export function useServiceEndpointSlices(namespace: string, name: string, enabled = true) {
|
|
3052
|
+
const { guard, gatedKey, support } = useRadarFeature("serviceEndpointSlices");
|
|
3053
|
+
const query = useQuery<ServiceEndpointSlicesResult>({
|
|
3054
|
+
queryKey: ["service-endpointslices", namespace, name, ...gatedKey],
|
|
3055
|
+
queryFn: ({ signal }) =>
|
|
3056
|
+
guard(() =>
|
|
3057
|
+
fetchJSON<ServiceEndpointSlicesResult>(
|
|
3058
|
+
`/services/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/endpointslices`,
|
|
3059
|
+
signal,
|
|
3060
|
+
),
|
|
3061
|
+
),
|
|
3062
|
+
retry: shouldRetryRadarQuery,
|
|
3063
|
+
refetchInterval: (query) =>
|
|
3064
|
+
query.state.error instanceof RadarFeatureUnsupportedError ? false : 30000,
|
|
3065
|
+
enabled,
|
|
3066
|
+
});
|
|
3067
|
+
const { error, refetch } = query;
|
|
3068
|
+
useEffect(() => {
|
|
3069
|
+
if (support === "supported" && error instanceof RadarFeatureUnsupportedError) void refetch();
|
|
3070
|
+
}, [support, error, refetch]);
|
|
3071
|
+
return query;
|
|
3072
|
+
}
|
|
3073
|
+
|
|
2974
3074
|
export function fetchWorkloadHistoryPage(kind: string, namespace: string, name: string, group: string | undefined, beforeSeq: number): Promise<WorkloadHistoryPage> {
|
|
2975
3075
|
return fetchJSON(workloadHistoryPath(kind, namespace, name, group, beforeSeq));
|
|
2976
3076
|
}
|
|
@@ -4092,7 +4192,7 @@ export function useUpdateResource() {
|
|
|
4092
4192
|
});
|
|
4093
4193
|
if (!response.ok) {
|
|
4094
4194
|
const error = await readErrorBody(response);
|
|
4095
|
-
const failure = new
|
|
4195
|
+
const failure = new ApiError(error.error || `HTTP ${response.status}`, response.status, error);
|
|
4096
4196
|
// A reviewed apply's failure is shown in the review itself (the
|
|
4097
4197
|
// changed-after-review notice, or the error), so it gets no toast.
|
|
4098
4198
|
if (reviewedResourceVersion !== undefined || reviewedContext !== undefined) {
|
|
@@ -5322,6 +5422,23 @@ export function useDrainPlan() {
|
|
|
5322
5422
|
});
|
|
5323
5423
|
}
|
|
5324
5424
|
|
|
5425
|
+
export function useNodeRemovalPlan(name: string | undefined, enabled: boolean) {
|
|
5426
|
+
const { guard, gatedKey } = useRadarFeature('drainPlan')
|
|
5427
|
+
return useQuery<DrainPlan>({
|
|
5428
|
+
queryKey: ['resources', 'node-removal-plan', name, ...gatedKey],
|
|
5429
|
+
queryFn: ({ signal }) => guard(() => fetchJSON<DrainPlan>(drainPlanPath(name!), {
|
|
5430
|
+
method: 'POST',
|
|
5431
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5432
|
+
body: drainPlanBody({ force: true, deleteEmptyDirData: true }),
|
|
5433
|
+
signal,
|
|
5434
|
+
})),
|
|
5435
|
+
enabled: enabled && Boolean(name),
|
|
5436
|
+
staleTime: ISSUES_REFRESH_INTERVAL_MS,
|
|
5437
|
+
refetchInterval: (query) => query.state.error instanceof RadarFeatureUnsupportedError ? false : ISSUES_REFRESH_INTERVAL_MS,
|
|
5438
|
+
retry: shouldRetryRadarQuery,
|
|
5439
|
+
})
|
|
5440
|
+
}
|
|
5441
|
+
|
|
5325
5442
|
export function useDrainNode() {
|
|
5326
5443
|
const queryClient = useQueryClient();
|
|
5327
5444
|
|
|
@@ -5457,14 +5574,10 @@ export function useHelmRelease(
|
|
|
5457
5574
|
}
|
|
5458
5575
|
|
|
5459
5576
|
// Get manifest for a Helm release (optionally at a specific revision).
|
|
5460
|
-
// `enabled` lets callers skip the query when the user's Cloud role
|
|
5461
|
-
// would 403 the read — saves a round-trip and avoids a transient
|
|
5462
|
-
// "error" state that the role-gated empty panel doesn't need.
|
|
5463
5577
|
export function useHelmManifest(
|
|
5464
5578
|
namespace: string,
|
|
5465
5579
|
name: string,
|
|
5466
5580
|
revision?: number,
|
|
5467
|
-
enabled = true,
|
|
5468
5581
|
) {
|
|
5469
5582
|
const params = revision ? `?revision=${revision}` : "";
|
|
5470
5583
|
return useQuery<string>({
|
|
@@ -5479,12 +5592,12 @@ export function useHelmManifest(
|
|
|
5479
5592
|
}
|
|
5480
5593
|
return response.text();
|
|
5481
5594
|
},
|
|
5482
|
-
enabled: Boolean(namespace && name
|
|
5595
|
+
enabled: Boolean(namespace && name),
|
|
5483
5596
|
staleTime: 60000, // 1 minute
|
|
5484
5597
|
});
|
|
5485
5598
|
}
|
|
5486
5599
|
|
|
5487
|
-
// Get values for a Helm release.
|
|
5600
|
+
// Get values for a Helm release.
|
|
5488
5601
|
export function useHelmValues(
|
|
5489
5602
|
namespace: string,
|
|
5490
5603
|
name: string,
|
|
@@ -5505,7 +5618,7 @@ export function useHelmValues(
|
|
|
5505
5618
|
});
|
|
5506
5619
|
}
|
|
5507
5620
|
|
|
5508
|
-
// Get diff between two revisions.
|
|
5621
|
+
// Get diff between two revisions.
|
|
5509
5622
|
export function useHelmManifestDiff(
|
|
5510
5623
|
namespace: string,
|
|
5511
5624
|
name: string,
|
package/src/api/cnpg.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import type { CNPGWorkspaceResponse, TimelineEvent } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { fetchJSON } from './client'
|
|
4
|
+
|
|
5
|
+
// /api/cnpg/workspace
|
|
6
|
+
//
|
|
7
|
+
// Every CloudNativePG object the caller may read, with per-kind coverage, the
|
|
8
|
+
// CNPG issues on them and the no-schedule audit finding. One query feeds the
|
|
9
|
+
// workspace screens, the Resources sidebar counts and the composed summaries,
|
|
10
|
+
// so they can never disagree about what they count.
|
|
11
|
+
export function useCNPGWorkspace(namespaces: string[], options?: { enabled?: boolean }) {
|
|
12
|
+
const ns = [...namespaces].sort().join(',')
|
|
13
|
+
return useQuery<CNPGWorkspaceResponse>({
|
|
14
|
+
queryKey: ['cnpg', 'workspace', ns],
|
|
15
|
+
queryFn: ({ signal }) => fetchJSON<CNPGWorkspaceResponse>(`/cnpg/workspace${ns ? `?namespaces=${encodeURIComponent(ns)}` : ''}`, signal),
|
|
16
|
+
enabled: options?.enabled ?? true,
|
|
17
|
+
staleTime: 10_000,
|
|
18
|
+
refetchInterval: 30_000,
|
|
19
|
+
placeholderData: (prev) => prev,
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CNPGOperatorCoverage {
|
|
24
|
+
state: 'full' | 'partial' | 'denied' | 'syncing' | 'error'
|
|
25
|
+
deniedNamespaces?: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CNPGOperatorComponent {
|
|
29
|
+
role: 'operator' | 'plugin'
|
|
30
|
+
pluginName?: string
|
|
31
|
+
namespace: string
|
|
32
|
+
deployment: string
|
|
33
|
+
image?: string
|
|
34
|
+
version?: string
|
|
35
|
+
readyReplicas: number | null
|
|
36
|
+
replicas: number | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CNPGOperatorConfig {
|
|
40
|
+
kind: 'ConfigMap' | 'Secret'
|
|
41
|
+
namespace: string
|
|
42
|
+
name: string
|
|
43
|
+
purpose: 'operator' | 'monitoring'
|
|
44
|
+
exists?: boolean | null
|
|
45
|
+
readable?: boolean
|
|
46
|
+
reason?: string
|
|
47
|
+
data?: Record<string, string>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CNPGOperatorResponse {
|
|
51
|
+
coverage: { deployments: CNPGOperatorCoverage; services: CNPGOperatorCoverage }
|
|
52
|
+
components: CNPGOperatorComponent[]
|
|
53
|
+
config: CNPGOperatorConfig[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// /api/cnpg/operator
|
|
57
|
+
//
|
|
58
|
+
// Operator and plugin workloads plus where the operator's configuration lives.
|
|
59
|
+
// Deliberately not filtered by the namespace view filter: the operator runs in
|
|
60
|
+
// its own namespace, which users rarely have selected.
|
|
61
|
+
export function useCNPGOperator(options?: { enabled?: boolean }) {
|
|
62
|
+
return useQuery<CNPGOperatorResponse>({
|
|
63
|
+
queryKey: ['cnpg', 'operator'],
|
|
64
|
+
queryFn: ({ signal }) => fetchJSON<CNPGOperatorResponse>('/cnpg/operator', signal),
|
|
65
|
+
enabled: options?.enabled ?? true,
|
|
66
|
+
staleTime: 30_000,
|
|
67
|
+
refetchInterval: 60_000,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CNPGClusterActivityResponse {
|
|
72
|
+
events: TimelineEvent[]
|
|
73
|
+
oldest: string | null
|
|
74
|
+
attributionSince: string | null
|
|
75
|
+
truncated: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// /api/cnpg/clusters/{ns}/{name}/activity
|
|
79
|
+
//
|
|
80
|
+
// The Cluster's history together with its instance Pods and every CNPG object
|
|
81
|
+
// attributed to it, including ones since deleted.
|
|
82
|
+
export function useCNPGClusterActivity(namespace: string, name: string, sinceHours = 24) {
|
|
83
|
+
return useQuery<CNPGClusterActivityResponse>({
|
|
84
|
+
queryKey: ['cnpg', 'activity', namespace, name, sinceHours],
|
|
85
|
+
queryFn: ({ signal }) => {
|
|
86
|
+
const since = new Date(Date.now() - sinceHours * 3600_000).toISOString()
|
|
87
|
+
return fetchJSON<CNPGClusterActivityResponse>(
|
|
88
|
+
`/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/activity?since=${encodeURIComponent(since)}&limit=500`,
|
|
89
|
+
signal,
|
|
90
|
+
)
|
|
91
|
+
},
|
|
92
|
+
staleTime: 10_000,
|
|
93
|
+
refetchInterval: 30_000,
|
|
94
|
+
})
|
|
95
|
+
}
|
package/src/api/diagnose.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
addTurn,
|
|
4
4
|
createRun,
|
|
5
5
|
DiagnoseError,
|
|
6
|
+
fetchAgents,
|
|
6
7
|
subscribeRun,
|
|
7
8
|
type DiagnoseStreamEvent,
|
|
8
9
|
} from "./diagnose";
|
|
@@ -367,3 +368,42 @@ describe("refused requests", () => {
|
|
|
367
368
|
expect((await startError()).message).toBe(message);
|
|
368
369
|
});
|
|
369
370
|
});
|
|
371
|
+
|
|
372
|
+
describe("fetchAgents agents normalisation", () => {
|
|
373
|
+
const withFetch = async (body: unknown) => {
|
|
374
|
+
const original = globalThis.fetch;
|
|
375
|
+
globalThis.fetch = (async () =>
|
|
376
|
+
new Response(JSON.stringify(body), {
|
|
377
|
+
status: 200,
|
|
378
|
+
headers: { "content-type": "application/json" },
|
|
379
|
+
})) as typeof fetch;
|
|
380
|
+
try {
|
|
381
|
+
return await fetchAgents();
|
|
382
|
+
} finally {
|
|
383
|
+
globalThis.fetch = original;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
it("turns the server's null agent list into an empty array", async () => {
|
|
388
|
+
// Older servers send null for a machine with no CLI installed, the case
|
|
389
|
+
// callers most need to survive.
|
|
390
|
+
const r = await withFetch({
|
|
391
|
+
agents: null,
|
|
392
|
+
enabled: false,
|
|
393
|
+
eligible: true,
|
|
394
|
+
consented: {},
|
|
395
|
+
});
|
|
396
|
+
expect(r.agents).toEqual([]);
|
|
397
|
+
expect(r.eligible).toBe(true);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("leaves a populated list alone", async () => {
|
|
401
|
+
const r = await withFetch({
|
|
402
|
+
agents: [{ name: "claude", label: "Claude Code", supported: true }],
|
|
403
|
+
enabled: true,
|
|
404
|
+
eligible: true,
|
|
405
|
+
consented: {},
|
|
406
|
+
});
|
|
407
|
+
expect(r.agents.map((a) => a.name)).toEqual(["claude"]);
|
|
408
|
+
});
|
|
409
|
+
});
|
package/src/api/diagnose.ts
CHANGED
|
@@ -29,6 +29,10 @@ export interface AgentsResponse {
|
|
|
29
29
|
// "install an agent to enable this" (eligible && !enabled) apart from "not
|
|
30
30
|
// available here" (auth/cloud/--no-mcp). Absent on older servers / embed hosts.
|
|
31
31
|
eligible?: boolean;
|
|
32
|
+
// cliOverride: RADAR_AI_CLI_BIN is set, pinning the engine to one CLI. With
|
|
33
|
+
// CLIs detected and investigations still off, that variable is what's broken.
|
|
34
|
+
// Absent on older servers and embed hosts.
|
|
35
|
+
cliOverride?: boolean;
|
|
32
36
|
// Machine-scoped consent per disclosure surface, recorded server-side
|
|
33
37
|
// (~/.radar) — one acknowledgment covers the web panel and the CLI.
|
|
34
38
|
consented?: Record<string, boolean>;
|
|
@@ -257,7 +261,14 @@ export async function fetchAgents(
|
|
|
257
261
|
signal,
|
|
258
262
|
});
|
|
259
263
|
if (!res.ok) throw new Error(`agents: ${res.status}`);
|
|
260
|
-
|
|
264
|
+
const body = (await res.json()) as AgentsResponse & {
|
|
265
|
+
agents: AgentInfo[] | null;
|
|
266
|
+
};
|
|
267
|
+
// Older Radar servers send "no agent found" as JSON null, not []. This client
|
|
268
|
+
// also runs against servers of other versions, and that is the single most
|
|
269
|
+
// common response on a machine with no CLI, so normalise here rather than in
|
|
270
|
+
// every caller that reaches for .filter.
|
|
271
|
+
return { ...body, agents: body.agents ?? [] };
|
|
261
272
|
}
|
|
262
273
|
|
|
263
274
|
/**
|
|
@@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client'
|
|
|
4
4
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
5
5
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
6
6
|
import { getRadarUpgradeRequirement } from '@skyhook-io/k8s-ui'
|
|
7
|
-
import { ApiError, isNotFoundError, useApplications, useCapacityPoolDetail, useDrainPlan, usePodEnvironment, useWorkloadHistory } from './client'
|
|
7
|
+
import { ApiError, isNotFoundError, useApplications, useCapacityPoolDetail, useDrainPlan, useNodeRemovalPlan, usePodEnvironment, useWorkloadHistory } from './client'
|
|
8
8
|
import { getApiBase } from './config'
|
|
9
9
|
import { usePolicyResource } from './policy'
|
|
10
10
|
import { isRadarFeatureUnsupported } from './radarFeatures'
|
|
@@ -153,6 +153,24 @@ function DrainPlanProbe() {
|
|
|
153
153
|
return null
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
function RemovalPlanProbe() {
|
|
157
|
+
const { error } = useNodeRemovalPlan('worker-1', true)
|
|
158
|
+
return <span>{getRadarUpgradeRequirement(error) ? 'upgrade' : 'pending'}</span>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
it('does not poll an old Radar for removal details', async () => {
|
|
162
|
+
host.radarVersion = 'v1.13.1'
|
|
163
|
+
await render(<RemovalPlanProbe />)
|
|
164
|
+
expect(element.textContent).toBe('upgrade')
|
|
165
|
+
expect(asked('/drain-plan')).toBe(false)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('requests removal details once the drain-plan feature is supported', async () => {
|
|
169
|
+
client.setQueryData(['capabilities'], { features: { resourceIssues: true } })
|
|
170
|
+
await render(<RemovalPlanProbe />)
|
|
171
|
+
expect(asked('/drain-plan')).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
|
|
156
174
|
it('never asks a Radar older than v1.14 for a drain plan', async () => {
|
|
157
175
|
host.radarVersion = 'v1.13.1'
|
|
158
176
|
await render(<DrainPlanProbe />)
|
package/src/api/radarFeatures.ts
CHANGED
|
@@ -35,6 +35,9 @@ export const RADAR_FEATURES = {
|
|
|
35
35
|
drainPlan: { label: 'Drain plans', minimumVersion: 'v1.14.0' },
|
|
36
36
|
applications: { label: 'Applications', minimumVersion: 'v1.7.7' },
|
|
37
37
|
workloadHistory: { label: 'Workload history', flag: 'workloadHistory', flagShippedWithEndpoint: true },
|
|
38
|
+
serviceEndpointSlices: { label: 'Service EndpointSlice inventories', flag: 'serviceEndpointSlices', flagShippedWithEndpoint: true },
|
|
39
|
+
trafficRecords: { label: 'Flow records for a graph selection', flag: 'trafficRecords', flagShippedWithEndpoint: true },
|
|
40
|
+
gitopsWriteEvidence: { label: 'GitOps revert warnings', flag: 'gitopsWriteEvidence', flagShippedWithEndpoint: true },
|
|
38
41
|
} as const satisfies Record<string, RadarFeatureSpec>
|
|
39
42
|
|
|
40
43
|
export type RadarFeature = keyof typeof RADAR_FEATURES
|
|
@@ -423,7 +423,7 @@ export async function fetchRetainedDelta(
|
|
|
423
423
|
// Mirrors the Go store's TimelineEvent.IsManaged (pkg/timeline/types.go):
|
|
424
424
|
// a resource managed by another — owned, or one of the churn kinds. Keep the
|
|
425
425
|
// two predicates in lockstep.
|
|
426
|
-
function isManagedTimelineEvent(e: TimelineEvent): boolean {
|
|
426
|
+
export function isManagedTimelineEvent(e: TimelineEvent): boolean {
|
|
427
427
|
return e.owner != null || e.kind === 'ReplicaSet' || e.kind === 'Pod' || e.kind === 'Event'
|
|
428
428
|
}
|
|
429
429
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { detectTrafficSourcesAgain, fetchTrafficSources } from './traffic'
|
|
4
|
+
|
|
5
|
+
const found = { detected: [{ name: 'hubble', status: 'available' }], notDetected: [] }
|
|
6
|
+
const none = { detected: [], notDetected: [{ name: 'hubble', status: 'not_found' }] }
|
|
7
|
+
|
|
8
|
+
function serve(responses: Record<string, unknown>, delays: Record<string, number> = {}) {
|
|
9
|
+
const calls: string[] = []
|
|
10
|
+
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
|
11
|
+
const path = url.slice(url.indexOf('/traffic/'))
|
|
12
|
+
calls.push(path)
|
|
13
|
+
if (delays[path]) {
|
|
14
|
+
await new Promise((resolve, reject) => {
|
|
15
|
+
const timer = setTimeout(resolve, delays[path])
|
|
16
|
+
init?.signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('aborted', 'AbortError')) })
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
return new Response(JSON.stringify(responses[path]), { status: 200 })
|
|
20
|
+
}))
|
|
21
|
+
return calls
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
25
|
+
|
|
26
|
+
describe('fetchTrafficSources', () => {
|
|
27
|
+
it('reuses a recent detection that found a source', async () => {
|
|
28
|
+
const calls = serve({ '/traffic/sources?recent=1': found })
|
|
29
|
+
const sources = await fetchTrafficSources(new QueryClient(), false)
|
|
30
|
+
expect(sources).toEqual(found)
|
|
31
|
+
expect(calls).toEqual(['/traffic/sources?recent=1'])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('detects afresh before showing a recent detection that found none', async () => {
|
|
35
|
+
const calls = serve({ '/traffic/sources?recent=1': none, '/traffic/sources': found })
|
|
36
|
+
const sources = await fetchTrafficSources(new QueryClient(), false)
|
|
37
|
+
expect(sources).toEqual(found)
|
|
38
|
+
expect(calls).toEqual(['/traffic/sources?recent=1', '/traffic/sources'])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('detectTrafficSourcesAgain', () => {
|
|
43
|
+
it('detects afresh even while a recent detection is being fetched', async () => {
|
|
44
|
+
const calls = serve({ '/traffic/sources?recent=1': found, '/traffic/sources': none }, { '/traffic/sources?recent=1': 50 })
|
|
45
|
+
const client = new QueryClient()
|
|
46
|
+
void client.fetchQuery({ queryKey: ['traffic-sources'], queryFn: ({ signal }) => fetchTrafficSources(client, false, signal) }).catch(() => {})
|
|
47
|
+
const sources = await detectTrafficSourcesAgain(client)
|
|
48
|
+
expect(sources).toEqual(none)
|
|
49
|
+
expect(calls).toContain('/traffic/sources')
|
|
50
|
+
// The abandoned recent detection must not land afterwards.
|
|
51
|
+
await new Promise(resolve => setTimeout(resolve, 80))
|
|
52
|
+
expect(client.getQueryData(['traffic-sources'])).toEqual(none)
|
|
53
|
+
expect(client.getQueryData(['traffic-sources', 'recent'])).toEqual(none)
|
|
54
|
+
})
|
|
55
|
+
})
|