@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.
Files changed (201) hide show
  1. package/package.json +8 -8
  2. package/src/App.tsx +60 -24
  3. package/src/api/client.authRedirect.test.ts +12 -1
  4. package/src/api/client.prometheusStatus.test.ts +24 -0
  5. package/src/api/client.ts +321 -16
  6. package/src/api/diagnose.ts +1 -0
  7. package/src/api/drain-plan.test.ts +86 -0
  8. package/src/api/quotas.test.ts +26 -0
  9. package/src/api/quotas.ts +20 -0
  10. package/src/api/workloadMetrics.test.ts +40 -0
  11. package/src/api/workloadMetrics.ts +68 -0
  12. package/src/components/CloudConnectFlow.tsx +179 -42
  13. package/src/components/CloudFunnelButton.tsx +107 -36
  14. package/src/components/SyncProgressPanel.tsx +73 -0
  15. package/src/components/audit/AuditView.tsx +2 -0
  16. package/src/components/cloudConnectHandoff.test.ts +140 -0
  17. package/src/components/cloudConnectHandoff.ts +124 -0
  18. package/src/components/diagnose/DiagnoseContext.tsx +2 -1
  19. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  20. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  21. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  22. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  23. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  24. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  25. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  26. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  28. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  29. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  31. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  35. package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
  36. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  37. package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
  38. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  39. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  40. package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
  41. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  42. package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
  43. package/src/components/diagnose/investigationEvidence/types.ts +512 -0
  44. package/src/components/diagnose/target.test.ts +19 -0
  45. package/src/components/diagnose/target.ts +3 -2
  46. package/src/components/gitops/GitOpsView.tsx +7 -0
  47. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  48. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  49. package/src/components/resource/PrometheusCharts.tsx +4 -3
  50. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  51. package/src/components/resource/PrometheusChartsGrid.tsx +75 -35
  52. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  53. package/src/components/resource/RightsizingStrip.tsx +15 -3
  54. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  55. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  56. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  57. package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
  58. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  59. package/src/components/resource/workloadMetricValues.ts +30 -0
  60. package/src/components/resources/ResourcesView.tsx +44 -6
  61. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  63. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  64. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  65. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  66. package/src/components/settings/SettingsDialog.tsx +94 -14
  67. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  68. package/src/components/traffic/TrafficGraph.tsx +6 -6
  69. package/src/components/traffic/TrafficView.tsx +10 -9
  70. package/src/components/traffic/trafficFilters.test.ts +28 -1
  71. package/src/components/traffic/trafficFilters.ts +31 -0
  72. package/src/components/workload/WorkloadView.tsx +19 -8
  73. package/src/context/ConnectionContext.tsx +48 -3
  74. package/src/context/DiagnoseCustomization.tsx +1 -0
  75. package/src/index.css +17 -0
  76. package/src/utils/navigation.ts +1 -1
  77. package/src/utils/topology-namespace.test.ts +64 -0
  78. package/src/utils/topology-namespace.ts +18 -0
  79. package/src/components/diagnose/investigationEvidence.test.ts +0 -6617
  80. package/src/components/diagnose/investigationEvidence.ts +0 -5572
  81. package/src/components/gitops/GitOpsActions.tsx +0 -1
  82. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  83. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  84. package/src/components/gitops/SyncCountdown.tsx +0 -1
  85. package/src/components/gitops/index.ts +0 -4
  86. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  87. package/src/components/logs/JsonLogLine.tsx +0 -1
  88. package/src/components/logs/LogCore.tsx +0 -2
  89. package/src/components/logs/useLogBuffer.ts +0 -2
  90. package/src/components/logs/useLogSearch.ts +0 -1
  91. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  92. package/src/components/resources/drawer-components.tsx +0 -1
  93. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  94. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  95. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  96. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  97. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  98. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  99. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  100. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  101. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  102. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  103. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  104. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  105. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  106. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  107. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  108. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  109. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  110. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  111. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  112. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  113. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  114. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  115. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  116. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  117. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  118. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  119. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  120. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  121. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  122. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  123. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  124. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  125. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  126. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  127. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  128. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  129. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  130. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  131. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  132. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  133. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  134. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  162. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  169. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  170. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  171. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  172. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  173. package/src/components/resources/renderers/index.ts +0 -94
  174. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  175. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  176. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  177. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  178. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  179. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  180. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  181. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  182. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  183. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  184. package/src/components/resources/resource-utils-argo.ts +0 -2
  185. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  186. package/src/components/resources/resource-utils-eso.ts +0 -2
  187. package/src/components/resources/resource-utils-flux.ts +0 -2
  188. package/src/components/resources/resource-utils-istio.ts +0 -2
  189. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  190. package/src/components/resources/resource-utils-knative.ts +0 -2
  191. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  192. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  193. package/src/components/resources/resource-utils-traefik.ts +0 -1
  194. package/src/components/resources/resource-utils-trivy.ts +0 -2
  195. package/src/components/resources/resource-utils-velero.ts +0 -2
  196. package/src/components/shared/EditableYamlView.tsx +0 -24
  197. package/src/components/timeline/DiffViewer.tsx +0 -1
  198. package/src/components/traffic/index.ts +0 -3
  199. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  200. package/src/components/ui/MetricsChart.tsx +0 -1
  201. 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
+ }