@skyhook-io/radar-app 1.14.2 → 1.14.4

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 (83) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +32 -0
  3. package/src/api/diagnose.ts +7 -1
  4. package/src/components/CloudConnectFlow.tsx +21 -7
  5. package/src/components/CloudFunnelButton.tsx +85 -8
  6. package/src/components/cloudConnectHandoff.test.ts +10 -1
  7. package/src/components/cloudConnectHandoff.ts +15 -1
  8. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  9. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  10. package/src/components/diagnose/AgentControls.tsx +603 -0
  11. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  12. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  13. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  14. package/src/components/diagnose/AssessmentCard.tsx +1579 -0
  15. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  16. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  17. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  18. package/src/components/diagnose/Home.test.tsx +1 -3
  19. package/src/components/diagnose/Home.tsx +2 -1
  20. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  21. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  22. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  23. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  24. package/src/components/diagnose/InvestigationView.tsx +39 -51
  25. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  26. package/src/components/diagnose/assessmentCopy.ts +169 -0
  27. package/src/components/diagnose/investigationCase.test.tsx +658 -2
  28. package/src/components/diagnose/investigationCase.ts +135 -26
  29. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +682 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
  31. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
  32. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  34. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +136 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  41. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  47. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  48. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  49. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  50. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  51. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  52. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  53. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  54. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  55. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  57. package/src/components/diagnose/investigationEvidence/observations.ts +13 -0
  58. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  59. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  60. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  61. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  62. package/src/components/diagnose/investigationState.test.ts +2 -2
  63. package/src/components/diagnose/investigationState.ts +2 -2
  64. package/src/components/diagnose/investigationStory.ts +1 -1
  65. package/src/components/diagnose/parts.test.tsx +15 -1
  66. package/src/components/diagnose/parts.tsx +26 -3904
  67. package/src/components/diagnose/target.ts +9 -3
  68. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  69. package/src/components/diagnose/toolCallLabel.ts +41 -2
  70. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  71. package/src/components/home/MCPSetupDialog.tsx +1 -1
  72. package/src/components/home/mcpToolCatalog.ts +27 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  74. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  75. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  76. package/src/components/resources/PodFilePreview.tsx +1 -1
  77. package/src/components/rightsizing/RightsizingScanView.tsx +20 -1
  78. package/src/components/ui/CommandPalette.tsx +12 -8
  79. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  80. package/src/components/ui/Omnibar.tsx +96 -60
  81. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  82. package/src/components/workload/WorkloadView.tsx +7 -1
  83. package/src/components/ui/Disclosure.tsx +0 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.14.2",
3
+ "version": "1.14.4",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -41,7 +41,7 @@
41
41
  "yaml": "^2.9.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "@skyhook-io/k8s-ui": ">=1.14.7",
44
+ "@skyhook-io/k8s-ui": ">=1.14.8",
45
45
  "@tanstack/react-query": ">=5",
46
46
  "@xyflow/react": ">=12.0.0",
47
47
  "clsx": ">=2",
package/src/api/client.ts CHANGED
@@ -2048,6 +2048,36 @@ export function useCloudConnectSelf(enabled: boolean) {
2048
2048
  })
2049
2049
  }
2050
2050
 
2051
+ // What the driver-lane dialog learns about the cluster when it opens: the
2052
+ // chart's Deployments already carrying Cloud connection settings, so a
2053
+ // cluster that is already connected is pointed at rather than re-offered.
2054
+ export interface CloudInstallConnectedRadar {
2055
+ namespace: string
2056
+ deployment: string
2057
+ release?: string
2058
+ // The Hub the Deployment names, when it is not the one this Radar uses.
2059
+ hubHost?: string
2060
+ // The cluster's page in Radar Cloud, when its Hub is ours and its id is known.
2061
+ clusterUrl?: string
2062
+ }
2063
+
2064
+ export interface CloudInstallDiscovered {
2065
+ connected: CloudInstallConnectedRadar[]
2066
+ partialScan: boolean
2067
+ }
2068
+
2069
+ // Keyed by context: a switch must not describe the previous cluster.
2070
+ export function useCloudInstallDiscover(enabled: boolean, contextName: string | undefined) {
2071
+ return useQuery<CloudInstallDiscovered>({
2072
+ queryKey: ['cloud-install-discover', contextName],
2073
+ queryFn: () => fetchJSON('/cloud/install/discover'),
2074
+ enabled,
2075
+ staleTime: 0,
2076
+ gcTime: 0,
2077
+ retry: false,
2078
+ })
2079
+ }
2080
+
2051
2081
  export function prepareCloudInstall(): Promise<CloudInstallStatus> {
2052
2082
  return fetchJSON('/cloud/install/prepare', { method: 'POST' })
2053
2083
  }
@@ -3336,6 +3366,8 @@ export interface RightsizingScanCoverage {
3336
3366
  completedBatches: number;
3337
3367
  restrictedKinds?: string[];
3338
3368
  unavailableKinds?: string[];
3369
+ partiallyCachedKinds?: string[];
3370
+ daemonSetsWithoutNodes?: number;
3339
3371
  }
3340
3372
 
3341
3373
  export interface RightsizingScanResponse {
@@ -15,6 +15,10 @@ export interface AgentInfo {
15
15
  hosted?: boolean;
16
16
  /** Hosted backends opt in only when they implement assessment-bound, tool-free explanations. */
17
17
  assessmentExplanations?: boolean;
18
+ /** The backend performs a user-confirmed Apply turn. Absent means read-only. */
19
+ apply?: boolean;
20
+ /** The backend re-checks the resource after an Apply. Absent means it does not. */
21
+ verification?: boolean;
18
22
  }
19
23
 
20
24
  export interface AgentsResponse {
@@ -56,7 +60,7 @@ export interface RootCauseEvidence {
56
60
  * How the agent frames one cited Radar result. Roles order cards; they never
57
61
  * hide one.
58
62
  *
59
- * One half of a Go↔TS contract: `evidenceRoles` in internal/ai/parse.go and
63
+ * One half of a Go↔TS contract: `Roles` in pkg/investigation/verdict.go and
60
64
  * EVIDENCE_ROLES in components/diagnose/investigationCase.ts must list exactly
61
65
  * these roles. Change all three together.
62
66
  */
@@ -133,6 +137,8 @@ export interface Diagnosis {
133
137
  unlinkedEvidence?: number;
134
138
  /** The agent's evidence field was not a list, so none of it could be read. */
135
139
  evidenceMalformed?: boolean;
140
+ /** Valid next steps, open items and ruled-out hypotheses a count cap left out of this record. */
141
+ omittedEntries?: number;
136
142
  ruledOut?: DiagnosisRuledOut[];
137
143
  report: string;
138
144
  /** The agent's evidence ledger, written before its verdict block; shown in Activity, never in Findings. */
@@ -1,9 +1,10 @@
1
1
  import { type ReactNode, useEffect, useRef, useState } from 'react'
2
2
  import { useMutation } from '@tanstack/react-query'
3
3
  import { AlertTriangle, ArrowUpRight, Check, Copy, ExternalLink, GitBranch, Info, Loader2, ShieldAlert, X } from 'lucide-react'
4
- import { type AdminNoteContext, type BlockedExit, composeAdminNote, composeFailureNote, needsAdminHandoff } from './cloudConnectHandoff'
4
+ import { type AdminNoteContext, type BlockedExit, composeAdminNote, composeFailureNote, needsAdminHandoff, trimRefusal } from './cloudConnectHandoff'
5
5
  import { copyText } from '@skyhook-io/k8s-ui/utils/clipboard'
6
6
  import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
7
+ import { Tooltip } from './ui/Tooltip'
7
8
  import {
8
9
  ApiError,
9
10
  cancelCloudInstall,
@@ -264,15 +265,28 @@ function BlockedSection({ label, children }: { label: string; children: ReactNod
264
265
  )
265
266
  }
266
267
 
268
+ // Each line is a Go error chain; the card shows what was attempted and why
269
+ // it was refused, with the whole chain a hover away.
267
270
  function BlockingLines({ lines }: { lines: string[] }) {
268
271
  return (
269
272
  <ul className="mt-1.5 space-y-1 text-[11.5px] text-theme-text-tertiary">
270
- {lines.map((line) => (
271
- <li key={line} className="flex items-start gap-1.5">
272
- <span className="mt-[6px] w-1 h-1 rounded-full bg-amber-500 shrink-0" />
273
- {line}
274
- </li>
275
- ))}
273
+ {lines.map((line) => {
274
+ const shown = trimRefusal(line)
275
+ return (
276
+ <li key={line} className="flex items-start gap-1.5">
277
+ <span className="mt-[6px] w-1 h-1 rounded-full bg-amber-500 shrink-0" />
278
+ <Tooltip
279
+ content={line}
280
+ position="bottom"
281
+ className="max-w-md whitespace-normal"
282
+ // Off when the trim only added the period, so the hover never repeats the line.
283
+ disabled={shown === line.trim().replace(/\.?$/, '.')}
284
+ >
285
+ <span>{shown}</span>
286
+ </Tooltip>
287
+ </li>
288
+ )
289
+ })}
276
290
  </ul>
277
291
  )
278
292
  }
@@ -7,6 +7,7 @@ import { Tooltip } from './ui/Tooltip'
7
7
  import { CloudConnectFlow } from './CloudConnectFlow'
8
8
  import {
9
9
  type Handoff,
10
+ SELF_HOSTED_DOCS_URL,
10
11
  exitFor,
11
12
  handoffForBlocked,
12
13
  handoffForPrepareError,
@@ -18,12 +19,14 @@ import {
18
19
  ApiError,
19
20
  cloudInstallActive,
20
21
  type CloudConnectSelf,
22
+ type CloudInstallConnectedRadar,
21
23
  type CloudInstallBlocked,
22
24
  type CloudInstallStatus,
23
25
  prepareCloudInstall,
24
26
  useCapabilities,
25
27
  useCloudConnectInfo,
26
28
  useCloudConnectSelf,
29
+ useCloudInstallDiscover,
27
30
  useCloudInstallStatus,
28
31
  useClusterInfo,
29
32
  } from '../api/client'
@@ -68,7 +71,6 @@ export function openCloudFunnel() {
68
71
  }
69
72
  const ABOUT_URL = 'https://radarhq.io/about'
70
73
  const PRICING_URL = 'https://radarhq.io/pricing'
71
- const SELF_HOSTED_DOCS_URL = 'https://radarhq.io/docs/cloud/self-hosted/'
72
74
  const SEEN_KEY = 'radar.cloudFunnel.seen'
73
75
 
74
76
  // localStorage access can throw (SecurityError) where storage is denied —
@@ -109,6 +111,7 @@ export function CloudFunnelButton() {
109
111
  const prepareFailed = handoff?.retryable ?? false
110
112
 
111
113
  const capabilities = useCapabilities()
114
+ const clusterInfo = useClusterInfo()
112
115
  const lane = capabilities.data?.cloudConnect?.lane ?? 'wizard'
113
116
  const appUrl = capabilities.data?.cloudConnect?.appUrl || FALLBACK_APP_URL
114
117
  // utm_content names the link that was clicked. It travels only in the link
@@ -131,6 +134,20 @@ export function CloudFunnelButton() {
131
134
  const inCluster = capabilities.data?.deployment?.mode === 'in-cluster'
132
135
  const self = useCloudConnectSelf(open && inCluster)
133
136
 
137
+ // Each time the dialog opens, look for a Radar in the cluster that already
138
+ // carries Cloud settings — installed by a colleague after a handoff, or from
139
+ // another machine. The plan would refuse to install over it; better to say
140
+ // "already connected" and point at it than to offer a click that ends blocked.
141
+ const discovered = useCloudInstallDiscover(open && lane === 'driver', clusterInfo.data?.context)
142
+ // The server ranks these: one it can link to first. A failed lookup is
143
+ // not a verdict — the CTA returns and the plan does its own inspection —
144
+ // and neither is the previous opening's answer: this component outlives
145
+ // the dialog, so a reopen refetches over cached data, and the gate must
146
+ // hold for that refetch too (isFetching, not isPending) while a refetch
147
+ // that fails must not keep showing what it found last time.
148
+ const alreadyConnected = discovered.isError ? undefined : discovered.data?.connected[0]
149
+ const discoverPending = lane === 'driver' && discovered.isFetching
150
+
134
151
  // The flow is server-owned: polling here both drives the live progress view
135
152
  // and re-attaches to an ongoing flow after a reload or modal close.
136
153
  const flowStatus = useCloudInstallStatus(lane === 'driver')
@@ -234,7 +251,6 @@ export function CloudFunnelButton() {
234
251
  // relabel the CTA (and the link) for cluster B, and a blocked view left
235
252
  // armed would reopen on B with A's refusal, feeding it back into the
236
253
  // outcome on Back.
237
- const clusterInfo = useClusterInfo()
238
254
  const contextName = clusterInfo.data?.context
239
255
  useEffect(() => {
240
256
  setHandoff(null)
@@ -330,6 +346,11 @@ export function CloudFunnelButton() {
330
346
  assurances={connectInfo.data?.assurances}
331
347
  notice={connectInfo.data?.notice}
332
348
  self={inCluster ? self.data : undefined}
349
+ clusterName={clusterInfo.data?.context}
350
+ alreadyConnected={alreadyConnected}
351
+ connectedCount={alreadyConnected ? (discovered.data?.connected.length ?? 0) : 0}
352
+ discoverPending={discoverPending}
353
+ clustersUrl={`${appUrl}/clusters`}
333
354
  // Also covers the capabilities query: until it resolves, lane
334
355
  // defaults to wizard and Radar does not yet know it is
335
356
  // in-cluster, so the CTA would escape before classification.
@@ -385,6 +406,11 @@ function ModalFooter({
385
406
  notice,
386
407
  self,
387
408
  selfLoading,
409
+ clusterName,
410
+ alreadyConnected,
411
+ connectedCount = 0,
412
+ discoverPending = false,
413
+ clustersUrl,
388
414
  onConnect,
389
415
  onLater,
390
416
  }: {
@@ -402,6 +428,20 @@ function ModalFooter({
402
428
  self?: CloudConnectSelf
403
429
  // True while in-cluster self-classification is still in flight.
404
430
  selfLoading?: boolean
431
+ // The kubeconfig context, named under the CTA so the click is about a
432
+ // specific cluster — kept out of the button, whose width must not depend
433
+ // on the name.
434
+ clusterName?: string
435
+ // Driver lane: a Radar in the cluster that already carries Cloud settings.
436
+ alreadyConnected?: CloudInstallConnectedRadar
437
+ connectedCount?: number
438
+ // True while that lookup is in flight: a click now would start the very
439
+ // plan the lookup exists to pre-empt, and the flow view would then hide
440
+ // its answer.
441
+ discoverPending?: boolean
442
+ // The configured Hub's clusters list — where to look when an install's
443
+ // settings say it is connected but not where.
444
+ clustersUrl?: string
405
445
  onConnect: () => void
406
446
  onLater: () => void
407
447
  }) {
@@ -458,12 +498,48 @@ function ModalFooter({
458
498
  {notice && (
459
499
  <div className="mb-3.5 card-inner p-3 text-[12px] leading-relaxed text-theme-text-secondary">{notice}</div>
460
500
  )}
501
+ {lane === 'driver' && alreadyConnected && (
502
+ <div className="mb-3.5 card-inner p-3 text-[12px] leading-relaxed text-theme-text-secondary">
503
+ <span className="font-semibold text-theme-text-primary">This cluster is already connected to Radar Cloud.</span>{' '}
504
+ Found release{' '}
505
+ <code className="font-mono text-[11px] text-theme-text-primary">{alreadyConnected.release || alreadyConnected.deployment}</code>{' '}
506
+ in namespace <code className="font-mono text-[11px] text-theme-text-primary">{alreadyConnected.namespace}</code>{' '}
507
+ {alreadyConnected.hubHost ? (
508
+ <>
509
+ configured for a Radar Cloud at{' '}
510
+ <code className="font-mono text-[11px] text-theme-text-primary">{alreadyConnected.hubHost}</code> — open that one to see
511
+ this cluster.
512
+ </>
513
+ ) : alreadyConnected.clusterUrl ? (
514
+ <>configured to connect. Open Radar Cloud to see it; if it isn’t there, its page shows the recovery options.</>
515
+ ) : (
516
+ <>
517
+ with Cloud settings Radar can’t read from here. Look for it in Radar Cloud; if it isn’t there, an admin can
518
+ recover the pairing with <code className="font-mono text-[11px] text-theme-text-primary">radar cloud status</code>.
519
+ </>
520
+ )}
521
+ {connectedCount > 1 && <> {connectedCount - 1} more install{connectedCount > 2 ? 's' : ''} carry Cloud settings too.</>}
522
+ </div>
523
+ )}
461
524
  <div className="flex flex-wrap items-center gap-x-5 gap-y-2.5">
462
- {lane === 'driver' ? (
525
+ {lane === 'driver' && alreadyConnected ? (
526
+ // Another Hub is named, not linked: the link would open ours.
527
+ !alreadyConnected.hubHost && (
528
+ <a
529
+ href={alreadyConnected.clusterUrl || clustersUrl}
530
+ target="_blank"
531
+ rel="noopener noreferrer"
532
+ className="whitespace-nowrap px-6 py-2.5 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[14px] font-bold shadow-[0_0_22px_rgba(16,185,129,0.35)] hover:shadow-[0_0_30px_rgba(16,185,129,0.5)] hover:-translate-y-px transition-all"
533
+ >
534
+ {alreadyConnected.clusterUrl ? 'Open in Radar Cloud' : 'Open Radar Cloud'}
535
+ </a>
536
+ )
537
+ ) : lane === 'driver' ? (
463
538
  <>
464
539
  <button
465
540
  onClick={onConnect}
466
- className="whitespace-nowrap px-6 py-2.5 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[14px] font-bold shadow-[0_0_22px_rgba(16,185,129,0.35)] hover:shadow-[0_0_30px_rgba(16,185,129,0.5)] hover:-translate-y-px transition-all"
541
+ disabled={discoverPending}
542
+ className={`whitespace-nowrap px-6 py-2.5 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[14px] font-bold shadow-[0_0_22px_rgba(16,185,129,0.35)] hover:shadow-[0_0_30px_rgba(16,185,129,0.5)] hover:-translate-y-px transition-all ${discoverPending ? 'opacity-60 pointer-events-none' : ''}`}
467
543
  >
468
544
  {/* Trailing ellipsis: further input follows the click — the
469
545
  inspect step and a plan the user approves in the browser. */}
@@ -494,7 +570,7 @@ function ModalFooter({
494
570
  </a>
495
571
  )}
496
572
  <button onClick={onLater} className="ml-auto whitespace-nowrap text-[12px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
497
- Maybe later
573
+ {lane === 'driver' && alreadyConnected ? 'Close' : 'Maybe later'}
498
574
  </button>
499
575
  </div>
500
576
  {/* The last attempt's stop, when Radar could not even inspect the
@@ -508,10 +584,11 @@ function ModalFooter({
508
584
  )}
509
585
  {/* Mechanics, not marketing: a falsifiable claim the plan card then
510
586
  fulfills. Sits next to the button whose click it de-risks. */}
511
- {lane === 'driver' && (
587
+ {lane === 'driver' && !alreadyConnected && (
512
588
  <p className="mt-2.5 text-[11px] leading-relaxed text-theme-text-tertiary">
513
- Nothing installs on click. Radar inspects the cluster and shows you a plan; you approve it in
514
- the browser before anything changes.
589
+ Nothing installs on click. Radar inspects{' '}
590
+ {clusterName ? <span className="text-theme-text-secondary">{clusterName}</span> : 'the cluster'} and shows
591
+ you a plan; you approve it in the browser before anything changes.
515
592
  </p>
516
593
  )}
517
594
  {/* A 2-column grid, not flex-wrap: the long data-locality chip cannot
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { ApiError, type CloudInstallAttempted } from '../api/client'
3
- import { composeAdminNote, composeFailureNote, exitFor, needsAdminHandoff, handoffForBlocked, handoffForPrepareError, isHandoffOutcome, signupUrlFor } from './cloudConnectHandoff'
3
+ import { SELF_HOSTED_DOCS_URL, composeAdminNote, composeFailureNote, exitFor, trimRefusal, needsAdminHandoff, handoffForBlocked, handoffForPrepareError, isHandoffOutcome, signupUrlFor } from './cloudConnectHandoff'
4
4
 
5
5
  const APP = 'https://app.test.example'
6
6
  const UTM = 'utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal'
@@ -169,6 +169,9 @@ describe('composeAdminNote — an ask, then the link, then the evidence', () =>
169
169
  expect(note).toContain("Details: No Radar install was found in the cluster; the check stopped while reading Helm's release records.")
170
170
  expect(note).toContain('inspect Helm release "radar" in namespace "radar" — User "system:serviceaccount:default:limited" cannot list resource "secrets" in API group "" in the namespace "radar".')
171
171
  expect(note).toContain('Please confirm nothing is already installed before a fresh install.')
172
+ // The object of the request and the answer to "no SaaS", before the evidence.
173
+ expect(note.indexOf('What it is: Radar, the open-source Kubernetes tool')).toBeLessThan(note.indexOf('Details:'))
174
+ expect(note).toContain(`can be self-hosted in-house: ${SELF_HOSTED_DOCS_URL}`)
172
175
  // Never the card's second person.
173
176
  expect(note).not.toMatch(/\byour\b/i)
174
177
  })
@@ -194,6 +197,12 @@ describe('composeAdminNote — an ask, then the link, then the evidence', () =>
194
197
  expect(note).toContain('Open Radar Cloud:\n' + `${APP}/signup?via=admin_handoff`)
195
198
  })
196
199
 
200
+ it('trims a refusal to what was attempted and why, for the card as for the note', () => {
201
+ expect(
202
+ trimRefusal('inspect Helm release "radar" in namespace "radar": failed to inspect existing release: query: failed to query with labels: secrets is forbidden: User "u" cannot list resource "secrets" in API group "" in the namespace "radar"'),
203
+ ).toBe('inspect Helm release "radar" in namespace "radar" — User "u" cannot list resource "secrets" in API group "" in the namespace "radar".')
204
+ })
205
+
197
206
  it('keeps the whole line when a refusal has no error chain to trim', () => {
198
207
  const blocked = {
199
208
  reason: 'preflight' as const,
@@ -136,6 +136,17 @@ export interface AdminNoteContext {
136
136
  cluster?: string
137
137
  }
138
138
 
139
+ // A cold reader gets the object of the request and the answer to "no SaaS"
140
+ // in one place: what runs in the cluster, the trust boundary (Radar dials
141
+ // out; what comes back through that session is Radar's own API, bounded by
142
+ // the ServiceAccount it runs as), and that the control plane can be run
143
+ // in-house.
144
+ export const SELF_HOSTED_DOCS_URL = 'https://radarhq.io/docs/cloud/self-hosted/'
145
+ const WHAT_IT_IS = [
146
+ 'What it is: Radar, the open-source Kubernetes tool, installed in the cluster as a Helm chart. It opens an outbound connection to a hosted control plane (nothing listens inbound); Radar Cloud users reach this Radar only through that connection, with the permissions of the ServiceAccount it runs as.',
147
+ `If a hosted service is not an option, the same control plane can be self-hosted in-house: ${SELF_HOSTED_DOCS_URL}`,
148
+ ].join(' ')
149
+
139
150
  export function composeAdminNote(blocked: CloudInstallBlocked, exit: BlockedExit, where: AdminNoteContext = {}): string {
140
151
  const a = blocked.attempted
141
152
  const name = where.context || where.cluster || 'this cluster'
@@ -206,6 +217,8 @@ export function composeAdminNote(blocked: CloudInstallBlocked, exit: BlockedExit
206
217
  '',
207
218
  linkLabel,
208
219
  plainLink(exit.href),
220
+ '',
221
+ WHAT_IT_IS,
209
222
  ...(details.length ? ['', `Details: ${details.join(' ')}`] : []),
210
223
  ].join('\n')
211
224
  }
@@ -237,7 +250,7 @@ function plainLink(href: string): string {
237
250
  // A refusal line is a Go error chain — "create Namespace "radar": namespaces
238
251
  // "radar" is forbidden: ValidatingAdmissionPolicy … denied request: <reason>".
239
252
  // Keep what was attempted and why it was refused; drop the wrapping between.
240
- function trimRefusal(line: string): string {
253
+ export function trimRefusal(line: string): string {
241
254
  const parts = line.split(': ').map((p) => p.trim()).filter(Boolean)
242
255
  if (parts.length <= 2) return line.trim().replace(/\.?$/, '.')
243
256
  return `${parts[0]} — ${parts[parts.length - 1]}`.replace(/\.?$/, '.')
@@ -267,6 +280,7 @@ export function composeFailureNote(failure: CloudInstallFailure, where: AdminNot
267
280
  `Connecting it from Radar got as far as the install: ${stage}. ${failure.message.replace(/\s+/g, ' ').trim()} Could someone with cluster access take it from here?`,
268
281
  ]
269
282
  if (g?.clusterUrl) lines.push('', 'The cluster in Radar Cloud (its page shows the recovery options):', plainLink(g.clusterUrl))
283
+ lines.push('', WHAT_IT_IS)
270
284
  const details: string[] = []
271
285
  if (g?.summary && g.summary !== failure.message) details.push(g.summary)
272
286
  for (const l of g?.lines ?? []) details.push(l)
@@ -0,0 +1,87 @@
1
+ import { useState } from "react";
2
+ import { AlertTriangle, Copy, Check } from "lucide-react";
3
+ import { Markdown } from "../ui/Markdown";
4
+ import { Tooltip } from "../ui/Tooltip";
5
+
6
+ export function CopyButton({ text, label }: { text: string; label: string }) {
7
+ const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
8
+ const copied = state === "copied";
9
+ return (
10
+ <Tooltip
11
+ content={
12
+ state === "copied"
13
+ ? "Copied"
14
+ : state === "failed"
15
+ ? "Copy failed"
16
+ : label
17
+ }
18
+ delay={100}
19
+ wrapperClassName="shrink-0"
20
+ >
21
+ <button
22
+ onClick={async () => {
23
+ // Report what happened, not what was attempted: the write can be
24
+ // denied or unavailable, and "Copied" over an empty clipboard is worse
25
+ // than no button.
26
+ try {
27
+ if (!navigator.clipboard) throw new Error("clipboard unavailable");
28
+ await navigator.clipboard.writeText(text);
29
+ setState("copied");
30
+ } catch {
31
+ setState("failed");
32
+ }
33
+ setTimeout(() => setState("idle"), 1200);
34
+ }}
35
+ className="shrink-0 rounded p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
36
+ aria-label={
37
+ state === "copied"
38
+ ? `${label} — copied`
39
+ : state === "failed"
40
+ ? `${label} — copy failed`
41
+ : label
42
+ }
43
+ aria-live="polite"
44
+ >
45
+ {copied ? (
46
+ <Check className="h-3.5 w-3.5 text-emerald-400" />
47
+ ) : state === "failed" ? (
48
+ <AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
49
+ ) : (
50
+ <Copy className="h-3.5 w-3.5" />
51
+ )}
52
+ </button>
53
+ </Tooltip>
54
+ );
55
+ }
56
+
57
+ // LLMs occasionally open a ```fence mid-line ("run this: ```bash kubectl …") or
58
+ // put the command on the same line as the ```lang marker. GFM then won't parse
59
+ // it as a fence — it leaks the literal ``` and renders an empty code box. Coerce
60
+ // fence markers onto their own lines and push trailing content off the opener so
61
+ // the block renders. (Well-formed markdown is unaffected.)
62
+ function tidyFences(md: string): string {
63
+ if (!md || !md.includes("```")) return md;
64
+ return md
65
+ .replace(/([^\n])```/g, "$1\n\n```") // opener/closer must start a line
66
+ .replace(/```([A-Za-z0-9_-]*)[ \t]+(\S)/g, "```$1\n$2"); // content off the opener line
67
+ }
68
+
69
+ // Diagnosis output is dense with inline `code`; the shared chip's brand tint is
70
+ // too loud at that density, so neutralize it (border/bg only) for this surface.
71
+ const SOFT_INLINE_CODE =
72
+ "[&_.inline-code]:border-theme-border/60 [&_.inline-code]:bg-theme-base [&_.inline-code]:font-normal";
73
+
74
+ // Markdown for agent-generated text — normalizes flaky fences + softens code.
75
+ export function AIMarkdown({
76
+ className,
77
+ children,
78
+ }: {
79
+ className?: string;
80
+ children: string;
81
+ }) {
82
+ return (
83
+ <Markdown className={`${SOFT_INLINE_CODE} ${className ?? ""}`}>
84
+ {tidyFences(children)}
85
+ </Markdown>
86
+ );
87
+ }