@skyhook-io/radar-app 1.14.2 → 1.14.3
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 +2 -2
- package/src/api/client.ts +30 -0
- package/src/components/CloudConnectFlow.tsx +21 -7
- package/src/components/CloudFunnelButton.tsx +85 -8
- package/src/components/cloudConnectHandoff.test.ts +10 -1
- package/src/components/cloudConnectHandoff.ts +15 -1
- package/src/components/diagnose/AIMarkdown.tsx +87 -0
- package/src/components/diagnose/ActivityTurn.tsx +1244 -0
- package/src/components/diagnose/AgentControls.tsx +603 -0
- package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
- package/src/components/diagnose/AnalysisStory.tsx +13 -18
- package/src/components/diagnose/ApplyDialog.tsx +289 -0
- package/src/components/diagnose/AssessmentCard.tsx +1560 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
- package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
- package/src/components/diagnose/EvidenceCard.tsx +611 -0
- package/src/components/diagnose/Home.test.tsx +1 -3
- package/src/components/diagnose/Home.tsx +2 -1
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
- package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
- package/src/components/diagnose/InvestigationView.tsx +11 -28
- package/src/components/diagnose/StoryExcerpt.tsx +118 -0
- package/src/components/diagnose/assessmentCopy.ts +169 -0
- package/src/components/diagnose/investigationCase.test.tsx +631 -2
- package/src/components/diagnose/investigationCase.ts +132 -24
- package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +660 -0
- package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
- package/src/components/diagnose/investigationEvidence/adapters/listings.ts +134 -0
- package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
- package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
- package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
- package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
- package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
- package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
- package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
- package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
- package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
- package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
- package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
- package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
- package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
- package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
- package/src/components/diagnose/investigationEvidence/index.ts +1 -1
- package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +11 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
- package/src/components/diagnose/investigationEvidence/types.ts +97 -2
- package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
- package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
- package/src/components/diagnose/parts.test.tsx +3 -1
- package/src/components/diagnose/parts.tsx +26 -3904
- package/src/components/diagnose/target.ts +9 -3
- package/src/components/diagnose/toolCallLabel.test.ts +47 -1
- package/src/components/diagnose/toolCallLabel.ts +41 -2
- package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
- package/src/components/resources/PodFilePreview.tsx +1 -1
- package/src/components/ui/CommandPalette.tsx +12 -8
- package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
- package/src/components/ui/Omnibar.tsx +33 -10
- package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
- 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.
|
|
3
|
+
"version": "1.14.3",
|
|
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.
|
|
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
|
}
|
|
@@ -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
|
-
|
|
272
|
-
|
|
273
|
-
{line}
|
|
274
|
-
|
|
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
|
-
|
|
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
|
|
514
|
-
the
|
|
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
|
+
}
|