@skyhook-io/radar-app 1.13.4 → 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/RadarApp.tsx +5 -0
- package/src/api/client.authRedirect.test.ts +12 -1
- package/src/api/client.prometheusStatus.test.ts +24 -0
- package/src/api/client.ts +325 -16
- package/src/api/diagnose.ts +62 -15
- 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/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- 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/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseContext.tsx +2 -1
- package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
- package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
- package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
- package/src/components/diagnose/InvestigationView.tsx +222 -5
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
- package/src/components/diagnose/investigationCase.test.tsx +1568 -0
- package/src/components/diagnose/investigationCase.ts +439 -0
- 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/investigationEvidenceKinds.ts +218 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
- package/src/components/diagnose/investigationMetrics.test.ts +712 -0
- package/src/components/diagnose/investigationMetrics.ts +393 -0
- package/src/components/diagnose/investigationSourceFocus.ts +2 -0
- package/src/components/diagnose/investigationState.test.ts +322 -0
- package/src/components/diagnose/investigationState.ts +147 -25
- package/src/components/diagnose/parts.test.tsx +482 -0
- package/src/components/diagnose/parts.tsx +418 -41
- 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 +245 -103
- 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/PodFilePreview.test.tsx +131 -0
- package/src/components/resources/PodFilePreview.tsx +394 -0
- package/src/components/resources/PodFilesystemModal.tsx +157 -67
- 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.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +15 -2
- package/src/index.css +17 -0
- package/src/index.ts +1 -0
- package/src/utils/navigation.ts +1 -1
- package/src/utils/shell-safe.test.ts +25 -1
- package/src/utils/shell-safe.ts +16 -0
- 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 -5202
- package/src/components/diagnose/investigationEvidence.ts +0 -4784
- 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,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
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { clsx } from "clsx";
|
|
2
|
+
import { Sparkles } from "lucide-react";
|
|
3
|
+
import { Badge } from "@skyhook-io/k8s-ui";
|
|
4
|
+
|
|
5
|
+
import type { DiagnosisEvidenceRole } from "../../api/diagnose";
|
|
6
|
+
import { Tooltip } from "../ui/Tooltip";
|
|
7
|
+
|
|
8
|
+
export const AGENT_ROLE_LABELS: Readonly<
|
|
9
|
+
Record<DiagnosisEvidenceRole, string>
|
|
10
|
+
> = {
|
|
11
|
+
cause: "Cause",
|
|
12
|
+
symptom: "Symptom",
|
|
13
|
+
context: "Context",
|
|
14
|
+
benign: "Not a problem",
|
|
15
|
+
demoted: "Less relevant",
|
|
16
|
+
rules_out: "Rules out",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The agent's framing of a Radar fact. Always the agent tone, never a
|
|
21
|
+
* severity tone: a chip must not read as a Radar finding.
|
|
22
|
+
*/
|
|
23
|
+
export function AgentRoleChip({ role }: { role: DiagnosisEvidenceRole }) {
|
|
24
|
+
return (
|
|
25
|
+
<Tooltip
|
|
26
|
+
content={`The agent labelled this evidence "${AGENT_ROLE_LABELS[role]}". Radar recorded the fact; the label is the agent's reading of it.`}
|
|
27
|
+
// The label has to share the sentence's baseline. Centring the badge box
|
|
28
|
+
// drops it ~1px (padding and border make the box taller than the line),
|
|
29
|
+
// and the badge's own baseline comes from the sparkle, landing 1px high.
|
|
30
|
+
wrapperClassName="shrink-0 align-[-1px]"
|
|
31
|
+
>
|
|
32
|
+
<Badge tone="agent" size="sm">
|
|
33
|
+
<Sparkles className="h-2.5 w-2.5 shrink-0" aria-hidden />
|
|
34
|
+
{AGENT_ROLE_LABELS[role]}
|
|
35
|
+
</Badge>
|
|
36
|
+
</Tooltip>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One sentence from the agent about a Radar fact. Everything the agent
|
|
42
|
+
* contributed to a card lives in this one row — the sparkle, the role, and the
|
|
43
|
+
* sentence — so the register is unmistakable: the fact above is Radar's, this
|
|
44
|
+
* line is the agent's reading of it. The role travelled with the card title
|
|
45
|
+
* once, where it sat in the same slot as Radar's own badges and could be read
|
|
46
|
+
* as Radar having established it.
|
|
47
|
+
*
|
|
48
|
+
* A `rules_out` note carries the hypothesis it excludes, as text beside the
|
|
49
|
+
* chip rather than inside it: "Rules out" alone forces the reader to find the
|
|
50
|
+
* hypothesis in a block further down and match it back, and a hypothesis long
|
|
51
|
+
* enough to be useful makes a badge that swallows the row.
|
|
52
|
+
*/
|
|
53
|
+
export function AgentClaimNote({
|
|
54
|
+
claim,
|
|
55
|
+
role,
|
|
56
|
+
excludes,
|
|
57
|
+
subject,
|
|
58
|
+
className,
|
|
59
|
+
}: {
|
|
60
|
+
claim: string;
|
|
61
|
+
role?: DiagnosisEvidenceRole;
|
|
62
|
+
/**
|
|
63
|
+
* The hypothesis this item excludes. Rendered only for `rules_out`, where
|
|
64
|
+
* the chip frames it as rejected: a hypothesis is a claim the agent
|
|
65
|
+
* DISPROVED, so on any other card it would read as a statement of fact.
|
|
66
|
+
*/
|
|
67
|
+
excludes?: string;
|
|
68
|
+
/** The observation the note was bound to, when it is not on that card. */
|
|
69
|
+
subject?: string;
|
|
70
|
+
className?: string;
|
|
71
|
+
}) {
|
|
72
|
+
if (!claim && !role) return null;
|
|
73
|
+
return (
|
|
74
|
+
<p
|
|
75
|
+
data-agent-claim
|
|
76
|
+
className={clsx(
|
|
77
|
+
"flex min-w-0 items-start gap-2 border-t border-dashed border-theme-border/70 text-xs leading-relaxed text-theme-text-secondary",
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
|
|
82
|
+
{/* The chip and its sparkle carry this visually; the word is what
|
|
83
|
+
survives when colour and iconography do not. "Agent" rather than
|
|
84
|
+
"Agent's note" because a role can arrive with no sentence. */}
|
|
85
|
+
<span className="sr-only">Agent: </span>
|
|
86
|
+
{role ? (
|
|
87
|
+
<>
|
|
88
|
+
<AgentRoleChip role={role} />{" "}
|
|
89
|
+
</>
|
|
90
|
+
) : null}
|
|
91
|
+
{excludes && role === "rules_out" ? (
|
|
92
|
+
<>
|
|
93
|
+
<span className="italic text-theme-text-tertiary">{excludes}</span>
|
|
94
|
+
{" — "}
|
|
95
|
+
</>
|
|
96
|
+
) : null}
|
|
97
|
+
{claim ? (
|
|
98
|
+
<>
|
|
99
|
+
{subject ? (
|
|
100
|
+
<span className="text-theme-text-tertiary">{subject} · </span>
|
|
101
|
+
) : null}
|
|
102
|
+
{renderClaim(claim)}
|
|
103
|
+
</>
|
|
104
|
+
) : null}
|
|
105
|
+
</span>
|
|
106
|
+
</p>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Claims are one sentence of prose in which the agent marks identifiers with
|
|
112
|
+
* backticks. Only that inline-code span is honoured: a full Markdown render
|
|
113
|
+
* would wrap the sentence in a block and break the shared baseline with the
|
|
114
|
+
* role chip and the lead-in.
|
|
115
|
+
*/
|
|
116
|
+
function renderClaim(claim: string) {
|
|
117
|
+
const parts = claim.split("`");
|
|
118
|
+
if (parts.length < 3) return claim;
|
|
119
|
+
return parts.map((part, i) =>
|
|
120
|
+
i % 2 === 1 && i < parts.length - 1 ? (
|
|
121
|
+
<code
|
|
122
|
+
key={i}
|
|
123
|
+
className="inline-code rounded border border-theme-border/60 bg-theme-base px-1 py-px font-mono text-[11px] font-normal text-theme-text-primary"
|
|
124
|
+
>
|
|
125
|
+
{part}
|
|
126
|
+
</code>
|
|
127
|
+
) : (
|
|
128
|
+
part
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -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,
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
INVESTIGATION_HISTORY_MIN_WIDTH,
|
|
5
5
|
MAXIMIZED_RUN_META_VISIBILITY_CLASS,
|
|
6
6
|
canRerunInvestigation,
|
|
7
|
-
canCopyRunLink,
|
|
8
7
|
investigationHistoryIsPersistent,
|
|
9
8
|
investigationHeaderPresentation,
|
|
10
9
|
openInvestigationEvidenceResource,
|
|
@@ -337,25 +336,6 @@ describe("canContinueInvestigation", () => {
|
|
|
337
336
|
});
|
|
338
337
|
});
|
|
339
338
|
|
|
340
|
-
describe("canCopyRunLink", () => {
|
|
341
|
-
it("does not expose collaboration UI for an OSS run", () => {
|
|
342
|
-
expect(canCopyRunLink(run("done"))).toBe(false);
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
it("exposes the copy action only for a canonical hosted URL", () => {
|
|
346
|
-
expect(
|
|
347
|
-
canCopyRunLink({
|
|
348
|
-
...run("done"),
|
|
349
|
-
radarUrl: "/c/cluster-1?org=org-1&ai-run=r1",
|
|
350
|
-
}),
|
|
351
|
-
).toBe(true);
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
it("treats an empty hosted URL as unavailable", () => {
|
|
355
|
-
expect(canCopyRunLink({ ...run("done"), radarUrl: "" })).toBe(false);
|
|
356
|
-
});
|
|
357
|
-
});
|
|
358
|
-
|
|
359
339
|
describe("canInvestigateFurther", () => {
|
|
360
340
|
it("offers a context-preserving next step on a completed automatic investigation", () => {
|
|
361
341
|
expect(
|