@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,73 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { Loader2, ArrowRight } from 'lucide-react'
|
|
3
|
+
import { StatusDot } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import type { SyncStatusSnapshot } from '../api/client'
|
|
5
|
+
|
|
6
|
+
// Shown in place of views that need the full cluster dataset while the
|
|
7
|
+
// initial informer sync is still running. Resource views for already-synced
|
|
8
|
+
// kinds work during this window — the per-kind list doubles as the map of
|
|
9
|
+
// what's usable right now.
|
|
10
|
+
export function SyncProgressPanel({
|
|
11
|
+
syncStatus,
|
|
12
|
+
onNavigateToKind,
|
|
13
|
+
}: {
|
|
14
|
+
syncStatus: SyncStatusSnapshot
|
|
15
|
+
onNavigateToKind: (key: string) => void
|
|
16
|
+
}) {
|
|
17
|
+
const total = syncStatus.criticalTotal + syncStatus.deferredTotal
|
|
18
|
+
const synced = syncStatus.criticalSynced + syncStatus.deferredSynced
|
|
19
|
+
const kinds = useMemo(
|
|
20
|
+
() => [...syncStatus.kinds].sort((a, b) => {
|
|
21
|
+
// Ready first, failed last, both alphabetical within their group.
|
|
22
|
+
const rank = (k: typeof a) => (k.synced ? 0 : k.failed ? 2 : 1)
|
|
23
|
+
if (rank(a) !== rank(b)) return rank(a) - rank(b)
|
|
24
|
+
return a.kind.localeCompare(b.kind)
|
|
25
|
+
}),
|
|
26
|
+
[syncStatus.kinds],
|
|
27
|
+
)
|
|
28
|
+
const failedCount = useMemo(
|
|
29
|
+
() => syncStatus.kinds.filter((k) => k.failed).length,
|
|
30
|
+
[syncStatus.kinds],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex-1 flex items-center justify-center overflow-y-auto bg-theme-base">
|
|
35
|
+
<div className="max-w-xl w-full px-8 py-10">
|
|
36
|
+
<div className="flex items-center gap-3 mb-1">
|
|
37
|
+
<Loader2 className="w-5 h-5 animate-spin text-theme-text-tertiary" />
|
|
38
|
+
<h2 className="text-lg font-semibold text-theme-text-primary">Loading cluster data</h2>
|
|
39
|
+
</div>
|
|
40
|
+
<p className="text-sm text-theme-text-secondary mb-6">
|
|
41
|
+
{synced} of {total} resource types ready — views open as their data finishes loading.
|
|
42
|
+
This view needs the full dataset and will appear automatically.
|
|
43
|
+
{failedCount > 0 && (
|
|
44
|
+
<span className="block mt-1 text-red-400">
|
|
45
|
+
{failedCount} resource {failedCount === 1 ? 'type' : 'types'} failed to load within the
|
|
46
|
+
sync deadline — affected views will show an error. Retry the connection to try again.
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
</p>
|
|
50
|
+
<div className="card-inner grid grid-cols-2 gap-x-6 gap-y-1.5 max-h-80 overflow-y-auto">
|
|
51
|
+
{kinds.map((k) => (
|
|
52
|
+
<div key={k.key} className="flex items-center gap-2 min-w-0 text-sm">
|
|
53
|
+
<StatusDot tone={k.synced ? 'healthy' : k.failed ? 'unhealthy' : 'neutral'} size="sm" className="shrink-0" />
|
|
54
|
+
{k.synced ? (
|
|
55
|
+
<button
|
|
56
|
+
onClick={() => onNavigateToKind(k.key)}
|
|
57
|
+
className="group flex items-center gap-1 min-w-0 text-theme-text-primary hover:text-theme-brand transition-colors"
|
|
58
|
+
>
|
|
59
|
+
<span className="truncate">{k.kind}</span>
|
|
60
|
+
<ArrowRight className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
|
61
|
+
</button>
|
|
62
|
+
) : k.failed ? (
|
|
63
|
+
<span className="truncate text-red-400">{k.kind} — failed</span>
|
|
64
|
+
) : (
|
|
65
|
+
<span className="truncate text-theme-text-tertiary animate-pulse">{k.kind}</span>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -124,6 +124,8 @@ function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps)
|
|
|
124
124
|
checks={data.groupedChecks ?? []}
|
|
125
125
|
catalog={data.checks ?? {}}
|
|
126
126
|
anyData
|
|
127
|
+
evaluated={data.summary.passing + data.summary.warning + data.summary.danger}
|
|
128
|
+
missingInputs={data.missingInputs}
|
|
127
129
|
onResourceClick={onResourceClick}
|
|
128
130
|
onHideCheck={canEdit ? hideCheck : undefined}
|
|
129
131
|
onHideCategory={canEdit ? hideCategory : undefined}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { ApiError, type CloudInstallAttempted } from '../api/client'
|
|
3
|
+
import { exitFor, handoffForBlocked, handoffForPrepareError, isHandoffOutcome, signupUrlFor } from './cloudConnectHandoff'
|
|
4
|
+
|
|
5
|
+
const APP = 'https://app.test.example'
|
|
6
|
+
const UTM = 'utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal'
|
|
7
|
+
const CARD = 'driver-blocked-card-browser-link'
|
|
8
|
+
|
|
9
|
+
const attempted = (over: Partial<CloudInstallAttempted>): CloudInstallAttempted => ({
|
|
10
|
+
mode: 'fresh',
|
|
11
|
+
namespace: 'radar',
|
|
12
|
+
release: 'radar',
|
|
13
|
+
stage: 'preflight',
|
|
14
|
+
...over,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('signupUrlFor', () => {
|
|
18
|
+
it('tags the link and omits the outcome when there was no attempt', () => {
|
|
19
|
+
expect(signupUrlFor(APP, 'driver-footer-browser-link')).toBe(`${APP}/signup?${UTM}&utm_content=driver-footer-browser-link`)
|
|
20
|
+
expect(signupUrlFor(APP, 'driver-footer-browser-link', null)).not.toContain('radar_outcome')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('appends a well-formed outcome after an attempt', () => {
|
|
24
|
+
expect(signupUrlFor(APP, 'driver-footer-browser-link', { outcome: 'hub_connect_request_failed', retryable: true })).toMatch(
|
|
25
|
+
/utm_content=driver-footer-browser-link&radar_outcome=hub_connect_request_failed$/,
|
|
26
|
+
)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('never forwards an outcome outside the vocabulary shape', () => {
|
|
30
|
+
// A server message or a cluster name must not ride along in the link.
|
|
31
|
+
for (const bad of ['connect failed: dial tcp 10.0.0.1', 'Prod-East', '', 'a'.repeat(41), 'x&y=1']) {
|
|
32
|
+
expect(signupUrlFor(APP, 'driver-footer-browser-link', { outcome: bad, retryable: true })).not.toContain('radar_outcome')
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('exitFor — every blocked card has one exit, chosen by what Radar established', () => {
|
|
38
|
+
it('adopts a release Radar found, pinned to the Helm tab', () => {
|
|
39
|
+
const h = handoffForBlocked('preflight', attempted({ mode: 'adopt', namespace: 'monitoring', release: 'radar-prod', stage: 'inspect' }))
|
|
40
|
+
const exit = exitFor(APP, CARD, h)
|
|
41
|
+
expect(exit).toMatchObject({ install: true, label: 'Get the install command' })
|
|
42
|
+
expect(exit.href).toBe(
|
|
43
|
+
`${APP}/install?existing=1&ns=monitoring&release=radar-prod&method=helm&${UTM}&utm_content=${CARD}&radar_outcome=blocked_preflight_checks_failed`,
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('offers a fresh install only from a complete plan whose discovery saw the whole cluster', () => {
|
|
48
|
+
const fresh = exitFor(APP, CARD, handoffForBlocked('preflight', attempted({})))
|
|
49
|
+
expect(fresh).toMatchObject({ install: true, label: 'Get the install command' })
|
|
50
|
+
expect(fresh.href).toContain('/install?method=helm&')
|
|
51
|
+
expect(fresh.href).not.toContain('existing')
|
|
52
|
+
|
|
53
|
+
// A partial scan may have missed a Radar elsewhere; guessing fresh would
|
|
54
|
+
// reset its values, so the exit is Radar Cloud itself.
|
|
55
|
+
const partial = exitFor(APP, CARD, handoffForBlocked('preflight', attempted({ partialScan: true })))
|
|
56
|
+
expect(partial).toMatchObject({ install: false, label: 'Open Radar Cloud' })
|
|
57
|
+
expect(partial.href).toContain('/signup?')
|
|
58
|
+
expect(partial.href).toContain('radar_outcome=blocked_preflight_checks_failed')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('offers a fresh install when nothing is running and only the release records were unread', () => {
|
|
62
|
+
const exit = exitFor(APP, CARD, handoffForBlocked('preflight', attempted({ stage: 'inspect', releaseUnread: true })))
|
|
63
|
+
expect(exit).toMatchObject({ install: true, label: 'Get the install command' })
|
|
64
|
+
expect(exit.href).toContain('/install?method=helm&')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('sends an unknown target to Radar Cloud, never to a guessed install', () => {
|
|
68
|
+
const exit = exitFor(APP, CARD, handoffForBlocked('preflight'))
|
|
69
|
+
expect(exit).toMatchObject({ install: false, label: 'Open Radar Cloud' })
|
|
70
|
+
expect(exit.href).toContain('/signup?')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('hands a GitOps-managed release to its verified controller’s tab', () => {
|
|
74
|
+
for (const [method, label] of [
|
|
75
|
+
['argocd', 'Get the Argo CD values patch'],
|
|
76
|
+
['flux', 'Get the Flux values patch'],
|
|
77
|
+
] as const) {
|
|
78
|
+
const exit = exitFor(APP, CARD, handoffForBlocked('gitops', attempted({ mode: 'gitops', namespace: 'radar', release: 'radar', stage: 'inspect', method })))
|
|
79
|
+
expect(exit).toMatchObject({ install: true, label })
|
|
80
|
+
expect(exit.href).toContain(`/install?existing=1&ns=radar&release=radar&method=${method}&`)
|
|
81
|
+
}
|
|
82
|
+
// An unverified or unrecognized owner gets no patch for the wrong tool.
|
|
83
|
+
const unknownOwner = exitFor(APP, CARD, handoffForBlocked('gitops', attempted({ mode: 'gitops', stage: 'inspect', method: '' })))
|
|
84
|
+
expect(unknownOwner).toMatchObject({ install: false, label: 'Open Radar Cloud' })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('never turns an unsupported refusal into an install, even with a known release', () => {
|
|
88
|
+
// Preparation can refuse after inspection found a release (incompatible
|
|
89
|
+
// settings, a token Secret already present); the card still keeps that
|
|
90
|
+
// as evidence, but the exit is Radar Cloud, not an install over it.
|
|
91
|
+
const exit = exitFor(APP, CARD, handoffForBlocked('unsupported', attempted({ mode: 'adopt', stage: 'prepare' })))
|
|
92
|
+
expect(exit).toMatchObject({ install: false, label: 'Open Radar Cloud' })
|
|
93
|
+
expect(exit.href).toContain('radar_outcome=blocked_unsupported_install')
|
|
94
|
+
expect(exitFor(APP, CARD, handoffForBlocked('unsupported'))).toMatchObject({ install: false, label: 'Open Radar Cloud' })
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('handoffForPrepareError', () => {
|
|
99
|
+
it('separates no cluster, inspection failed, Radar unreachable, and an unusable response', () => {
|
|
100
|
+
expect(handoffForPrepareError(new ApiError('down', 503)).outcome).toBe('radar_not_connected_to_cluster')
|
|
101
|
+
expect(handoffForPrepareError(new ApiError('nope', 500)).outcome).toBe('cluster_inspect_failed')
|
|
102
|
+
expect(handoffForPrepareError(new TypeError('Failed to fetch')).outcome).toBe('radar_server_unreachable')
|
|
103
|
+
expect(handoffForPrepareError(new SyntaxError('Unexpected token <')).outcome).toBe('cluster_inspect_request_error')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('is worth trying again and keeps the message for the pitch', () => {
|
|
107
|
+
const h = handoffForPrepareError(new ApiError('Not connected to cluster', 503))
|
|
108
|
+
expect(h.retryable).toBe(true)
|
|
109
|
+
expect(h.detail).toBe('Not connected to cluster')
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('handoffForBlocked', () => {
|
|
114
|
+
it('names the refusal, does not offer a retry, and keeps what Radar attempted', () => {
|
|
115
|
+
expect(handoffForBlocked('gitops')).toEqual({ outcome: 'blocked_gitops_managed_install', retryable: false, target: null })
|
|
116
|
+
expect(handoffForBlocked('preflight')).toEqual({ outcome: 'blocked_preflight_checks_failed', retryable: false, target: null })
|
|
117
|
+
expect(handoffForBlocked('unsupported')).toEqual({ outcome: 'blocked_unsupported_install', retryable: false, target: null })
|
|
118
|
+
const a = attempted({ mode: 'adopt', partialScan: false })
|
|
119
|
+
expect(handoffForBlocked('preflight', a).target).toBe(a)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('isHandoffOutcome', () => {
|
|
124
|
+
it('accepts the server failure kinds verbatim', () => {
|
|
125
|
+
for (const kind of [
|
|
126
|
+
'hub_connect_request_failed',
|
|
127
|
+
'hub_approval_poll_failed',
|
|
128
|
+
'approval_rejected_in_browser',
|
|
129
|
+
'approval_window_expired',
|
|
130
|
+
'approved_but_credential_pickup_expired',
|
|
131
|
+
'approval_outcome_unknown',
|
|
132
|
+
'canceled_before_approval_page',
|
|
133
|
+
'canceled_after_approval',
|
|
134
|
+
'helm_provision_failed',
|
|
135
|
+
'installed_but_tunnel_not_confirmed',
|
|
136
|
+
]) {
|
|
137
|
+
expect(isHandoffOutcome(kind)).toBe(true)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ApiError, type CloudInstallAttempted, type CloudInstallBlocked } from '../api/client'
|
|
2
|
+
|
|
3
|
+
// The Cloud dialog's links into the Hub. utm_content names the link that was
|
|
4
|
+
// clicked. After an in-app connect attempt that did not end connected, the
|
|
5
|
+
// link also carries radar_outcome, a short phrase for what happened, so the
|
|
6
|
+
// wizard opens knowing what the person is coming from. The person sees both
|
|
7
|
+
// in the address bar, so every value reads as plain words about the flow —
|
|
8
|
+
// never an error message, never a cluster name. Anything that is not a short
|
|
9
|
+
// snake_case phrase is dropped here and again on the Hub.
|
|
10
|
+
export const SIGNUP_QUERY = '?utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal'
|
|
11
|
+
|
|
12
|
+
// What the person is coming from, whether the pitch should offer "Try
|
|
13
|
+
// again" (a failure can be retried; a refusal or the person's own cancel
|
|
14
|
+
// cannot be "tried again" without misreading it as something going wrong),
|
|
15
|
+
// and, for a blocked plan, the release Radar found so the card's install
|
|
16
|
+
// link adopts it instead of opening a fresh install over it.
|
|
17
|
+
export interface Handoff {
|
|
18
|
+
outcome: string
|
|
19
|
+
retryable: boolean
|
|
20
|
+
// What Radar attempted, when it got that far — the blocked card's evidence
|
|
21
|
+
// and the input to the exit it offers.
|
|
22
|
+
target?: CloudInstallAttempted | null
|
|
23
|
+
// A prepare error's message, shown on the pitch so the reason outlives the
|
|
24
|
+
// toast that first carried it.
|
|
25
|
+
detail?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Closed shape, not a closed list: flow failure kinds come from the server
|
|
29
|
+
// (hub_connect_request_failed, approval_window_expired, helm_provision_failed,
|
|
30
|
+
// …) and this frontend must not need a release to forward a new one.
|
|
31
|
+
const OUTCOME_SHAPE = /^[a-z][a-z_]{0,39}$/
|
|
32
|
+
|
|
33
|
+
export function isHandoffOutcome(value: unknown): value is string {
|
|
34
|
+
return typeof value === 'string' && OUTCOME_SHAPE.test(value)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The prepare POST failed before any flow existed, so there is no failure
|
|
38
|
+
// kind to forward; classify by HTTP class. Prepare never contacts the Hub: a
|
|
39
|
+
// 503 is Radar without a cluster (disconnected or still connecting), any other
|
|
40
|
+
// status is Radar itself declining or failing the inspection, a TypeError is
|
|
41
|
+
// fetch failing to reach Radar's own server, and anything else is a response
|
|
42
|
+
// that could not be used. All of them are worth trying again.
|
|
43
|
+
export function handoffForPrepareError(err: unknown): Handoff {
|
|
44
|
+
let outcome = 'cluster_inspect_request_error'
|
|
45
|
+
if (err instanceof ApiError) {
|
|
46
|
+
outcome = err.status === 503 ? 'radar_not_connected_to_cluster' : 'cluster_inspect_failed'
|
|
47
|
+
} else if (err instanceof TypeError) {
|
|
48
|
+
outcome = 'radar_server_unreachable'
|
|
49
|
+
}
|
|
50
|
+
return { outcome, retryable: true, detail: err instanceof Error ? err.message : undefined }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A blocked plan was refused for a stated reason and trying again reproduces
|
|
54
|
+
// it; the wizard is the next step, not a retry.
|
|
55
|
+
const BLOCKED_OUTCOMES: Record<CloudInstallBlocked['reason'], string> = {
|
|
56
|
+
gitops: 'blocked_gitops_managed_install',
|
|
57
|
+
preflight: 'blocked_preflight_checks_failed',
|
|
58
|
+
unsupported: 'blocked_unsupported_install',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function handoffForBlocked(reason: CloudInstallBlocked['reason'], target?: CloudInstallAttempted | null): Handoff {
|
|
62
|
+
return { outcome: BLOCKED_OUTCOMES[reason], retryable: false, target: target ?? null }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function signupUrlFor(appUrl: string, content: string, handoff?: Handoff | null): string {
|
|
66
|
+
const url = `${appUrl}/signup${SIGNUP_QUERY}&utm_content=${content}`
|
|
67
|
+
return handoff && isHandoffOutcome(handoff.outcome) ? `${url}&radar_outcome=${handoff.outcome}` : url
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The one exit every blocked card offers. An install-page deep link is
|
|
71
|
+
// justified only where Radar established both the target and the operation:
|
|
72
|
+
// a release it found (adopt, or its GitOps owner's values patch), or a fresh
|
|
73
|
+
// install from a complete plan whose discovery saw the whole cluster. The
|
|
74
|
+
// Hub's fresh command would reset an existing release's values and its script
|
|
75
|
+
// writes the token Secret before Helm runs, so a guessed target is never
|
|
76
|
+
// linked. Everywhere else the exit is Radar Cloud itself — the same
|
|
77
|
+
// signup/login entry as the pitch — and the copy hands the work to an admin.
|
|
78
|
+
// Signed out, the Hub stashes an /install link across sign-in only when it
|
|
79
|
+
// carries a target or a method, so every install link carries one.
|
|
80
|
+
export interface BlockedExit {
|
|
81
|
+
href: string
|
|
82
|
+
label: string
|
|
83
|
+
// True when href is the install page for an established target.
|
|
84
|
+
install: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function exitFor(appUrl: string, content: string, handoff: Handoff | null | undefined): BlockedExit {
|
|
88
|
+
const generic = { href: signupUrlFor(appUrl, content, handoff), label: 'Open Radar Cloud', install: false }
|
|
89
|
+
const t = handoff?.target
|
|
90
|
+
if (!t) return generic
|
|
91
|
+
// An unsupported refusal names its own remedy (several Radars to pick from,
|
|
92
|
+
// a pairing to recover, ownership Radar will not guess at); a release it
|
|
93
|
+
// happens to know is not an invitation to install over it.
|
|
94
|
+
if (handoff?.outcome === BLOCKED_OUTCOMES.unsupported) return generic
|
|
95
|
+
const params = new URLSearchParams()
|
|
96
|
+
switch (t.mode) {
|
|
97
|
+
case 'adopt':
|
|
98
|
+
params.set('existing', '1')
|
|
99
|
+
params.set('ns', t.namespace)
|
|
100
|
+
params.set('release', t.release)
|
|
101
|
+
params.set('method', 'helm')
|
|
102
|
+
return { href: installHref(appUrl, content, handoff, params), label: 'Get the install command', install: true }
|
|
103
|
+
case 'gitops':
|
|
104
|
+
if (t.method !== 'argocd' && t.method !== 'flux') return generic
|
|
105
|
+
params.set('existing', '1')
|
|
106
|
+
params.set('ns', t.namespace)
|
|
107
|
+
params.set('release', t.release)
|
|
108
|
+
params.set('method', t.method)
|
|
109
|
+
return {
|
|
110
|
+
href: installHref(appUrl, content, handoff, params),
|
|
111
|
+
label: `Get the ${t.method === 'argocd' ? 'Argo CD' : 'Flux'} values patch`,
|
|
112
|
+
install: true,
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
if (t.partialScan) return generic
|
|
116
|
+
params.set('method', 'helm')
|
|
117
|
+
return { href: installHref(appUrl, content, handoff, params), label: 'Get the install command', install: true }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function installHref(appUrl: string, content: string, handoff: Handoff | null | undefined, params: URLSearchParams): string {
|
|
122
|
+
const url = `${appUrl}/install?${params.toString()}&${SIGNUP_QUERY.slice(1)}&utm_content=${content}`
|
|
123
|
+
return handoff && isHandoffOutcome(handoff.outcome) ? `${url}&radar_outcome=${handoff.outcome}` : url
|
|
124
|
+
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
type ExecutionProfile,
|
|
37
37
|
} from "../../api/diagnose";
|
|
38
38
|
import { runTargetKey } from "./target";
|
|
39
|
+
import { knownKindForPluralWithGroup } from "../../utils/navigation";
|
|
39
40
|
|
|
40
41
|
export interface Target {
|
|
41
42
|
kind: string;
|
|
@@ -660,7 +661,7 @@ function RoutedDiagnoseProvider({
|
|
|
660
661
|
const startRunRef = useRef<(t: Target) => void>(() => {});
|
|
661
662
|
startRunRef.current = (t: Target) => {
|
|
662
663
|
const seq = ++startSeqRef.current;
|
|
663
|
-
createRun(t, {
|
|
664
|
+
createRun({ ...t, kind: knownKindForPluralWithGroup(t.kind, t.group) ?? t.kind }, {
|
|
664
665
|
agent: selectedAgent || undefined,
|
|
665
666
|
profile: hosted ? undefined : effectiveProfile,
|
|
666
667
|
model: model || undefined,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type IssueRecentChange } from "@skyhook-io/k8s-ui";
|
|
2
|
+
import {
|
|
3
|
+
ProjectionBuilder,
|
|
4
|
+
addChanges,
|
|
5
|
+
addNarrowHint,
|
|
6
|
+
changesSubjectFromArgs,
|
|
7
|
+
invalidPayload,
|
|
8
|
+
scopeFromArgs,
|
|
9
|
+
sourceArgsRelevance,
|
|
10
|
+
} from "../observations";
|
|
11
|
+
import { nonEmptyString, parseJSON, recentChange, record } from "../parse";
|
|
12
|
+
import { type InvestigationEvidenceSource } from "../types";
|
|
13
|
+
|
|
14
|
+
export function adaptChanges(
|
|
15
|
+
builder: ProjectionBuilder,
|
|
16
|
+
source: InvestigationEvidenceSource,
|
|
17
|
+
payload: unknown,
|
|
18
|
+
): void {
|
|
19
|
+
const value = record(payload);
|
|
20
|
+
// The producer serializes an empty result as a nil slice, which is null.
|
|
21
|
+
const changesRaw = value?.changes === null ? [] : value?.changes;
|
|
22
|
+
if (!value || !Array.isArray(changesRaw)) {
|
|
23
|
+
invalidPayload(builder, source, "Recent changes");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const changes = changesRaw
|
|
27
|
+
.map(recentChange)
|
|
28
|
+
.filter((item): item is IssueRecentChange => Boolean(item));
|
|
29
|
+
if (changes.length !== changesRaw.length) {
|
|
30
|
+
invalidPayload(builder, source, "Recent changes");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
addNarrowHint(builder, source, value);
|
|
34
|
+
let sourceErrors = 0;
|
|
35
|
+
if (Array.isArray(value.sourcesErrored)) {
|
|
36
|
+
for (const sourceError of value.sourcesErrored) {
|
|
37
|
+
if (nonEmptyString(sourceError)) {
|
|
38
|
+
sourceErrors += 1;
|
|
39
|
+
builder.limit(source, "Recent changes", sourceError, "error");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} else if (value.sourcesErrored !== undefined) {
|
|
43
|
+
invalidPayload(builder, source, "Recent changes source coverage");
|
|
44
|
+
}
|
|
45
|
+
// Reads of one resource's history are revisions of one card however the
|
|
46
|
+
// window or cap differs; the window is stated in the title instead.
|
|
47
|
+
const args = record(source.args ? parseJSON(source.args) : undefined);
|
|
48
|
+
addChanges(
|
|
49
|
+
builder,
|
|
50
|
+
source,
|
|
51
|
+
changes,
|
|
52
|
+
`changes:${scopeFromArgs(source)}`,
|
|
53
|
+
undefined,
|
|
54
|
+
!nonEmptyString(value.narrowHint) && sourceErrors === 0,
|
|
55
|
+
false,
|
|
56
|
+
sourceArgsRelevance(builder, source),
|
|
57
|
+
changesSubjectFromArgs(source),
|
|
58
|
+
nonEmptyString(args?.since) ? args.since : undefined,
|
|
59
|
+
);
|
|
60
|
+
}
|