@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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type { TimeSeries } from "@skyhook-io/k8s-ui/components/charts";
|
|
3
|
+
import { ApiError, fetchJSON, useClusterInfo, type PrometheusTimeRange } from "./client";
|
|
4
|
+
import { getApiBase } from "./config";
|
|
5
|
+
|
|
6
|
+
export type WorkloadMetricState =
|
|
7
|
+
"available" | "partial" | "stale" | "unavailable" | "error" | "detecting";
|
|
8
|
+
export type WorkloadRequestSource = "beyla" | "istio";
|
|
9
|
+
|
|
10
|
+
export interface WorkloadMetricPanel {
|
|
11
|
+
state: WorkloadMetricState;
|
|
12
|
+
reason?: string;
|
|
13
|
+
unit: string;
|
|
14
|
+
series: TimeSeries[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WorkloadMetrics {
|
|
18
|
+
history: Partial<Record<"cpu" | "memory" | "throttling" | "requests", { mode: "workload-history" | "current-pods" | "unavailable"; reason?: string }>>;
|
|
19
|
+
comparison: Partial<Record<"cpu" | "memory" | "throttling", WorkloadMetricPanel>>;
|
|
20
|
+
scopeNotice?: string;
|
|
21
|
+
attribution?: Record<string, string>;
|
|
22
|
+
state: WorkloadMetricState;
|
|
23
|
+
reason?: string;
|
|
24
|
+
source?: WorkloadRequestSource;
|
|
25
|
+
sources: {
|
|
26
|
+
id: WorkloadRequestSource;
|
|
27
|
+
label: string;
|
|
28
|
+
state: WorkloadMetricState;
|
|
29
|
+
}[];
|
|
30
|
+
pods: number;
|
|
31
|
+
podsTotal: number;
|
|
32
|
+
end: number;
|
|
33
|
+
start: number;
|
|
34
|
+
stepSeconds: number;
|
|
35
|
+
rateWindowSeconds: number;
|
|
36
|
+
panels: Partial<
|
|
37
|
+
Record<
|
|
38
|
+
"requests" | "errors" | "p50" | "p95" | "throttling" | "cpu" | "memory" | "observedPods",
|
|
39
|
+
WorkloadMetricPanel
|
|
40
|
+
>
|
|
41
|
+
>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useWorkloadMetrics(
|
|
45
|
+
kind: string,
|
|
46
|
+
namespace: string,
|
|
47
|
+
name: string,
|
|
48
|
+
range: PrometheusTimeRange,
|
|
49
|
+
source: WorkloadRequestSource | "",
|
|
50
|
+
enabled: boolean,
|
|
51
|
+
) {
|
|
52
|
+
const { data: cluster } = useClusterInfo();
|
|
53
|
+
const identity = ["workload-metrics", getApiBase(), cluster?.context, kind, namespace, name, range];
|
|
54
|
+
return useQuery<WorkloadMetrics>({
|
|
55
|
+
queryKey: [...identity, source],
|
|
56
|
+
placeholderData: (previous, previousQuery) =>
|
|
57
|
+
identity.every((value, index) => previousQuery?.queryKey[index] === value) ? previous : undefined,
|
|
58
|
+
queryFn: ({ signal }) =>
|
|
59
|
+
fetchJSON<WorkloadMetrics>(
|
|
60
|
+
`/prometheus/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}?${new URLSearchParams({ range, source })}`,
|
|
61
|
+
signal,
|
|
62
|
+
),
|
|
63
|
+
enabled,
|
|
64
|
+
staleTime: 30_000,
|
|
65
|
+
refetchInterval: (query) => query.state.data?.state === "detecting" || Object.values(query.state.data?.comparison ?? {}).some((panel) => panel.state === "detecting") ? 3_000 : 30_000,
|
|
66
|
+
retry: (failureCount, error) => failureCount < 1 && error instanceof ApiError && error.status === 409,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useRef, useState } from 'react'
|
|
1
|
+
import { type ReactNode, useRef, useState } from 'react'
|
|
2
2
|
import { useMutation } from '@tanstack/react-query'
|
|
3
3
|
import { AlertTriangle, ArrowUpRight, Check, ExternalLink, GitBranch, Info, Loader2, ShieldAlert, X } from 'lucide-react'
|
|
4
|
+
import type { BlockedExit } from './cloudConnectHandoff'
|
|
4
5
|
import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
|
|
5
6
|
import {
|
|
6
7
|
ApiError,
|
|
@@ -19,20 +20,20 @@ import { showApiError } from './ui/Toast'
|
|
|
19
20
|
export function CloudConnectFlow({
|
|
20
21
|
status,
|
|
21
22
|
blocked,
|
|
22
|
-
|
|
23
|
+
exit,
|
|
23
24
|
onStatus,
|
|
24
25
|
onExit,
|
|
25
26
|
}: {
|
|
26
27
|
status: CloudInstallStatus
|
|
27
28
|
blocked: CloudInstallBlocked | null
|
|
28
|
-
|
|
29
|
+
exit: BlockedExit
|
|
29
30
|
// Push a mutation's status response into the shared query state.
|
|
30
31
|
onStatus: (st: CloudInstallStatus) => void
|
|
31
32
|
// Leave the flow view (back to the pitch, or close after dismiss).
|
|
32
33
|
onExit: () => void
|
|
33
34
|
}) {
|
|
34
35
|
if (blocked) {
|
|
35
|
-
return <BlockedView blocked={blocked}
|
|
36
|
+
return <BlockedView blocked={blocked} exit={exit} onExit={onExit} />
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
switch (status.state) {
|
|
@@ -66,13 +67,14 @@ export function CloudConnectFlow({
|
|
|
66
67
|
|
|
67
68
|
function BlockedView({
|
|
68
69
|
blocked,
|
|
69
|
-
|
|
70
|
+
exit,
|
|
70
71
|
onExit,
|
|
71
72
|
}: {
|
|
72
73
|
blocked: CloudInstallBlocked
|
|
73
|
-
|
|
74
|
+
exit: BlockedExit
|
|
74
75
|
onExit: () => void
|
|
75
76
|
}) {
|
|
77
|
+
const copy = blockedCopy(blocked, exit)
|
|
76
78
|
const icon =
|
|
77
79
|
blocked.reason === 'gitops' ? (
|
|
78
80
|
<GitBranch className="w-4 h-4 shrink-0 mt-0.5 text-emerald-600 dark:text-emerald-400" />
|
|
@@ -81,48 +83,33 @@ function BlockedView({
|
|
|
81
83
|
) : (
|
|
82
84
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-500" />
|
|
83
85
|
)
|
|
84
|
-
const title =
|
|
85
|
-
blocked.reason === 'gitops'
|
|
86
|
-
? 'This install is managed by GitOps'
|
|
87
|
-
: blocked.reason === 'preflight'
|
|
88
|
-
? 'Your Kubernetes identity can’t install this'
|
|
89
|
-
: 'This cluster can’t be connected from here'
|
|
90
86
|
return (
|
|
91
87
|
<div className="px-8 pt-6 pb-5">
|
|
92
88
|
<div className="card-inner-lg flex gap-2.5">
|
|
93
89
|
{icon}
|
|
94
|
-
<div className="min-w-0">
|
|
95
|
-
<div className="text-[13px] font-semibold text-theme-text-primary">{title}</div>
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
{line}
|
|
103
|
-
</li>
|
|
104
|
-
))}
|
|
105
|
-
</ul>
|
|
106
|
-
)}
|
|
90
|
+
<div className="min-w-0 space-y-3">
|
|
91
|
+
<div className="text-[13px] font-semibold text-theme-text-primary">{copy.title}</div>
|
|
92
|
+
<BlockedSection label="What Radar tried">{copy.tried}</BlockedSection>
|
|
93
|
+
<BlockedSection label="Why it stopped">
|
|
94
|
+
{copy.why}
|
|
95
|
+
{blocked.blocking && blocked.blocking.length > 0 && <BlockingLines lines={blocked.blocking} />}
|
|
96
|
+
</BlockedSection>
|
|
97
|
+
<BlockedSection label="What to do">{copy.next}</BlockedSection>
|
|
107
98
|
</div>
|
|
108
99
|
</div>
|
|
109
100
|
<div className="mt-4 flex items-center gap-4">
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
Connect through the browser wizard instead →
|
|
123
|
-
</a>
|
|
124
|
-
)}
|
|
125
|
-
<button onClick={onExit} className="text-[12.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
|
|
101
|
+
<a
|
|
102
|
+
href={exit.href}
|
|
103
|
+
target="_blank"
|
|
104
|
+
rel="noopener noreferrer"
|
|
105
|
+
className="px-5 py-2 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[13.5px] font-bold shadow-[0_0_22px_rgba(16,185,129,0.35)] hover:shadow-[0_0_30px_rgba(16,185,129,0.5)] hover:-translate-y-px transition-all"
|
|
106
|
+
>
|
|
107
|
+
{exit.label}
|
|
108
|
+
</a>
|
|
109
|
+
<button
|
|
110
|
+
onClick={onExit}
|
|
111
|
+
className="ml-auto text-[12.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
|
|
112
|
+
>
|
|
126
113
|
Back
|
|
127
114
|
</button>
|
|
128
115
|
</div>
|
|
@@ -130,6 +117,156 @@ function BlockedView({
|
|
|
130
117
|
)
|
|
131
118
|
}
|
|
132
119
|
|
|
120
|
+
function BlockedSection({ label, children }: { label: string; children: ReactNode }) {
|
|
121
|
+
return (
|
|
122
|
+
<div>
|
|
123
|
+
<div className="text-[10.5px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{label}</div>
|
|
124
|
+
<div className="mt-0.5 text-[12px] leading-relaxed text-theme-text-secondary">{children}</div>
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function BlockingLines({ lines }: { lines: string[] }) {
|
|
130
|
+
return (
|
|
131
|
+
<ul className="mt-1.5 space-y-1 text-[11.5px] text-theme-text-tertiary">
|
|
132
|
+
{lines.map((line) => (
|
|
133
|
+
<li key={line} className="flex items-start gap-1.5">
|
|
134
|
+
<span className="mt-[6px] w-1 h-1 rounded-full bg-amber-500 shrink-0" />
|
|
135
|
+
{line}
|
|
136
|
+
</li>
|
|
137
|
+
))}
|
|
138
|
+
</ul>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The blocked card stands in for the plan card the person never saw, so it
|
|
143
|
+
// carries what Radar did first, then why it stopped, then the way forward.
|
|
144
|
+
// Three short labeled parts, not one paragraph. Every card ends the same way
|
|
145
|
+
// — an admin with cluster access, in Radar Cloud — and the exit says whether
|
|
146
|
+
// that is a deep-linked install command or Radar Cloud itself.
|
|
147
|
+
function blockedCopy(blocked: CloudInstallBlocked, exit: BlockedExit): { title: string; tried: ReactNode; why: ReactNode; next: ReactNode } {
|
|
148
|
+
const a = blocked.attempted
|
|
149
|
+
const release = a && (
|
|
150
|
+
<>
|
|
151
|
+
release <code className="font-mono text-[11px] text-theme-text-primary">{a.release}</code> in namespace{' '}
|
|
152
|
+
<code className="font-mono text-[11px] text-theme-text-primary">{a.namespace}</code>
|
|
153
|
+
</>
|
|
154
|
+
)
|
|
155
|
+
const operation = a?.mode === 'adopt' ? 'a Helm upgrade of your existing' : 'a fresh Helm install of'
|
|
156
|
+
// What Radar did is told by the stage it reached, not by the plan it had:
|
|
157
|
+
// a target can be known from discovery before any chart was rendered or
|
|
158
|
+
// dry-run, and the card must not claim work that did not happen.
|
|
159
|
+
const tried =
|
|
160
|
+
!a ? (
|
|
161
|
+
<>Radar was looking for an existing Radar install in this cluster, as your kubeconfig identity. Nothing was changed.</>
|
|
162
|
+
) : a.mode === 'gitops' ? (
|
|
163
|
+
<>Radar found {release} and traced how it is managed before planning anything. Nothing was changed.</>
|
|
164
|
+
) : a.stage === 'inspect' ? (
|
|
165
|
+
a.mode === 'adopt' ? (
|
|
166
|
+
<>
|
|
167
|
+
Radar found {release} and was reading its Helm state, as your kubeconfig identity, before planning anything.
|
|
168
|
+
Nothing was changed.
|
|
169
|
+
</>
|
|
170
|
+
) : (
|
|
171
|
+
<>
|
|
172
|
+
Radar found no Radar running anywhere in this cluster and was checking Helm’s release records in namespace{' '}
|
|
173
|
+
<code className="font-mono text-[11px] text-theme-text-primary">{a.namespace}</code>, as your kubeconfig
|
|
174
|
+
identity, before planning anything. Nothing was changed.
|
|
175
|
+
</>
|
|
176
|
+
)
|
|
177
|
+
) : a.stage === 'prepare' ? (
|
|
178
|
+
<>
|
|
179
|
+
Radar planned {operation} {release} with the Cloud connection enabled and was preparing the chart, as your
|
|
180
|
+
kubeconfig identity. Nothing was changed.
|
|
181
|
+
</>
|
|
182
|
+
) : (
|
|
183
|
+
<>
|
|
184
|
+
Radar prepared {operation} {release} with the Cloud connection enabled, and dry-ran it against the cluster as
|
|
185
|
+
your kubeconfig identity. Nothing was changed.
|
|
186
|
+
</>
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
// Where the exit is an install command, Radar Cloud asks for a cluster name
|
|
190
|
+
// first, then shows the instructions — say so, rather than promising a
|
|
191
|
+
// command on the next screen.
|
|
192
|
+
const installNext = (
|
|
193
|
+
<>
|
|
194
|
+
Have an admin with cluster access get the install command from Radar Cloud: it asks for a cluster name, then
|
|
195
|
+
shows the Helm, Argo CD or Flux instructions to review before running.
|
|
196
|
+
</>
|
|
197
|
+
)
|
|
198
|
+
const genericNext = (
|
|
199
|
+
<>
|
|
200
|
+
Have an admin with cluster access resolve the issue above, then connect — or recover — this cluster from Radar
|
|
201
|
+
Cloud.
|
|
202
|
+
</>
|
|
203
|
+
)
|
|
204
|
+
const unknownNext = (
|
|
205
|
+
<>
|
|
206
|
+
Have an admin with cluster access connect it from Radar Cloud.{' '}
|
|
207
|
+
{a?.partialScan ? (
|
|
208
|
+
<>
|
|
209
|
+
Radar could only check namespace{' '}
|
|
210
|
+
<code className="font-mono text-[11px] text-theme-text-primary">{a.namespace}</code> for an existing install,
|
|
211
|
+
so they should check the rest of the cluster first.
|
|
212
|
+
</>
|
|
213
|
+
) : (
|
|
214
|
+
<>Radar couldn’t tell whether it is already installed here, so they should check first.</>
|
|
215
|
+
)}
|
|
216
|
+
</>
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if (blocked.reason === 'gitops') {
|
|
220
|
+
return {
|
|
221
|
+
title: 'This install is managed by GitOps',
|
|
222
|
+
tried,
|
|
223
|
+
why: blocked.message,
|
|
224
|
+
next: exit.install ? (
|
|
225
|
+
<>
|
|
226
|
+
Have an admin with repo access get the values patch from Radar Cloud: it asks for a cluster name, then shows
|
|
227
|
+
the patch for your controller and the one command that creates the token Secret.
|
|
228
|
+
</>
|
|
229
|
+
) : (
|
|
230
|
+
genericNext
|
|
231
|
+
),
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (blocked.reason === 'unsupported') {
|
|
235
|
+
return { title: 'Radar can’t connect this cluster from here', tried, why: blocked.message, next: genericNext }
|
|
236
|
+
}
|
|
237
|
+
// Fresh offered on "nothing running" alone: say what was not confirmed.
|
|
238
|
+
const unconfirmedNext = (
|
|
239
|
+
<>
|
|
240
|
+
Radar found no Radar running in this cluster but couldn’t read Helm’s release records, so an admin with cluster
|
|
241
|
+
access should confirm nothing is installed before running a fresh install. {installNext}
|
|
242
|
+
</>
|
|
243
|
+
)
|
|
244
|
+
const next = exit.install ? (a?.releaseUnread ? unconfirmedNext : installNext) : unknownNext
|
|
245
|
+
switch (blocked.cause) {
|
|
246
|
+
case 'permissions':
|
|
247
|
+
return {
|
|
248
|
+
title: 'Your Kubernetes identity can’t do this install',
|
|
249
|
+
tried,
|
|
250
|
+
why: 'Your credentials lack permissions this needs (below). Anyone with them can complete this exact install.',
|
|
251
|
+
next,
|
|
252
|
+
}
|
|
253
|
+
case 'verification':
|
|
254
|
+
return {
|
|
255
|
+
title: 'This version of Radar can’t install this chart version from here',
|
|
256
|
+
tried,
|
|
257
|
+
why: 'The chart renders something this Radar build can’t check before applying, so it won’t install it unseen. A limitation of this Radar, not of your cluster or your access.',
|
|
258
|
+
next,
|
|
259
|
+
}
|
|
260
|
+
default:
|
|
261
|
+
return {
|
|
262
|
+
title: 'The cluster blocked part of this install',
|
|
263
|
+
tried,
|
|
264
|
+
why: 'The cluster refused: something already there conflicts with the install, or a policy rejects it. More permission wouldn’t change that.',
|
|
265
|
+
next: exit.install ? <>After the refusals above are resolved: {installNext}</> : next,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
133
270
|
function PlanCard({
|
|
134
271
|
status,
|
|
135
272
|
onStatus,
|
|
@@ -348,7 +485,7 @@ function ConsentRow({
|
|
|
348
485
|
checked: boolean
|
|
349
486
|
onChange: (v: boolean) => void
|
|
350
487
|
tone: 'emerald' | 'amber'
|
|
351
|
-
children:
|
|
488
|
+
children: ReactNode
|
|
352
489
|
}) {
|
|
353
490
|
return (
|
|
354
491
|
<label className="mt-3 flex items-start gap-2 cursor-pointer">
|
|
@@ -5,6 +5,14 @@ import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Coll
|
|
|
5
5
|
import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
|
|
6
6
|
import { Tooltip } from './ui/Tooltip'
|
|
7
7
|
import { CloudConnectFlow } from './CloudConnectFlow'
|
|
8
|
+
import {
|
|
9
|
+
type Handoff,
|
|
10
|
+
exitFor,
|
|
11
|
+
handoffForBlocked,
|
|
12
|
+
handoffForPrepareError,
|
|
13
|
+
isHandoffOutcome,
|
|
14
|
+
signupUrlFor as buildSignupUrl,
|
|
15
|
+
} from './cloudConnectHandoff'
|
|
8
16
|
import { showApiError } from './ui/Toast'
|
|
9
17
|
import {
|
|
10
18
|
ApiError,
|
|
@@ -27,8 +35,10 @@ import {
|
|
|
27
35
|
//
|
|
28
36
|
// The only outbound call is the Hub's own copy, fetched when the dialog opens
|
|
29
37
|
// (never on a poll or a timer) and falling back per-field to the constants
|
|
30
|
-
// below.
|
|
31
|
-
//
|
|
38
|
+
// below. The request names the lane the footer renders and where this Radar
|
|
39
|
+
// runs, both closed enums. The links into the Hub carry utm_content naming
|
|
40
|
+
// the link that was clicked and, after an in-app attempt that did not connect,
|
|
41
|
+
// radar_outcome saying what happened; see cloudConnectHandoff.ts.
|
|
32
42
|
const FALLBACK_APP_URL = 'https://app.radarhq.io'
|
|
33
43
|
|
|
34
44
|
// Rendered until (or unless) the Hub states its own. Keeping the compiled-in
|
|
@@ -47,7 +57,15 @@ const DEFAULT_ASSURANCES = [
|
|
|
47
57
|
'SOC 2 Type II',
|
|
48
58
|
'3 clusters free, no card required',
|
|
49
59
|
]
|
|
50
|
-
|
|
60
|
+
// Other OSS surfaces (a GitOps app that deploys to another cluster, say)
|
|
61
|
+
// point at Radar Cloud by asking this button to open its dialog, so there
|
|
62
|
+
// is one pitch and one flow. The button is mounted whenever Radar runs
|
|
63
|
+
// standalone; embedded hosts never mount it and never dispatch this.
|
|
64
|
+
const OPEN_EVENT = 'radar:open-cloud-funnel'
|
|
65
|
+
|
|
66
|
+
export function openCloudFunnel() {
|
|
67
|
+
window.dispatchEvent(new Event(OPEN_EVENT))
|
|
68
|
+
}
|
|
51
69
|
const ABOUT_URL = 'https://radarhq.io/about'
|
|
52
70
|
const PRICING_URL = 'https://radarhq.io/pricing'
|
|
53
71
|
const SELF_HOSTED_DOCS_URL = 'https://radarhq.io/docs/cloud/self-hosted/'
|
|
@@ -79,25 +97,33 @@ export function CloudFunnelButton() {
|
|
|
79
97
|
const [seen, setSeen] = useState(readSeen)
|
|
80
98
|
const [inFlowView, setInFlowView] = useState(false)
|
|
81
99
|
const [blocked, setBlocked] = useState<CloudInstallBlocked | null>(null)
|
|
82
|
-
// Set once an in-app attempt has
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
100
|
+
// Set once an in-app attempt has ended without connecting, naming what
|
|
101
|
+
// happened (a flow failure kind, a blocked plan, a canceled plan, or the
|
|
102
|
+
// prepare call itself failing) and whether it is worth trying again. It
|
|
103
|
+
// relabels the CTA "Try again" for a retryable failure and lets the browser
|
|
104
|
+
// link say what the person is coming from. Survives closing the modal (the
|
|
105
|
+
// outcome belongs to the cluster, not the dialog session) but not a reload,
|
|
106
|
+
// and is reset below when the kubeconfig context changes — it must never
|
|
107
|
+
// describe a cluster the user has switched away from.
|
|
108
|
+
const [handoff, setHandoff] = useState<Handoff | null>(null)
|
|
109
|
+
const prepareFailed = handoff?.retryable ?? false
|
|
88
110
|
|
|
89
111
|
const capabilities = useCapabilities()
|
|
90
112
|
const lane = capabilities.data?.cloudConnect?.lane ?? 'wizard'
|
|
91
113
|
const appUrl = capabilities.data?.cloudConnect?.appUrl || FALLBACK_APP_URL
|
|
92
|
-
// utm_content
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
114
|
+
// utm_content names the link that was clicked. It travels only in the link
|
|
115
|
+
// the user opens; Radar sends nothing on its own. Only the blocked card may
|
|
116
|
+
// deep-link the install page (see exitFor); the pitch buttons and the
|
|
117
|
+
// footer link go to signup.
|
|
118
|
+
const signupUrl = buildSignupUrl(appUrl, 'wizard-signup-button')
|
|
96
119
|
|
|
97
120
|
// Only while the dialog is open — never on the capabilities poll. The Hub
|
|
98
121
|
// learns that someone opened it, which is congruent with what the dialog is
|
|
99
122
|
// for; it must not learn that Radar is merely running.
|
|
100
|
-
const connectInfo = useCloudConnectInfo(capabilities.data?.cloudConnect?.apiUrl, open
|
|
123
|
+
const connectInfo = useCloudConnectInfo(capabilities.data?.cloudConnect?.apiUrl, open, {
|
|
124
|
+
lane,
|
|
125
|
+
mode: capabilities.data?.deployment?.mode,
|
|
126
|
+
})
|
|
101
127
|
|
|
102
128
|
// In-cluster Radar can't install its own connection, but it knows exactly
|
|
103
129
|
// which install it is — so the wizard link can carry the real target, and a
|
|
@@ -135,7 +161,7 @@ export function CloudFunnelButton() {
|
|
|
135
161
|
// Anything else failed before a flow existed. Return to the pitch rather
|
|
136
162
|
// than leaving the flow view armed, where a later status change would
|
|
137
163
|
// pull the user into a screen they did not ask for.
|
|
138
|
-
exitFlow(
|
|
164
|
+
exitFlow(handoffForPrepareError(err))
|
|
139
165
|
showApiError('Could not inspect this cluster for Cloud connect', err instanceof Error ? err.message : undefined)
|
|
140
166
|
},
|
|
141
167
|
// No meta.errorMessage: the global handler cannot tell a single-flight 409
|
|
@@ -153,33 +179,65 @@ export function CloudFunnelButton() {
|
|
|
153
179
|
|
|
154
180
|
const startConnect = () => {
|
|
155
181
|
setBlocked(null)
|
|
156
|
-
|
|
182
|
+
setHandoff(null)
|
|
157
183
|
setInFlowView(true)
|
|
158
184
|
prepare.mutate()
|
|
159
185
|
}
|
|
160
186
|
|
|
161
|
-
// A flow that ended
|
|
162
|
-
// like a first attempt
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
187
|
+
// A flow that ended without connecting returns to a pitch whose CTA must
|
|
188
|
+
// not read like a first attempt, and whose browser link knows what happened.
|
|
189
|
+
// The caller passes the outcome because dismissing already overwrote the
|
|
190
|
+
// status by this point. Every exit sets it, null included, so a connected
|
|
191
|
+
// flow clears what an earlier attempt left behind.
|
|
192
|
+
const exitFlow = (outcome: Handoff | null = null) => {
|
|
193
|
+
setHandoff(outcome)
|
|
166
194
|
setInFlowView(false)
|
|
167
195
|
setBlocked(null)
|
|
168
196
|
}
|
|
169
197
|
|
|
198
|
+
// What the person is coming from. A blocked plan is read from local state,
|
|
199
|
+
// not the status: the status query keeps serving its last polled value
|
|
200
|
+
// (idle, usually) while the blocked view is up. A blocked plan counts as an
|
|
201
|
+
// attempt: the user tried the in-app path and was refused, and the wizard
|
|
202
|
+
// is the right next step for a GitOps-owned install. A failed flow carries
|
|
203
|
+
// the server's kind and its own verdict on retrying — the failed card
|
|
204
|
+
// already honors retrySafe, and the pitch must not contradict it two clicks
|
|
205
|
+
// later. Leaving a live plan is the user canceling it, not a failure. Only
|
|
206
|
+
// a connected flow, or no flow at all, leaves nothing to carry.
|
|
207
|
+
const outcomeOf = (st: CloudInstallStatus): Handoff | null => {
|
|
208
|
+
if (blocked) return handoffForBlocked(blocked.reason, blocked.attempted)
|
|
209
|
+
if (st.state === 'failed') {
|
|
210
|
+
return {
|
|
211
|
+
outcome: isHandoffOutcome(st.failure?.kind) ? st.failure.kind : 'failure_kind_unknown',
|
|
212
|
+
retryable: st.failure?.retrySafe ?? false,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (st.state === 'blocked' && st.blocked) return handoffForBlocked(st.blocked.reason, st.blocked.attempted)
|
|
216
|
+
if (st.state === 'connected' || st.state === 'idle') return null
|
|
217
|
+
return { outcome: 'install_plan_canceled', retryable: false }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
window.addEventListener(OPEN_EVENT, openModal)
|
|
222
|
+
return () => window.removeEventListener(OPEN_EVENT, openModal)
|
|
223
|
+
})
|
|
224
|
+
|
|
170
225
|
// Re-attach to a server-owned flow whenever one is observed while the modal
|
|
171
226
|
// is open — the status query may resolve after openModal ran.
|
|
172
227
|
useEffect(() => {
|
|
173
228
|
if (open && lane === 'driver' && flowLive) setInFlowView(true)
|
|
174
229
|
}, [open, lane, flowLive])
|
|
175
230
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// stays mounted, so without the reset cluster A's
|
|
179
|
-
// CTA for cluster B
|
|
231
|
+
// handoff and a blocked plan outlive the dialog but must not outlive the
|
|
232
|
+
// cluster they describe: a context switch swaps every query cache, yet this
|
|
233
|
+
// component stays mounted, so without the reset cluster A's outcome would
|
|
234
|
+
// relabel the CTA (and the link) for cluster B, and a blocked view left
|
|
235
|
+
// armed would reopen on B with A's refusal, feeding it back into the
|
|
236
|
+
// outcome on Back.
|
|
180
237
|
const contextName = useClusterInfo().data?.context
|
|
181
238
|
useEffect(() => {
|
|
182
|
-
|
|
239
|
+
setHandoff(null)
|
|
240
|
+
setBlocked(null)
|
|
183
241
|
}, [contextName])
|
|
184
242
|
|
|
185
243
|
// The server owns the "nothing to pitch" decision: an already-tunneled
|
|
@@ -248,9 +306,9 @@ export function CloudFunnelButton() {
|
|
|
248
306
|
<CloudConnectFlow
|
|
249
307
|
status={flowForView}
|
|
250
308
|
blocked={blocked}
|
|
251
|
-
|
|
309
|
+
exit={exitFor(appUrl, 'driver-blocked-card-browser-link', outcomeOf(flowForView))}
|
|
252
310
|
onStatus={applyStatus}
|
|
253
|
-
onExit={() => exitFlow(flowForView
|
|
311
|
+
onExit={() => exitFlow(outcomeOf(flowForView))}
|
|
254
312
|
/>
|
|
255
313
|
</div>
|
|
256
314
|
) : (
|
|
@@ -261,10 +319,12 @@ export function CloudFunnelButton() {
|
|
|
261
319
|
<ModalFooter
|
|
262
320
|
lane={lane}
|
|
263
321
|
signupUrl={signupUrl}
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
322
|
+
// One link name whether or not an attempt preceded the click; the
|
|
323
|
+
// outcome, when present, is what says an attempt happened.
|
|
324
|
+
driverBrowserUrl={buildSignupUrl(appUrl, 'driver-footer-browser-link', handoff)}
|
|
267
325
|
prepareFailed={prepareFailed}
|
|
326
|
+
// A prepare error's reason, kept on the pitch after its toast is gone.
|
|
327
|
+
prepareError={handoff?.detail}
|
|
268
328
|
assurances={connectInfo.data?.assurances}
|
|
269
329
|
notice={connectInfo.data?.notice}
|
|
270
330
|
self={inCluster ? self.data : undefined}
|
|
@@ -316,8 +376,9 @@ function Eyebrow() {
|
|
|
316
376
|
function ModalFooter({
|
|
317
377
|
lane,
|
|
318
378
|
signupUrl,
|
|
319
|
-
|
|
379
|
+
driverBrowserUrl,
|
|
320
380
|
prepareFailed,
|
|
381
|
+
prepareError,
|
|
321
382
|
assurances,
|
|
322
383
|
notice,
|
|
323
384
|
self,
|
|
@@ -327,10 +388,11 @@ function ModalFooter({
|
|
|
327
388
|
}: {
|
|
328
389
|
lane: 'driver' | 'wizard'
|
|
329
390
|
signupUrl: string
|
|
330
|
-
// Same destination as signupUrl, distinct utm_content
|
|
331
|
-
//
|
|
332
|
-
|
|
391
|
+
// Same destination as signupUrl, distinct utm_content, and the outcome of
|
|
392
|
+
// an in-app attempt when one preceded this render.
|
|
393
|
+
driverBrowserUrl: string
|
|
333
394
|
prepareFailed: boolean
|
|
395
|
+
prepareError?: string
|
|
334
396
|
// Live copy from the Hub; undefined until (or unless) it arrives.
|
|
335
397
|
assurances?: string[]
|
|
336
398
|
notice?: string
|
|
@@ -409,7 +471,7 @@ function ModalFooter({
|
|
|
409
471
|
a recovery path — install-averse operators need the door before
|
|
410
472
|
anything fails, or they close the modal instead. */}
|
|
411
473
|
<a
|
|
412
|
-
href={
|
|
474
|
+
href={driverBrowserUrl}
|
|
413
475
|
target="_blank"
|
|
414
476
|
rel="noopener noreferrer"
|
|
415
477
|
className="whitespace-nowrap text-[12.5px] text-theme-text-secondary hover:text-theme-text-primary hover:underline underline-offset-2 transition-colors"
|
|
@@ -433,6 +495,15 @@ function ModalFooter({
|
|
|
433
495
|
Maybe later
|
|
434
496
|
</button>
|
|
435
497
|
</div>
|
|
498
|
+
{/* The last attempt's stop, when Radar could not even inspect the
|
|
499
|
+
cluster: the toast that first said so is gone in seconds, and "Try
|
|
500
|
+
again" alone does not say what to try again for. The footer link
|
|
501
|
+
above is the way around it. */}
|
|
502
|
+
{lane === 'driver' && prepareError && (
|
|
503
|
+
<p className="mt-2.5 text-[11.5px] leading-relaxed text-amber-600 dark:text-amber-400">
|
|
504
|
+
Radar couldn’t inspect this cluster: {prepareError}
|
|
505
|
+
</p>
|
|
506
|
+
)}
|
|
436
507
|
{/* Mechanics, not marketing: a falsifiable claim the plan card then
|
|
437
508
|
fulfills. Sits next to the button whose click it de-risks. */}
|
|
438
509
|
{lane === 'driver' && (
|