@skyhook-io/radar-app 1.13.5 → 1.14.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 +8 -8
- package/src/App.tsx +60 -24
- package/src/api/client.authRedirect.test.ts +12 -1
- package/src/api/client.prometheusStatus.test.ts +24 -0
- package/src/api/client.ts +321 -16
- package/src/api/diagnose.ts +1 -0
- package/src/api/drain-plan.test.ts +86 -0
- package/src/api/quotas.test.ts +26 -0
- package/src/api/quotas.ts +20 -0
- package/src/api/workloadMetrics.test.ts +40 -0
- package/src/api/workloadMetrics.ts +68 -0
- package/src/components/CloudConnectFlow.tsx +179 -42
- package/src/components/CloudFunnelButton.tsx +107 -36
- package/src/components/SyncProgressPanel.tsx +73 -0
- package/src/components/audit/AuditView.tsx +2 -0
- package/src/components/cloudConnectHandoff.test.ts +140 -0
- package/src/components/cloudConnectHandoff.ts +124 -0
- package/src/components/diagnose/DiagnoseContext.tsx +2 -1
- package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
- package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
- package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
- package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
- package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
- package/src/components/diagnose/investigationEvidence/index.ts +55 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
- package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
- package/src/components/diagnose/investigationEvidence/types.ts +512 -0
- package/src/components/diagnose/target.test.ts +19 -0
- package/src/components/diagnose/target.ts +3 -2
- package/src/components/gitops/GitOpsView.tsx +7 -0
- package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
- package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
- package/src/components/resource/PrometheusCharts.tsx +4 -3
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +75 -35
- package/src/components/resource/RightsizingStrip.test.ts +38 -0
- package/src/components/resource/RightsizingStrip.tsx +15 -3
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
- package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
- package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
- package/src/components/resource/workloadMetricValues.test.ts +65 -0
- package/src/components/resource/workloadMetricValues.ts +30 -0
- package/src/components/resources/ResourcesView.tsx +44 -6
- package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
- package/src/components/resources/renderers/PodRenderer.tsx +4 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
- package/src/components/settings/SettingsDialog.tsx +94 -14
- package/src/components/traffic/TrafficFlowList.tsx +229 -70
- package/src/components/traffic/TrafficGraph.tsx +6 -6
- package/src/components/traffic/TrafficView.tsx +10 -9
- package/src/components/traffic/trafficFilters.test.ts +28 -1
- package/src/components/traffic/trafficFilters.ts +31 -0
- package/src/components/workload/WorkloadView.tsx +19 -8
- package/src/context/ConnectionContext.tsx +48 -3
- package/src/context/DiagnoseCustomization.tsx +1 -0
- package/src/index.css +17 -0
- package/src/utils/navigation.ts +1 -1
- package/src/utils/topology-namespace.test.ts +64 -0
- package/src/utils/topology-namespace.ts +18 -0
- package/src/components/diagnose/investigationEvidence.test.ts +0 -6617
- package/src/components/diagnose/investigationEvidence.ts +0 -5572
- package/src/components/gitops/GitOpsActions.tsx +0 -1
- package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
- package/src/components/gitops/ManagedResourcesList.tsx +0 -1
- package/src/components/gitops/SyncCountdown.tsx +0 -1
- package/src/components/gitops/index.ts +0 -4
- package/src/components/helm/ManifestDiffViewer.tsx +0 -69
- package/src/components/logs/JsonLogLine.tsx +0 -1
- package/src/components/logs/LogCore.tsx +0 -2
- package/src/components/logs/useLogBuffer.ts +0 -2
- package/src/components/logs/useLogSearch.ts +0 -1
- package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
- package/src/components/resources/drawer-components.tsx +0 -1
- package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
- package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
- package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
- package/src/components/resources/renderers/EventRenderer.tsx +0 -1
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
- package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
- package/src/components/resources/renderers/JobRenderer.tsx +0 -1
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
- package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/VPARenderer.tsx +0 -1
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
- package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
- package/src/components/resources/renderers/argo-cells.tsx +0 -1
- package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
- package/src/components/resources/renderers/eso-cells.tsx +0 -1
- package/src/components/resources/renderers/flux-cells.tsx +0 -1
- package/src/components/resources/renderers/index.ts +0 -94
- package/src/components/resources/renderers/istio-cells.tsx +0 -1
- package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
- package/src/components/resources/renderers/keda-cells.tsx +0 -1
- package/src/components/resources/renderers/knative-cells.tsx +0 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
- package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
- package/src/components/resources/renderers/traefik-cells.tsx +0 -1
- package/src/components/resources/renderers/trivy-cells.tsx +0 -1
- package/src/components/resources/renderers/trivy-shared.tsx +0 -1
- package/src/components/resources/renderers/velero-cells.tsx +0 -1
- package/src/components/resources/resource-utils-argo.ts +0 -2
- package/src/components/resources/resource-utils-certmanager.ts +0 -2
- package/src/components/resources/resource-utils-eso.ts +0 -2
- package/src/components/resources/resource-utils-flux.ts +0 -2
- package/src/components/resources/resource-utils-istio.ts +0 -2
- package/src/components/resources/resource-utils-karpenter.ts +0 -2
- package/src/components/resources/resource-utils-knative.ts +0 -2
- package/src/components/resources/resource-utils-kyverno.ts +0 -2
- package/src/components/resources/resource-utils-prometheus.ts +0 -2
- package/src/components/resources/resource-utils-traefik.ts +0 -1
- package/src/components/resources/resource-utils-trivy.ts +0 -2
- package/src/components/resources/resource-utils-velero.ts +0 -2
- package/src/components/shared/EditableYamlView.tsx +0 -24
- package/src/components/timeline/DiffViewer.tsx +0 -1
- package/src/components/traffic/index.ts +0 -3
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
- package/src/components/ui/MetricsChart.tsx +0 -1
- package/src/components/ui/ResourceBar.tsx +0 -1
package/src/api/client.ts
CHANGED
|
@@ -227,6 +227,64 @@ export function shouldRetryCapacityQuery(
|
|
|
227
227
|
return failureCount < 3;
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// isKindSyncPending matches the 503 the resource read handlers return while a
|
|
231
|
+
// kind's informer is still completing its initial sync — the caller should
|
|
232
|
+
// keep polling, not surface an error.
|
|
233
|
+
export function isKindSyncPending(error: unknown): boolean {
|
|
234
|
+
return (
|
|
235
|
+
error instanceof ApiError &&
|
|
236
|
+
error.status === 503 &&
|
|
237
|
+
error.data?.error_code === "kind_sync_pending"
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// isClusterConnecting matches the 503 the server returns while the whole
|
|
242
|
+
// connection is still being established (no cache handle for this surface
|
|
243
|
+
// yet) — like kind_sync_pending, a keep-polling signal, not an error.
|
|
244
|
+
export function isClusterConnecting(error: unknown): boolean {
|
|
245
|
+
return (
|
|
246
|
+
error instanceof ApiError &&
|
|
247
|
+
error.status === 503 &&
|
|
248
|
+
error.data?.error_code === "cluster_connecting"
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// isStillLoadingError groups the two retryable "the cluster is warming up"
|
|
253
|
+
// signals every resource surface should sit out in a loading state.
|
|
254
|
+
export function isStillLoadingError(error: unknown): boolean {
|
|
255
|
+
return isKindSyncPending(error) || isClusterConnecting(error);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// isKindSyncFailed matches the terminal variant: the kind never synced within
|
|
259
|
+
// the deadline for this connection. Retrying won't help — show the error.
|
|
260
|
+
export function isKindSyncFailed(error: unknown): boolean {
|
|
261
|
+
return (
|
|
262
|
+
error instanceof ApiError &&
|
|
263
|
+
error.status === 503 &&
|
|
264
|
+
error.data?.error_code === "kind_sync_failed"
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// SyncKindState / SyncStatusSnapshot mirror k8score's SyncSnapshot — attached
|
|
269
|
+
// to the connection-status payload while the initial informer sync is running.
|
|
270
|
+
export interface SyncKindState {
|
|
271
|
+
kind: string;
|
|
272
|
+
key: string;
|
|
273
|
+
synced: boolean;
|
|
274
|
+
deferred: boolean;
|
|
275
|
+
// Terminal: the kind's sync deadline fired without completing.
|
|
276
|
+
failed?: boolean;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface SyncStatusSnapshot {
|
|
280
|
+
phase: string;
|
|
281
|
+
criticalTotal: number;
|
|
282
|
+
criticalSynced: number;
|
|
283
|
+
deferredTotal: number;
|
|
284
|
+
deferredSynced: number;
|
|
285
|
+
kinds: SyncKindState[];
|
|
286
|
+
}
|
|
287
|
+
|
|
230
288
|
const METRICS_API_GROUP_TOKENS = ["metrics", "k8s", "io"] as const;
|
|
231
289
|
|
|
232
290
|
function mentionsMetricsAPIGroup(message: string): boolean {
|
|
@@ -435,6 +493,7 @@ export type DashboardAudit = AuditCardData;
|
|
|
435
493
|
export type { AuditFinding, ResourceGroup, CheckMeta, Check };
|
|
436
494
|
|
|
437
495
|
export interface AuditResponse {
|
|
496
|
+
missingInputs?: string[];
|
|
438
497
|
summary: DashboardAudit;
|
|
439
498
|
findings: AuditFinding[];
|
|
440
499
|
groups: ResourceGroup[];
|
|
@@ -700,7 +759,7 @@ export function useResourceIssues(
|
|
|
700
759
|
});
|
|
701
760
|
}
|
|
702
761
|
|
|
703
|
-
import type { Trace as NetworkTrace, InClusterCapability } from '@skyhook-io/k8s-ui'
|
|
762
|
+
import type { Trace as NetworkTrace, InClusterCapability, DrainPlan, DrainPlanPod } from '@skyhook-io/k8s-ui'
|
|
704
763
|
|
|
705
764
|
// useTrace polls the static path-shaped diagnosis for one network entry
|
|
706
765
|
// kind. 5s refetch + 15s staleTime keeps the drawer feeling live without
|
|
@@ -1834,8 +1893,31 @@ export interface CloudInstallFailure {
|
|
|
1834
1893
|
retrySafe: boolean
|
|
1835
1894
|
}
|
|
1836
1895
|
|
|
1896
|
+
// What Radar did before it stopped: the operation it planned, how far it got,
|
|
1897
|
+
// and what it could establish about the cluster. Evidence for the card, and
|
|
1898
|
+
// the input to the exit it offers — the two are decided separately.
|
|
1899
|
+
export interface CloudInstallAttempted {
|
|
1900
|
+
mode: 'fresh' | 'adopt' | 'gitops'
|
|
1901
|
+
namespace: string
|
|
1902
|
+
release: string
|
|
1903
|
+
stage: 'inspect' | 'prepare' | 'preflight'
|
|
1904
|
+
// Discovery saw only the default namespace; a fresh plan is not proof that
|
|
1905
|
+
// no Radar exists elsewhere.
|
|
1906
|
+
partialScan?: boolean
|
|
1907
|
+
// A complete scan found no Radar running, but Helm's release records could
|
|
1908
|
+
// not be read, so a leftover release cannot be ruled out.
|
|
1909
|
+
releaseUnread?: boolean
|
|
1910
|
+
// GitOps only: the Hub install-page tab of the verified owning controller.
|
|
1911
|
+
method?: 'argocd' | 'flux' | ''
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1837
1914
|
export interface CloudInstallBlocked {
|
|
1838
1915
|
reason: 'gitops' | 'preflight' | 'unsupported'
|
|
1916
|
+
// Preflight only: what would unblock it, and the Helm operation that was
|
|
1917
|
+
// dry-run — the plan card never renders when preflight blocks, so the
|
|
1918
|
+
// blocked card states what Radar tried itself.
|
|
1919
|
+
cause?: 'permissions' | 'cluster' | 'verification'
|
|
1920
|
+
attempted?: CloudInstallAttempted
|
|
1839
1921
|
message: string
|
|
1840
1922
|
blocking?: string[]
|
|
1841
1923
|
}
|
|
@@ -1890,13 +1972,23 @@ export interface CloudConnectInfo {
|
|
|
1890
1972
|
freeTier?: string
|
|
1891
1973
|
}
|
|
1892
1974
|
|
|
1893
|
-
// Deliberately a bare cross-origin GET: no credentials, no identifiers
|
|
1894
|
-
//
|
|
1895
|
-
|
|
1975
|
+
// Deliberately a bare cross-origin GET: no credentials, no identifiers. The
|
|
1976
|
+
// Hub learns what any HTTP request reveals plus `about`, below: the lane the
|
|
1977
|
+
// footer renders and this deployment's mode, both closed enums, so nothing
|
|
1978
|
+
// about the cluster or the person rides along. Part of the query key so a
|
|
1979
|
+
// lane change (a driver install that just became tunneled, say) refetches
|
|
1980
|
+
// instead of reusing the other lane's copy.
|
|
1981
|
+
export function useCloudConnectInfo(
|
|
1982
|
+
apiUrl: string | undefined,
|
|
1983
|
+
enabled: boolean,
|
|
1984
|
+
about: { lane: "driver" | "wizard"; mode?: DeploymentMode },
|
|
1985
|
+
) {
|
|
1986
|
+
const params = new URLSearchParams({ lane: about.lane });
|
|
1987
|
+
if (about.mode) params.set("mode", about.mode);
|
|
1896
1988
|
return useQuery<CloudConnectInfo>({
|
|
1897
|
-
queryKey: ["cloud-connect-info", apiUrl],
|
|
1989
|
+
queryKey: ["cloud-connect-info", apiUrl, about.lane, about.mode],
|
|
1898
1990
|
queryFn: async () => {
|
|
1899
|
-
const res = await fetch(`${apiUrl}/api/connect/info`, {
|
|
1991
|
+
const res = await fetch(`${apiUrl}/api/connect/info?${params}`, {
|
|
1900
1992
|
credentials: "omit",
|
|
1901
1993
|
signal: AbortSignal.timeout(4000),
|
|
1902
1994
|
});
|
|
@@ -2352,6 +2444,15 @@ export function useResource<T>(
|
|
|
2352
2444
|
queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
|
|
2353
2445
|
enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
|
|
2354
2446
|
refetchInterval: options?.refetchInterval,
|
|
2447
|
+
// Kind still completing its initial sync: stay in loading and poll until
|
|
2448
|
+
// it becomes readable instead of erroring out (deep links during startup).
|
|
2449
|
+
retry: (failureCount, error) => {
|
|
2450
|
+
if (isStillLoadingError(error)) return true;
|
|
2451
|
+
if (isKindSyncFailed(error)) return false;
|
|
2452
|
+
return failureCount < 1; // matches the QueryClient default (retry: 1)
|
|
2453
|
+
},
|
|
2454
|
+
retryDelay: (failureCount, error) =>
|
|
2455
|
+
isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
|
|
2355
2456
|
});
|
|
2356
2457
|
|
|
2357
2458
|
// Extract resource and relationships from the response
|
|
@@ -2375,6 +2476,15 @@ export function useResourceWithRelationships<T>(
|
|
|
2375
2476
|
queryKey: ["resource", kind, namespace, name, group],
|
|
2376
2477
|
queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
|
|
2377
2478
|
enabled: Boolean(kind && name),
|
|
2479
|
+
// Deep-linked detail views can mount while the kind's informer is still
|
|
2480
|
+
// completing its initial sync: keep polling instead of erroring out.
|
|
2481
|
+
retry: (failureCount, error) => {
|
|
2482
|
+
if (isStillLoadingError(error)) return true;
|
|
2483
|
+
if (isKindSyncFailed(error)) return false;
|
|
2484
|
+
return failureCount < 1; // matches the QueryClient default (retry: 1)
|
|
2485
|
+
},
|
|
2486
|
+
retryDelay: (failureCount, error) =>
|
|
2487
|
+
isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
|
|
2378
2488
|
});
|
|
2379
2489
|
}
|
|
2380
2490
|
|
|
@@ -2397,6 +2507,16 @@ export function useResources<T>(
|
|
|
2397
2507
|
enabled: (options?.enabled ?? true) && Boolean(kind),
|
|
2398
2508
|
staleTime: 30000, // 30 seconds - matches refetchInterval in ResourcesView
|
|
2399
2509
|
refetchInterval: options?.refetchInterval,
|
|
2510
|
+
// Kind still completing its initial sync (progressive shell, or a
|
|
2511
|
+
// deferred kind shortly after connect): keep polling instead of
|
|
2512
|
+
// surfacing an error.
|
|
2513
|
+
retry: (failureCount, error) => {
|
|
2514
|
+
if (isStillLoadingError(error)) return true;
|
|
2515
|
+
if (isKindSyncFailed(error)) return false;
|
|
2516
|
+
return failureCount < 1; // matches the QueryClient default (retry: 1)
|
|
2517
|
+
},
|
|
2518
|
+
retryDelay: (failureCount, error) =>
|
|
2519
|
+
isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
|
|
2400
2520
|
});
|
|
2401
2521
|
}
|
|
2402
2522
|
|
|
@@ -3067,6 +3187,8 @@ export function useTopNodeMetrics(options?: { enabled?: boolean }) {
|
|
|
3067
3187
|
export interface PrometheusStatus {
|
|
3068
3188
|
available: boolean;
|
|
3069
3189
|
connected: boolean;
|
|
3190
|
+
/** A discovery run is in flight for the current configuration. Never true alongside connected. */
|
|
3191
|
+
discovering?: boolean;
|
|
3070
3192
|
address?: string;
|
|
3071
3193
|
service?: {
|
|
3072
3194
|
namespace: string;
|
|
@@ -3173,6 +3295,8 @@ export interface RightsizingRow {
|
|
|
3173
3295
|
currentPodOOM?: boolean;
|
|
3174
3296
|
windowOomEvidence?: boolean;
|
|
3175
3297
|
oomEvidenceAvailable: boolean;
|
|
3298
|
+
/** The cluster cache would not list the workload's pods, so currentPodOOM is unknown rather than false. */
|
|
3299
|
+
liveInventoryUnavailable?: boolean;
|
|
3176
3300
|
limitConflict?: boolean;
|
|
3177
3301
|
queryError?: string;
|
|
3178
3302
|
}
|
|
@@ -3222,13 +3346,32 @@ export interface RightsizingScanResponse {
|
|
|
3222
3346
|
reason?: string;
|
|
3223
3347
|
}
|
|
3224
3348
|
|
|
3349
|
+
// Poll quickly while a discovery run is in flight so the flip to connected
|
|
3350
|
+
// (or to a failure message) lands promptly; otherwise the slow cadence. A
|
|
3351
|
+
// status that is neither connected nor carrying an outcome was read before
|
|
3352
|
+
// the server's run started (boot, context switch) — every run ends in one or
|
|
3353
|
+
// the other, so that state is transient and gets the fast cadence too.
|
|
3354
|
+
export const PROM_STATUS_POLL_DISCOVERING_MS = 2000;
|
|
3355
|
+
export const PROM_STATUS_POLL_IDLE_MS = 60000;
|
|
3356
|
+
|
|
3357
|
+
export function prometheusStatusRefetchInterval(
|
|
3358
|
+
status: PrometheusStatus | undefined,
|
|
3359
|
+
): number {
|
|
3360
|
+
if (!status) return PROM_STATUS_POLL_IDLE_MS;
|
|
3361
|
+
const settled = status.connected || !!status.error;
|
|
3362
|
+
return status.discovering || !settled
|
|
3363
|
+
? PROM_STATUS_POLL_DISCOVERING_MS
|
|
3364
|
+
: PROM_STATUS_POLL_IDLE_MS;
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3225
3367
|
// Check Prometheus availability
|
|
3226
3368
|
export function usePrometheusStatus() {
|
|
3227
3369
|
return useQuery<PrometheusStatus>({
|
|
3228
3370
|
queryKey: ["prometheus-status"],
|
|
3229
3371
|
queryFn: () => fetchJSON("/prometheus/status"),
|
|
3230
3372
|
staleTime: 30000,
|
|
3231
|
-
refetchInterval:
|
|
3373
|
+
refetchInterval: (query) =>
|
|
3374
|
+
prometheusStatusRefetchInterval(query.state.data),
|
|
3232
3375
|
});
|
|
3233
3376
|
}
|
|
3234
3377
|
|
|
@@ -3237,6 +3380,9 @@ export interface ArgoStatus {
|
|
|
3237
3380
|
// client is live. The two differ right after a restart (configured, reconnecting).
|
|
3238
3381
|
configured: boolean;
|
|
3239
3382
|
connected: boolean;
|
|
3383
|
+
// Connected without a token: the install serves reads to everyone, so
|
|
3384
|
+
// there is nothing to add in Settings.
|
|
3385
|
+
anonymous?: boolean;
|
|
3240
3386
|
address?: string;
|
|
3241
3387
|
reason?: string;
|
|
3242
3388
|
}
|
|
@@ -3343,6 +3489,12 @@ export function useAutoPromConnect(): void {
|
|
|
3343
3489
|
// initial UI render isn't competing with the cluster network call.
|
|
3344
3490
|
const delay = cached === "1" ? 0 : PROM_FIRSTLAUNCH_PROBE_DELAY_MS;
|
|
3345
3491
|
const timeout = window.setTimeout(() => {
|
|
3492
|
+
// The server only reports discovering once the run has started; mark it
|
|
3493
|
+
// here first so the view shows progress instead of the manual CTA for the
|
|
3494
|
+
// whole duration of this request.
|
|
3495
|
+
queryClient.setQueryData<PrometheusStatus>(["prometheus-status"], (prev) =>
|
|
3496
|
+
prev ? { ...prev, discovering: true } : prev,
|
|
3497
|
+
);
|
|
3346
3498
|
// Direct apiFetch (not via the usePrometheusConnect mutation) so the
|
|
3347
3499
|
// meta-driven toast handler stays silent — the user didn't click anything.
|
|
3348
3500
|
apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, {
|
|
@@ -3357,6 +3509,14 @@ export function useAutoPromConnect(): void {
|
|
|
3357
3509
|
queryClient.invalidateQueries({ queryKey: ["prometheus-status"] });
|
|
3358
3510
|
})
|
|
3359
3511
|
.catch(() => {
|
|
3512
|
+
// Undo the optimistic flag, then ask the server: if it's reachable
|
|
3513
|
+
// the authoritative status (possibly a still-running discovery)
|
|
3514
|
+
// replaces this within one fetch; if it isn't, the CTA is right.
|
|
3515
|
+
queryClient.setQueryData<PrometheusStatus>(
|
|
3516
|
+
["prometheus-status"],
|
|
3517
|
+
(prev) => (prev?.discovering ? { ...prev, discovering: false } : prev),
|
|
3518
|
+
);
|
|
3519
|
+
queryClient.invalidateQueries({ queryKey: ["prometheus-status"] });
|
|
3360
3520
|
try {
|
|
3361
3521
|
window.localStorage.removeItem(promAutoConnectKey(context));
|
|
3362
3522
|
} catch {
|
|
@@ -4734,6 +4894,31 @@ export function useRolloutCapabilities(
|
|
|
4734
4894
|
});
|
|
4735
4895
|
}
|
|
4736
4896
|
|
|
4897
|
+
export interface AnalysisRunSummary {
|
|
4898
|
+
name: string;
|
|
4899
|
+
phase: string;
|
|
4900
|
+
message?: string;
|
|
4901
|
+
trigger?: string;
|
|
4902
|
+
stepIndex?: number;
|
|
4903
|
+
createdAt: string;
|
|
4904
|
+
metricsTotal: number;
|
|
4905
|
+
metricsPassing: number;
|
|
4906
|
+
metricsNotPassing: number;
|
|
4907
|
+
}
|
|
4908
|
+
|
|
4909
|
+
export function useRolloutAnalysisRuns(
|
|
4910
|
+
namespace: string,
|
|
4911
|
+
name: string,
|
|
4912
|
+
enabled = true,
|
|
4913
|
+
) {
|
|
4914
|
+
return useQuery<{ items: AnalysisRunSummary[] }>({
|
|
4915
|
+
queryKey: ["rollout-analysisruns", namespace, name],
|
|
4916
|
+
queryFn: () => fetchJSON(`/rollouts/${namespace}/${name}/analysisruns`),
|
|
4917
|
+
enabled: Boolean(namespace && name && enabled),
|
|
4918
|
+
staleTime: 10000,
|
|
4919
|
+
});
|
|
4920
|
+
}
|
|
4921
|
+
|
|
4737
4922
|
// Fallbacks only. The server reports what it actually did — including when it found
|
|
4738
4923
|
// nothing to do — so its message is preferred over anything asserted here.
|
|
4739
4924
|
const ROLLOUT_ACTION_MESSAGES: Record<
|
|
@@ -4898,6 +5083,75 @@ export interface DrainNodeOptions {
|
|
|
4898
5083
|
force?: boolean;
|
|
4899
5084
|
}
|
|
4900
5085
|
|
|
5086
|
+
export interface DrainPlanRequestOptions {
|
|
5087
|
+
deleteEmptyDirData: boolean;
|
|
5088
|
+
force: boolean;
|
|
5089
|
+
}
|
|
5090
|
+
|
|
5091
|
+
/** Path of the read-only plan endpoint; distinct from the drain so a preview can never drain. */
|
|
5092
|
+
export function drainPlanPath(name: string): string {
|
|
5093
|
+
return `/nodes/${encodeURIComponent(name)}/drain-plan`;
|
|
5094
|
+
}
|
|
5095
|
+
|
|
5096
|
+
/** Body for the plan request: both options are always explicit, the server echoes them back. */
|
|
5097
|
+
export function drainPlanBody(options: DrainPlanRequestOptions): string {
|
|
5098
|
+
return JSON.stringify({
|
|
5099
|
+
deleteEmptyDirData: options.deleteEmptyDirData,
|
|
5100
|
+
force: options.force,
|
|
5101
|
+
});
|
|
5102
|
+
}
|
|
5103
|
+
|
|
5104
|
+
/**
|
|
5105
|
+
* The connected radar predates the drain-plan endpoint. Distinguished from a
|
|
5106
|
+
* node-not-found 404 by the body: handlers answer with a JSON error envelope,
|
|
5107
|
+
* while an unknown route gets the router's plain-text 404. Hosts serving a newer
|
|
5108
|
+
* frontend against an older radar (Radar Hub) use this to fall back to the
|
|
5109
|
+
* plan-less drain dialog instead of leaving Drain permanently disabled.
|
|
5110
|
+
*/
|
|
5111
|
+
export class DrainPlanUnsupportedError extends Error {
|
|
5112
|
+
constructor() {
|
|
5113
|
+
super("This radar does not support drain plans");
|
|
5114
|
+
this.name = "DrainPlanUnsupportedError";
|
|
5115
|
+
}
|
|
5116
|
+
}
|
|
5117
|
+
|
|
5118
|
+
export function drainPlanFetchError(
|
|
5119
|
+
status: number,
|
|
5120
|
+
body: { error?: string } | null,
|
|
5121
|
+
): Error {
|
|
5122
|
+
if (status === 404 && body === null) {
|
|
5123
|
+
return new DrainPlanUnsupportedError();
|
|
5124
|
+
}
|
|
5125
|
+
return new Error(body?.error || `HTTP ${status}`);
|
|
5126
|
+
}
|
|
5127
|
+
|
|
5128
|
+
// Read-only drain plan: what a drain with these options would do to each pod on the node.
|
|
5129
|
+
// Modelled as a mutation because it is a POST with a body and is fetched on demand
|
|
5130
|
+
// while the drain dialog is open; it performs no cluster mutation.
|
|
5131
|
+
export function useDrainPlan() {
|
|
5132
|
+
return useMutation<
|
|
5133
|
+
DrainPlan,
|
|
5134
|
+
Error,
|
|
5135
|
+
{ name: string; options: DrainPlanRequestOptions }
|
|
5136
|
+
>({
|
|
5137
|
+
mutationFn: async ({ name, options }) => {
|
|
5138
|
+
const response = await apiFetch(apiUrl(drainPlanPath(name)), {
|
|
5139
|
+
method: "POST",
|
|
5140
|
+
headers: { "Content-Type": "application/json" },
|
|
5141
|
+
body: drainPlanBody(options),
|
|
5142
|
+
});
|
|
5143
|
+
if (!response.ok) {
|
|
5144
|
+
const body = await response.json().catch(() => null);
|
|
5145
|
+
throw drainPlanFetchError(response.status, body);
|
|
5146
|
+
}
|
|
5147
|
+
return response.json();
|
|
5148
|
+
},
|
|
5149
|
+
// Deliberately no meta toast keys: the plan is fetched only from the drain dialog, which
|
|
5150
|
+
// renders a failure inline and keeps Drain disabled while it shows; a global toast would
|
|
5151
|
+
// report the same failure twice.
|
|
5152
|
+
});
|
|
5153
|
+
}
|
|
5154
|
+
|
|
4901
5155
|
export function useDrainNode() {
|
|
4902
5156
|
const queryClient = useQueryClient();
|
|
4903
5157
|
|
|
@@ -4927,7 +5181,11 @@ export function useDrainNode() {
|
|
|
4927
5181
|
// No static successMessage — handled in onSuccess to distinguish partial failures
|
|
4928
5182
|
},
|
|
4929
5183
|
onSuccess: (
|
|
4930
|
-
data: {
|
|
5184
|
+
data: {
|
|
5185
|
+
evictedPods?: string[];
|
|
5186
|
+
skippedPods?: DrainPlanPod[];
|
|
5187
|
+
errors?: string[];
|
|
5188
|
+
},
|
|
4931
5189
|
variables,
|
|
4932
5190
|
) => {
|
|
4933
5191
|
queryClient.invalidateQueries({ queryKey: ["resources", "nodes"] });
|
|
@@ -4936,20 +5194,67 @@ export function useDrainNode() {
|
|
|
4936
5194
|
});
|
|
4937
5195
|
queryClient.invalidateQueries({ queryKey: ["topology"] });
|
|
4938
5196
|
|
|
4939
|
-
const
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
showApiError(
|
|
4943
|
-
`Drain completed with ${errors} error(s)`,
|
|
4944
|
-
`${evicted} pods evicted. Errors: ${data.errors!.join("; ")}`,
|
|
4945
|
-
);
|
|
5197
|
+
const { title, detail, failed } = describeDrainResult(data);
|
|
5198
|
+
if (failed) {
|
|
5199
|
+
showApiError(title, detail);
|
|
4946
5200
|
} else {
|
|
4947
|
-
showApiSuccess(
|
|
5201
|
+
showApiSuccess(title, detail);
|
|
4948
5202
|
}
|
|
4949
5203
|
},
|
|
4950
5204
|
});
|
|
4951
5205
|
}
|
|
4952
5206
|
|
|
5207
|
+
const DRAIN_RESULT_MAX_LISTED = 5;
|
|
5208
|
+
|
|
5209
|
+
function listWithOverflow(items: string[]): string {
|
|
5210
|
+
const shown = items.slice(0, DRAIN_RESULT_MAX_LISTED);
|
|
5211
|
+
const rest = items.length - shown.length;
|
|
5212
|
+
return rest > 0 ? `${shown.join("; ")}; +${rest} more` : shown.join("; ");
|
|
5213
|
+
}
|
|
5214
|
+
|
|
5215
|
+
/**
|
|
5216
|
+
* Turns the drain response into a legible summary: evicted, skipped (with reasons),
|
|
5217
|
+
* failed. Lists are capped so the toast stays readable on nodes with many pods.
|
|
5218
|
+
*/
|
|
5219
|
+
export function describeDrainResult(data: {
|
|
5220
|
+
evictedPods?: string[];
|
|
5221
|
+
skippedPods?: DrainPlanPod[];
|
|
5222
|
+
errors?: string[];
|
|
5223
|
+
}): { title: string; detail: string; failed: boolean } {
|
|
5224
|
+
const evicted = data?.evictedPods?.length ?? 0;
|
|
5225
|
+
const skipped = data?.skippedPods ?? [];
|
|
5226
|
+
const errors = data?.errors ?? [];
|
|
5227
|
+
const parts: string[] = [];
|
|
5228
|
+
if (skipped.length > 0) {
|
|
5229
|
+
parts.push(
|
|
5230
|
+
`Skipped ${skipped.length}: ${listWithOverflow(
|
|
5231
|
+
skipped.map((p) => `${p.namespace}/${p.name} (${p.reason})`),
|
|
5232
|
+
)}`,
|
|
5233
|
+
);
|
|
5234
|
+
}
|
|
5235
|
+
if (errors.length > 0) {
|
|
5236
|
+
parts.push(`Failed ${errors.length}: ${listWithOverflow(errors)}`);
|
|
5237
|
+
}
|
|
5238
|
+
parts.push(
|
|
5239
|
+
evicted > 0
|
|
5240
|
+
? "Evictions were accepted; those pods may still be terminating. The node remains cordoned."
|
|
5241
|
+
: "The node remains cordoned.",
|
|
5242
|
+
);
|
|
5243
|
+
const detail = parts.join("\n");
|
|
5244
|
+
if (errors.length > 0) {
|
|
5245
|
+
return {
|
|
5246
|
+
title: `Drain finished with ${errors.length} failed eviction(s): ${evicted} evicted, ${skipped.length} skipped`,
|
|
5247
|
+
detail,
|
|
5248
|
+
failed: true,
|
|
5249
|
+
};
|
|
5250
|
+
}
|
|
5251
|
+
return {
|
|
5252
|
+
title: `Node drained: ${evicted} evicted, ${skipped.length} skipped`,
|
|
5253
|
+
detail,
|
|
5254
|
+
failed: false,
|
|
5255
|
+
};
|
|
5256
|
+
}
|
|
5257
|
+
|
|
4953
5258
|
// ============================================================================
|
|
4954
5259
|
// Helm API hooks
|
|
4955
5260
|
// ============================================================================
|
package/src/api/diagnose.ts
CHANGED
|
@@ -183,6 +183,7 @@ export interface DiagnoseStreamEvent {
|
|
|
183
183
|
// server runs.
|
|
184
184
|
export interface RunSummary {
|
|
185
185
|
id: string;
|
|
186
|
+
/** Stored target Kind; existing hosted runs may contain a plural API resource name. */
|
|
186
187
|
kind: string;
|
|
187
188
|
/** Kubernetes API group; empty means the core API group. */
|
|
188
189
|
group: string;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
describeDrainResult,
|
|
4
|
+
DrainPlanUnsupportedError,
|
|
5
|
+
drainPlanBody,
|
|
6
|
+
drainPlanFetchError,
|
|
7
|
+
drainPlanPath,
|
|
8
|
+
} from "./client";
|
|
9
|
+
|
|
10
|
+
describe("drain plan request", () => {
|
|
11
|
+
it("targets the read-only plan endpoint, never the drain", () => {
|
|
12
|
+
expect(drainPlanPath("worker-1")).toBe("/nodes/worker-1/drain-plan");
|
|
13
|
+
expect(drainPlanPath("worker-1")).not.toContain("/drain\"");
|
|
14
|
+
expect(drainPlanPath("odd node")).toBe("/nodes/odd%20node/drain-plan");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("always sends both options explicitly so no server default applies", () => {
|
|
18
|
+
expect(JSON.parse(drainPlanBody({ deleteEmptyDirData: false, force: false }))).toEqual({
|
|
19
|
+
deleteEmptyDirData: false,
|
|
20
|
+
force: false,
|
|
21
|
+
});
|
|
22
|
+
expect(JSON.parse(drainPlanBody({ deleteEmptyDirData: true, force: true }))).toEqual({
|
|
23
|
+
deleteEmptyDirData: true,
|
|
24
|
+
force: true,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("reads a plain-text 404 as an unsupported endpoint, not as a missing node", () => {
|
|
29
|
+
expect(drainPlanFetchError(404, null)).toBeInstanceOf(DrainPlanUnsupportedError);
|
|
30
|
+
const nodeGone = drainPlanFetchError(404, { error: 'nodes "worker-1" not found' });
|
|
31
|
+
expect(nodeGone).not.toBeInstanceOf(DrainPlanUnsupportedError);
|
|
32
|
+
expect(nodeGone.message).toBe('nodes "worker-1" not found');
|
|
33
|
+
expect(drainPlanFetchError(503, null).message).toBe("HTTP 503");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("describeDrainResult", () => {
|
|
38
|
+
it("lists evictions, skips with reasons, and individual failures", () => {
|
|
39
|
+
const r = describeDrainResult({
|
|
40
|
+
evictedPods: ["shop/cache"],
|
|
41
|
+
skippedPods: [
|
|
42
|
+
{ namespace: "shop", name: "agent", outcome: "skip", reason: "managed by a DaemonSet", emptyDir: false, pdbChecked: false },
|
|
43
|
+
],
|
|
44
|
+
errors: ["shop/web-1: timed out waiting for PDB to allow eviction"],
|
|
45
|
+
});
|
|
46
|
+
expect(r.failed).toBe(true);
|
|
47
|
+
expect(r.title).toBe("Drain finished with 1 failed eviction(s): 1 evicted, 1 skipped");
|
|
48
|
+
expect(r.detail).toContain("shop/agent (managed by a DaemonSet)");
|
|
49
|
+
expect(r.detail).toContain("shop/web-1: timed out");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("caps long lists so the toast stays readable", () => {
|
|
53
|
+
const skippedPods = Array.from({ length: 8 }, (_, i) => ({
|
|
54
|
+
namespace: "ns", name: `p${i}`, outcome: "skip" as const, reason: "r", emptyDir: false, pdbChecked: false,
|
|
55
|
+
}));
|
|
56
|
+
const r = describeDrainResult({ evictedPods: [], skippedPods });
|
|
57
|
+
expect(r.detail).toContain("Skipped 8:");
|
|
58
|
+
expect(r.detail).toContain("ns/p4 (r); +3 more");
|
|
59
|
+
expect(r.detail).not.toContain("ns/p5");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("reports a clean drain as success and says what success means", () => {
|
|
63
|
+
const r = describeDrainResult({ evictedPods: ["a", "b"] });
|
|
64
|
+
expect(r.failed).toBe(false);
|
|
65
|
+
expect(r.title).toBe("Node drained: 2 evicted, 0 skipped");
|
|
66
|
+
expect(r.detail).toContain("those pods may still be terminating");
|
|
67
|
+
expect(r.detail).toContain("node remains cordoned");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("never claims evictions were accepted when nothing was evicted", () => {
|
|
71
|
+
const allFailed = describeDrainResult({
|
|
72
|
+
evictedPods: [],
|
|
73
|
+
errors: ["shop/web-1: timed out waiting for PDB to allow eviction"],
|
|
74
|
+
});
|
|
75
|
+
expect(allFailed.detail).not.toContain("accepted");
|
|
76
|
+
expect(allFailed.detail).toContain("node remains cordoned");
|
|
77
|
+
|
|
78
|
+
const skipOnly = describeDrainResult({
|
|
79
|
+
evictedPods: [],
|
|
80
|
+
skippedPods: [
|
|
81
|
+
{ namespace: "shop", name: "agent", outcome: "skip", reason: "managed by a DaemonSet", emptyDir: false, pdbChecked: false },
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
expect(skipOnly.detail).not.toContain("accepted");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { podLimitRangeNames } from './quotas'
|
|
3
|
+
|
|
4
|
+
describe('Pod-relevant LimitRanges', () => {
|
|
5
|
+
it('excludes PVC-only rules and retains Pod, Container and mixed rules', () => {
|
|
6
|
+
const rules = [
|
|
7
|
+
{ metadata: { name: 'storage-only' }, spec: { limits: [{ type: 'PersistentVolumeClaim' }] } },
|
|
8
|
+
{ metadata: { name: 'container-defaults' }, spec: { limits: [{ type: 'Container' }] } },
|
|
9
|
+
{ metadata: { name: 'pod-total' }, spec: { limits: [{ type: 'Pod' }] } },
|
|
10
|
+
{ metadata: { name: 'mixed' }, spec: { limits: [{ type: 'PersistentVolumeClaim' }, { type: 'Container' }] } },
|
|
11
|
+
]
|
|
12
|
+
expect(podLimitRangeNames(rules)).toEqual(['container-defaults', 'pod-total', 'mixed'])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('ignores LimitRanges with no declared entries', () => {
|
|
16
|
+
expect(podLimitRangeNames([
|
|
17
|
+
{ metadata: { name: 'null-entries' }, spec: { limits: null } },
|
|
18
|
+
{ metadata: { name: 'empty-entries' }, spec: { limits: [] } },
|
|
19
|
+
])).toEqual([])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('has no contextual links before data is available or for an empty namespace', () => {
|
|
23
|
+
expect(podLimitRangeNames(undefined)).toEqual([])
|
|
24
|
+
expect(podLimitRangeNames([])).toEqual([])
|
|
25
|
+
})
|
|
26
|
+
})
|
package/src/api/quotas.ts
CHANGED
|
@@ -14,3 +14,23 @@ export function useNamespaceQuotas(namespace: string, enabled = true) {
|
|
|
14
14
|
staleTime: 15000,
|
|
15
15
|
})
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
// useNamespaceLimitRanges fetches a namespace's LimitRange objects via
|
|
19
|
+
// /api/resources/limitranges?namespace=<ns> (a bare array). Backs the
|
|
20
|
+
// NamespaceRenderer rules section and the contextual link on Pod/workload
|
|
21
|
+
// details — the defaults and constraints applied at admission are otherwise
|
|
22
|
+
// only visible by reading the object.
|
|
23
|
+
export function useNamespaceLimitRanges(namespace: string, enabled = true) {
|
|
24
|
+
return useQuery<any[]>({
|
|
25
|
+
queryKey: ['limitranges', namespace],
|
|
26
|
+
queryFn: () => fetchJSON<any[]>(`/resources/limitranges?namespace=${encodeURIComponent(namespace)}`),
|
|
27
|
+
enabled: enabled && !!namespace,
|
|
28
|
+
staleTime: 15000,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function podLimitRangeNames(limitRanges: any[] | undefined): string[] {
|
|
33
|
+
return (limitRanges ?? [])
|
|
34
|
+
.filter(lr => lr.spec.limits?.some((entry: { type: string }) => entry.type === 'Container' || entry.type === 'Pod'))
|
|
35
|
+
.map(lr => lr.metadata.name)
|
|
36
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import type { WorkloadMetrics } from './workloadMetrics'
|
|
3
|
+
|
|
4
|
+
vi.mock('@tanstack/react-query', () => ({ useQuery: (options: unknown) => options }))
|
|
5
|
+
vi.mock('./client', () => ({
|
|
6
|
+
useClusterInfo: () => ({ data: { context: 'cluster-a' } }),
|
|
7
|
+
fetchJSON: vi.fn(),
|
|
8
|
+
ApiError: class extends Error {},
|
|
9
|
+
}))
|
|
10
|
+
vi.mock('./config', () => ({ getApiBase: () => '/api' }))
|
|
11
|
+
const { useWorkloadMetrics: workloadQueryOptions } = await import('./workloadMetrics')
|
|
12
|
+
|
|
13
|
+
describe('workload source-switch placeholders', () => {
|
|
14
|
+
const options = () => workloadQueryOptions('Deployment', 'shop', 'api', '1h', 'istio', true) as unknown as {
|
|
15
|
+
queryKey: unknown[]
|
|
16
|
+
placeholderData: (data: WorkloadMetrics, query: { queryKey: unknown[] }) => WorkloadMetrics | undefined
|
|
17
|
+
refetchInterval: (query: { state: { data: WorkloadMetrics } }) => number
|
|
18
|
+
}
|
|
19
|
+
const data: WorkloadMetrics = {
|
|
20
|
+
state: 'available', sources: [], pods: 1, podsTotal: 1,
|
|
21
|
+
history: {}, comparison: {},
|
|
22
|
+
start: 0, end: 60, stepSeconds: 60, rateWindowSeconds: 300, panels: {},
|
|
23
|
+
}
|
|
24
|
+
it('keeps the previous payload when only the observer changes', () => {
|
|
25
|
+
const query = options()
|
|
26
|
+
const key = [...query.queryKey]
|
|
27
|
+
key[key.length - 1] = 'beyla'
|
|
28
|
+
expect(query.placeholderData(data, { queryKey: key })).toBe(data)
|
|
29
|
+
})
|
|
30
|
+
it('polls pending current attribution even while history is available', () => {
|
|
31
|
+
expect(options().refetchInterval({ state: { data } })).toBe(30_000)
|
|
32
|
+
expect(options().refetchInterval({ state: { data: { ...data, comparison: { cpu: { state: 'detecting', unit: 'cores', series: [] } } } } })).toBe(3_000)
|
|
33
|
+
})
|
|
34
|
+
it.each([1, 2, 3, 4, 5, 6])('drops the previous payload when identity field %s changes', (index) => {
|
|
35
|
+
const query = options()
|
|
36
|
+
const key = [...query.queryKey]
|
|
37
|
+
key[index] = 'different'
|
|
38
|
+
expect(query.placeholderData(data, { queryKey: key })).toBeUndefined()
|
|
39
|
+
})
|
|
40
|
+
})
|