@skyhook-io/radar-app 1.9.5 → 1.9.7
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 +10 -10
- package/src/api/client.authRedirect.test.ts +194 -0
- package/src/api/client.trace.test.ts +37 -0
- package/src/api/client.ts +136 -3
- package/src/api/diagnose.ts +12 -0
- package/src/components/CloudConnectFlow.tsx +7 -7
- package/src/components/CloudFunnelButton.tsx +87 -64
- package/src/components/diagnose/DiagnoseContext.tsx +38 -6
- package/src/components/diagnose/DiagnoseSurface.test.tsx +70 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +50 -0
- package/src/components/diagnose/InvestigationView.tsx +44 -7
- package/src/components/diagnose/parts.test.tsx +39 -0
- package/src/components/diagnose/parts.tsx +22 -1
- package/src/components/home/HomeView.tsx +6 -1
- package/src/components/home/mcpToolCatalog.ts +7 -5
- package/src/components/workload/WorkloadView.tsx +580 -16
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react'
|
|
1
|
+
import { useEffect, useId, useState, type ReactNode } from 'react'
|
|
2
2
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
3
3
|
import { Bell, Check, Globe, History, Sparkles, Users, X } from 'lucide-react'
|
|
4
|
+
import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
|
|
4
5
|
import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
|
|
5
6
|
import { Tooltip } from './ui/Tooltip'
|
|
6
7
|
import { CloudConnectFlow } from './CloudConnectFlow'
|
|
@@ -34,6 +35,9 @@ const FALLBACK_APP_URL = 'https://app.radarhq.io'
|
|
|
34
35
|
// offline laptop all render exactly what Radar rendered before this fetch
|
|
35
36
|
// existed — the dialog never waits on the network and never shows a gap.
|
|
36
37
|
const DEFAULT_ASSURANCES = ['Free for 3 clusters', 'No credit card', 'Your cluster data stays in your cluster']
|
|
38
|
+
// A product fact, not funnel copy — appended even when the Hub supplies its
|
|
39
|
+
// own assurances (deduped if the Hub starts sending it).
|
|
40
|
+
const SOC2_ASSURANCE = 'SOC 2 compliant'
|
|
37
41
|
const SIGNUP_QUERY = '?utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal'
|
|
38
42
|
const ABOUT_URL = 'https://radarhq.io/about'
|
|
39
43
|
const SELF_HOSTED_DOCS_URL = 'https://radarhq.io/docs/cloud/self-hosted/'
|
|
@@ -193,7 +197,7 @@ export function CloudFunnelButton() {
|
|
|
193
197
|
<DialogPortal
|
|
194
198
|
open={open}
|
|
195
199
|
onClose={() => setOpen(false)}
|
|
196
|
-
className="w-[
|
|
200
|
+
className="w-[580px] max-w-full max-h-[calc(100vh-2rem)] overflow-hidden flex flex-col"
|
|
197
201
|
>
|
|
198
202
|
<button
|
|
199
203
|
onClick={() => setOpen(false)}
|
|
@@ -208,7 +212,7 @@ export function CloudFunnelButton() {
|
|
|
208
212
|
more with the connect flow, whose plan card is the tallest state. */}
|
|
209
213
|
{showFlow && flowForView ? (
|
|
210
214
|
<div className="min-h-0 overflow-y-auto">
|
|
211
|
-
<div className="px-
|
|
215
|
+
<div className="px-8 pt-7">
|
|
212
216
|
<Eyebrow />
|
|
213
217
|
</div>
|
|
214
218
|
<CloudConnectFlow
|
|
@@ -245,6 +249,39 @@ export function CloudFunnelButton() {
|
|
|
245
249
|
)
|
|
246
250
|
}
|
|
247
251
|
|
|
252
|
+
// Secondary copy the pitch shouldn't spend vertical space on until asked for.
|
|
253
|
+
function Fold({ summary, className = '', children }: { summary: string; className?: string; children: ReactNode }) {
|
|
254
|
+
const [open, setOpen] = useState(false)
|
|
255
|
+
const bodyId = useId()
|
|
256
|
+
return (
|
|
257
|
+
<div className={className}>
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
onClick={() => setOpen((v) => !v)}
|
|
261
|
+
aria-expanded={open}
|
|
262
|
+
aria-controls={bodyId}
|
|
263
|
+
className="flex items-center gap-1.5 text-[11.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
|
|
264
|
+
>
|
|
265
|
+
<CollapseChevron open={open} className="w-3 h-3" />
|
|
266
|
+
{summary}
|
|
267
|
+
</button>
|
|
268
|
+
<Collapse open={open}>
|
|
269
|
+
<p id={bodyId} className="mt-2 pl-[18px] text-[11.5px] leading-relaxed text-theme-text-tertiary">
|
|
270
|
+
{children}
|
|
271
|
+
</p>
|
|
272
|
+
</Collapse>
|
|
273
|
+
</div>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function assuranceItems(fromHub?: string[]): string[] {
|
|
278
|
+
const items = fromHub?.length ? fromHub : DEFAULT_ASSURANCES
|
|
279
|
+
if (items.some((item) => item.toLowerCase().includes('soc 2'))) return items
|
|
280
|
+
// Before the last item: the closer ("data stays in your cluster") is the
|
|
281
|
+
// longest line and wraps most naturally when it comes last.
|
|
282
|
+
return [...items.slice(0, -1), SOC2_ASSURANCE, items[items.length - 1]]
|
|
283
|
+
}
|
|
284
|
+
|
|
248
285
|
function RadarSweep() {
|
|
249
286
|
return (
|
|
250
287
|
<div
|
|
@@ -263,32 +300,13 @@ function RadarSweep() {
|
|
|
263
300
|
|
|
264
301
|
function Eyebrow() {
|
|
265
302
|
return (
|
|
266
|
-
<div className="flex items-center gap-
|
|
303
|
+
<div className="flex items-center gap-3 mb-5">
|
|
267
304
|
<RadarSweep />
|
|
268
305
|
<span className="font-mono text-[10.5px] tracking-[0.16em] uppercase text-emerald-600 dark:text-emerald-400">Radar Cloud</span>
|
|
269
306
|
</div>
|
|
270
307
|
)
|
|
271
308
|
}
|
|
272
309
|
|
|
273
|
-
function Faces() {
|
|
274
|
-
return (
|
|
275
|
-
<div className="flex shrink-0" aria-hidden>
|
|
276
|
-
{[
|
|
277
|
-
['R', 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-300'],
|
|
278
|
-
['N', 'bg-sky-500/20 text-sky-700 dark:text-sky-300'],
|
|
279
|
-
['E', 'bg-amber-500/20 text-amber-700 dark:text-amber-300'],
|
|
280
|
-
].map(([initial, color], i) => (
|
|
281
|
-
<div
|
|
282
|
-
key={initial}
|
|
283
|
-
className={`w-6 h-6 text-[10px] ${color} rounded-full grid place-items-center font-bold border-2 border-theme-surface ${i > 0 ? '-ml-1.5' : ''}`}
|
|
284
|
-
>
|
|
285
|
-
{initial}
|
|
286
|
-
</div>
|
|
287
|
-
))}
|
|
288
|
-
</div>
|
|
289
|
-
)
|
|
290
|
-
}
|
|
291
|
-
|
|
292
310
|
function ModalFooter({
|
|
293
311
|
lane,
|
|
294
312
|
signupUrl,
|
|
@@ -328,9 +346,9 @@ function ModalFooter({
|
|
|
328
346
|
// fast click would escape to signup before we could route this install.
|
|
329
347
|
const selfPending = selfLoading === true
|
|
330
348
|
return (
|
|
331
|
-
<div className="px-
|
|
349
|
+
<div className="shrink-0 px-8 py-5 bg-theme-base border-t border-theme-border">
|
|
332
350
|
{self && self.ownership !== 'unknown' && (
|
|
333
|
-
<div className="mb-3 card-inner text-[
|
|
351
|
+
<div className="mb-3.5 card-inner p-3 text-[12px] leading-relaxed text-theme-text-secondary">
|
|
334
352
|
{ambiguous ? (
|
|
335
353
|
<>
|
|
336
354
|
Radar found conflicting management metadata on this install, so it can't say whether a Helm
|
|
@@ -367,14 +385,20 @@ function ModalFooter({
|
|
|
367
385
|
</div>
|
|
368
386
|
)}
|
|
369
387
|
{notice && (
|
|
370
|
-
<div className="mb-3 card-inner text-[
|
|
388
|
+
<div className="mb-3.5 card-inner p-3 text-[12px] leading-relaxed text-theme-text-secondary">{notice}</div>
|
|
389
|
+
)}
|
|
390
|
+
{lane === 'driver' && (
|
|
391
|
+
<p className="mb-3.5 text-[12px] leading-relaxed text-theme-text-secondary">
|
|
392
|
+
The guided setup runs right here in the app — Radar installs the Cloud agent on this cluster, and
|
|
393
|
+
you approve the connection in your browser.
|
|
394
|
+
</p>
|
|
371
395
|
)}
|
|
372
|
-
<div className="flex items-center gap-
|
|
396
|
+
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5">
|
|
373
397
|
{lane === 'driver' ? (
|
|
374
398
|
<>
|
|
375
399
|
<button
|
|
376
400
|
onClick={onConnect}
|
|
377
|
-
className="px-
|
|
401
|
+
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"
|
|
378
402
|
>
|
|
379
403
|
Connect this cluster
|
|
380
404
|
</button>
|
|
@@ -382,9 +406,9 @@ function ModalFooter({
|
|
|
382
406
|
href={driverEscapeUrl}
|
|
383
407
|
target="_blank"
|
|
384
408
|
rel="noopener noreferrer"
|
|
385
|
-
className="text-[
|
|
409
|
+
className="whitespace-nowrap text-[13px] text-theme-text-secondary hover:text-theme-text-primary underline underline-offset-2 transition-colors"
|
|
386
410
|
>
|
|
387
|
-
or
|
|
411
|
+
or set up in the browser
|
|
388
412
|
</a>
|
|
389
413
|
</>
|
|
390
414
|
) : cliOnly ? null : (
|
|
@@ -399,33 +423,32 @@ function ModalFooter({
|
|
|
399
423
|
{self?.ownership === 'helm' || gitops ? 'Connect this cluster' : 'Try Cloud free'}
|
|
400
424
|
</a>
|
|
401
425
|
)}
|
|
402
|
-
<button onClick={onLater} className="text-[
|
|
426
|
+
<button onClick={onLater} className="whitespace-nowrap text-[13px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
|
|
403
427
|
Maybe later
|
|
404
428
|
</button>
|
|
405
429
|
</div>
|
|
406
|
-
<div className="mt-
|
|
407
|
-
{(assurances
|
|
408
|
-
<span key={item} className="flex items-
|
|
409
|
-
<Check className="w-3 h-3 text-emerald-600 dark:text-emerald-400" />
|
|
430
|
+
<div className="mt-4 grid grid-cols-2 gap-x-5 gap-y-2 text-[11.5px] text-theme-text-tertiary">
|
|
431
|
+
{assuranceItems(assurances).map((item) => (
|
|
432
|
+
<span key={item} className="flex items-start gap-1.5">
|
|
433
|
+
<Check className="w-3 h-3 mt-[3px] shrink-0 text-emerald-600 dark:text-emerald-400" />
|
|
410
434
|
{item}
|
|
411
435
|
</span>
|
|
412
436
|
))}
|
|
413
437
|
</div>
|
|
414
|
-
<
|
|
415
|
-
|
|
416
|
-
call.{' '}
|
|
438
|
+
<Fold summary="Prefer to run the control plane in your own VPC?" className="mt-3.5">
|
|
439
|
+
Self-hosting is fully self-serve — set it up yourself, whenever you're ready.{' '}
|
|
417
440
|
<a href={SELF_HOSTED_DOCS_URL} target="_blank" rel="noopener noreferrer" className="text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary">
|
|
418
441
|
Read the docs
|
|
419
442
|
</a>
|
|
420
443
|
.
|
|
421
|
-
</
|
|
444
|
+
</Fold>
|
|
422
445
|
</div>
|
|
423
446
|
)
|
|
424
447
|
}
|
|
425
448
|
|
|
426
449
|
// Headline defuses the paywall fear before anything is pitched; the grid
|
|
427
|
-
// carries the concrete capabilities; the
|
|
428
|
-
//
|
|
450
|
+
// carries the concrete capabilities; the anti-sell closes — the credibility
|
|
451
|
+
// beat that makes the sell land.
|
|
429
452
|
function ModalBody() {
|
|
430
453
|
const features = [
|
|
431
454
|
{
|
|
@@ -456,43 +479,43 @@ function ModalBody() {
|
|
|
456
479
|
},
|
|
457
480
|
]
|
|
458
481
|
return (
|
|
459
|
-
<div className="px-
|
|
482
|
+
<div className="px-8 pt-7 pb-2">
|
|
460
483
|
<Eyebrow />
|
|
461
|
-
<h3 className="text-[
|
|
484
|
+
<h3 className="text-[22px] font-semibold leading-tight tracking-tight text-theme-text-primary mb-3 text-balance">
|
|
462
485
|
First things first: Radar stays free.
|
|
463
486
|
</h3>
|
|
464
|
-
<p className="text-[
|
|
487
|
+
<p className="text-[14px] leading-relaxed text-theme-text-secondary mb-5">
|
|
465
488
|
The app you're looking at is Apache 2.0 — every feature, forever, no rug pulls.{' '}
|
|
466
489
|
<b className="text-theme-text-primary font-semibold">Radar Cloud is how we keep the lights on:</b> the
|
|
467
490
|
same Radar, plus the parts that are genuinely hard to run on your own.
|
|
468
491
|
</p>
|
|
469
|
-
<div className="grid grid-cols-2 gap-
|
|
492
|
+
<div className="grid grid-cols-2 gap-3 mb-5">
|
|
470
493
|
{features.map(({ icon: Icon, title, body, wide }) => (
|
|
471
|
-
<div
|
|
494
|
+
<div
|
|
495
|
+
key={title}
|
|
496
|
+
className={`card-inner-lg p-3.5 flex gap-3 ${
|
|
497
|
+
wide ? 'col-span-2 bg-emerald-500/[0.06] border-emerald-500/25 dark:bg-emerald-500/[0.08]' : ''
|
|
498
|
+
}`}
|
|
499
|
+
>
|
|
472
500
|
<Icon className="w-4 h-4 shrink-0 mt-0.5 text-emerald-600 dark:text-emerald-400" />
|
|
473
501
|
<div>
|
|
474
|
-
<div className="text-[
|
|
475
|
-
<p className="mt-
|
|
502
|
+
<div className="text-[13px] font-semibold text-theme-text-primary">{title}</div>
|
|
503
|
+
<p className="mt-1 text-[12px] leading-relaxed text-theme-text-tertiary">{body}</p>
|
|
476
504
|
</div>
|
|
477
505
|
</div>
|
|
478
506
|
))}
|
|
479
507
|
</div>
|
|
480
|
-
<div className="
|
|
481
|
-
<
|
|
482
|
-
|
|
483
|
-
</
|
|
484
|
-
<
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
<a href={ABOUT_URL} target="_blank" rel="noopener noreferrer" className="whitespace-nowrap text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary">
|
|
492
|
-
Meet us →
|
|
493
|
-
</a>
|
|
494
|
-
</p>
|
|
495
|
-
</div>
|
|
508
|
+
<div className="mb-5 border-l-2 border-emerald-500/40 pl-3.5">
|
|
509
|
+
<p className="text-[12.5px] leading-relaxed text-theme-text-secondary">
|
|
510
|
+
If it's just you and one cluster — honestly, stay right here. This app is the product, not a demo.
|
|
511
|
+
</p>
|
|
512
|
+
<p className="mt-2 text-[11.5px] leading-relaxed text-theme-text-tertiary">
|
|
513
|
+
Radar is built in the open by many hands, and overseen by a small team of humans — the kind you
|
|
514
|
+
can actually talk to.{' '}
|
|
515
|
+
<a href={ABOUT_URL} target="_blank" rel="noopener noreferrer" className="whitespace-nowrap text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary">
|
|
516
|
+
Meet us →
|
|
517
|
+
</a>
|
|
518
|
+
</p>
|
|
496
519
|
</div>
|
|
497
520
|
</div>
|
|
498
521
|
)
|
|
@@ -32,6 +32,14 @@ export interface Target {
|
|
|
32
32
|
kind: string;
|
|
33
33
|
namespace: string;
|
|
34
34
|
name: string;
|
|
35
|
+
/** The issue this investigation is for, when it came from an issue. Hosts
|
|
36
|
+
* that group sessions by issue key on it; the rest carry it and ignore it. */
|
|
37
|
+
issueId?: string;
|
|
38
|
+
/** Start a new session instead of continuing one the backend would otherwise
|
|
39
|
+
* return for this target. Rides here rather than as a separate argument so
|
|
40
|
+
* the consent-deferred path replays one object — a parallel "was it fresh?"
|
|
41
|
+
* state is a thing that can fall out of sync with the target it describes. */
|
|
42
|
+
fresh?: boolean;
|
|
35
43
|
}
|
|
36
44
|
export type DiagnoseView = "home" | "investigation";
|
|
37
45
|
|
|
@@ -66,6 +74,10 @@ interface DiagnoseCtx {
|
|
|
66
74
|
historyDegraded: boolean; // persistence broke — history won't survive a restart
|
|
67
75
|
needsConsent: boolean; // a start is pending the one-time consent
|
|
68
76
|
startError: string | null;
|
|
77
|
+
// Kept apart from startError: the consent card renders this as "why your
|
|
78
|
+
// approval was refused", and a run-start failure landing in the same slot
|
|
79
|
+
// would be read as exactly that — the two paths don't share a lifecycle.
|
|
80
|
+
consentError: string | null;
|
|
69
81
|
openInvestigation: (t: Target) => void;
|
|
70
82
|
openRun: (id: string) => void;
|
|
71
83
|
openHome: () => void;
|
|
@@ -200,6 +212,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
200
212
|
const [historyDegraded, setHistoryDegraded] = useState(false);
|
|
201
213
|
const [pendingTarget, setPendingTarget] = useState<Target | null>(null);
|
|
202
214
|
const [startError, setStartError] = useState<string | null>(null);
|
|
215
|
+
const [consentError, setConsentError] = useState<string | null>(null);
|
|
203
216
|
const [width, setWidth] = useState<number>(() => {
|
|
204
217
|
try {
|
|
205
218
|
const v = Number(localStorage.getItem(WIDTH_KEY));
|
|
@@ -341,7 +354,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
341
354
|
// panel closed — and only re-render when the set actually changes, not every poll.
|
|
342
355
|
const runningSig = runs
|
|
343
356
|
.filter((r) => r.status === "running")
|
|
344
|
-
.map((r) =>
|
|
357
|
+
.map((r) => runTargetKey(r.kind, r.namespace, r.name))
|
|
345
358
|
.sort()
|
|
346
359
|
.join("|");
|
|
347
360
|
const runningKeys = useMemo(
|
|
@@ -398,6 +411,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
398
411
|
const openInvestigation = useCallback(
|
|
399
412
|
(t: Target) => {
|
|
400
413
|
setStartError(null);
|
|
414
|
+
setConsentError(null);
|
|
401
415
|
setOpen(true);
|
|
402
416
|
if (!hosted && !consentSurface) {
|
|
403
417
|
setPendingTarget(null);
|
|
@@ -449,21 +463,29 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
449
463
|
}
|
|
450
464
|
if (id) openRun(id);
|
|
451
465
|
}, [available, openRun]);
|
|
466
|
+
// Leaving the detail pane drops the failure that belonged to it. startError
|
|
467
|
+
// renders as the entire pane (maximized home still shows `detail`), where a
|
|
468
|
+
// message about a resource you just navigated away from has nothing to attach
|
|
469
|
+
// to and no way to be dismissed.
|
|
452
470
|
const openHome = useCallback(() => {
|
|
453
471
|
setView("home");
|
|
472
|
+
setStartError(null);
|
|
454
473
|
setOpen(true);
|
|
455
474
|
}, []);
|
|
456
|
-
const goHome = useCallback(() =>
|
|
475
|
+
const goHome = useCallback(() => {
|
|
476
|
+
setView("home");
|
|
477
|
+
setStartError(null);
|
|
478
|
+
}, []);
|
|
457
479
|
const close = useCallback(() => setOpen(false), []);
|
|
458
480
|
const consentBusyRef = useRef(false);
|
|
459
481
|
const approveConsent = useCallback(() => {
|
|
460
482
|
if (consentBusyRef.current) return;
|
|
461
483
|
if (!consentSurface) {
|
|
462
|
-
|
|
484
|
+
setConsentError("Radar can’t record consent for this agent.");
|
|
463
485
|
return;
|
|
464
486
|
}
|
|
465
487
|
consentBusyRef.current = true;
|
|
466
|
-
|
|
488
|
+
setConsentError(null);
|
|
467
489
|
const t = pendingTarget;
|
|
468
490
|
// The server ENFORCES consent at start, so the acknowledgment must land
|
|
469
491
|
// before the run request — awaiting also makes it durable for the CLI.
|
|
@@ -475,8 +497,16 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
475
497
|
setPendingTarget(null);
|
|
476
498
|
if (t) startRunRef.current(t);
|
|
477
499
|
})
|
|
478
|
-
.catch(() => {
|
|
479
|
-
|
|
500
|
+
.catch((e) => {
|
|
501
|
+
// The server's message, when it has one. A host that records consent
|
|
502
|
+
// above the individual refuses whoever isn't allowed to grant it, and
|
|
503
|
+
// only its message can say who is — "try again" sends them at something
|
|
504
|
+
// that can never succeed.
|
|
505
|
+
setConsentError(
|
|
506
|
+
e instanceof DiagnoseError && e.message
|
|
507
|
+
? e.message
|
|
508
|
+
: "Couldn't record your consent — try again.",
|
|
509
|
+
);
|
|
480
510
|
})
|
|
481
511
|
.finally(() => {
|
|
482
512
|
consentBusyRef.current = false;
|
|
@@ -484,6 +514,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
484
514
|
}, [consentSurface, pendingTarget]);
|
|
485
515
|
const cancelConsent = useCallback(() => {
|
|
486
516
|
setPendingTarget(null);
|
|
517
|
+
setConsentError(null);
|
|
487
518
|
setOpen(false);
|
|
488
519
|
}, []);
|
|
489
520
|
const dismissError = useCallback(() => setStartError(null), []);
|
|
@@ -517,6 +548,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
517
548
|
// cleared on approve/cancel — so its presence is exactly "consent needed now".
|
|
518
549
|
needsConsent: !!pendingTarget,
|
|
519
550
|
startError,
|
|
551
|
+
consentError,
|
|
520
552
|
openInvestigation,
|
|
521
553
|
openRun,
|
|
522
554
|
openHome,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { canStartNewInvestigation } from "./DiagnoseSurface";
|
|
3
|
+
import type { RunSummary } from "../../api/diagnose";
|
|
4
|
+
|
|
5
|
+
// The "new investigation" button dispatches an agent and spends the user's own
|
|
6
|
+
// tokens, so every one of these clauses is load-bearing rather than cosmetic.
|
|
7
|
+
// Each case below is a way it misfired before the gate existed.
|
|
8
|
+
function run(status: RunSummary["status"]): RunSummary {
|
|
9
|
+
return {
|
|
10
|
+
id: "r1",
|
|
11
|
+
kind: "Deployment",
|
|
12
|
+
namespace: "prod",
|
|
13
|
+
name: "payments",
|
|
14
|
+
context: "prod-cluster",
|
|
15
|
+
status,
|
|
16
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
17
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("canStartNewInvestigation", () => {
|
|
22
|
+
it("offers a new investigation on a finished run", () => {
|
|
23
|
+
expect(canStartNewInvestigation("investigation", run("done"), false)).toBe(
|
|
24
|
+
true,
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("stays hidden on the investigations list", () => {
|
|
29
|
+
// goHome() leaves activeRunId set, so the header still has a run to read.
|
|
30
|
+
// Without the view check the click starts an agent on a resource the user
|
|
31
|
+
// navigated away from, over a list of unrelated investigations.
|
|
32
|
+
expect(canStartNewInvestigation("home", run("done"), false)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("stays hidden while a turn is in flight", () => {
|
|
36
|
+
// A start would be handed back the live run, so the button does nothing.
|
|
37
|
+
expect(canStartNewInvestigation("investigation", run("running"), false)).toBe(
|
|
38
|
+
false,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("stays hidden on a stale run", () => {
|
|
43
|
+
// The body offers "Re-run on current cluster" WITH the context-changed
|
|
44
|
+
// warning. A bare + carries none of it, at a resource that may not exist in
|
|
45
|
+
// the context it would now run against.
|
|
46
|
+
expect(canStartNewInvestigation("investigation", run("stale"), false)).toBe(
|
|
47
|
+
false,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("stays hidden while consent is pending", () => {
|
|
52
|
+
expect(canStartNewInvestigation("investigation", run("done"), true)).toBe(
|
|
53
|
+
false,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("stays hidden with no focused run", () => {
|
|
58
|
+
expect(canStartNewInvestigation("investigation", null, false)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("offers one on an errored or stopped run", () => {
|
|
62
|
+
// Those are the runs a person most wants to start over from.
|
|
63
|
+
expect(canStartNewInvestigation("investigation", run("error"), false)).toBe(
|
|
64
|
+
true,
|
|
65
|
+
);
|
|
66
|
+
expect(canStartNewInvestigation("investigation", run("stopped"), false)).toBe(
|
|
67
|
+
true,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
TerminalSquare,
|
|
16
16
|
Copy,
|
|
17
17
|
Check,
|
|
18
|
+
Plus,
|
|
18
19
|
} from "lucide-react";
|
|
19
20
|
import { Tooltip } from "../ui/Tooltip";
|
|
20
21
|
import {
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
useDiagnoseLayout,
|
|
23
24
|
agentLabelFor,
|
|
24
25
|
openDiagnoseSettings,
|
|
26
|
+
type DiagnoseView,
|
|
25
27
|
} from "./DiagnoseContext";
|
|
26
28
|
import { useDiagnoseCustomization } from "../../context/DiagnoseCustomization";
|
|
27
29
|
import { InvestigationView } from "./InvestigationView";
|
|
@@ -141,6 +143,33 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
|
|
|
141
143
|
// header, right of the nav rail) — App renders it there and passes topInset (the
|
|
142
144
|
// header height; 0 in chromeless embeds). It shares that frame with the resource/
|
|
143
145
|
// Helm drawers, so it no longer floats viewport-fixed or DOM-measures the chrome.
|
|
146
|
+
// Whether the header offers a new investigation on the focused run's resource.
|
|
147
|
+
// Every clause is a failure this button actually had:
|
|
148
|
+
//
|
|
149
|
+
// view goHome() leaves activeRunId set, so the header keeps rendering
|
|
150
|
+
// the last focused run. Without this the button dispatches an
|
|
151
|
+
// agent — real tokens — from a screen showing an unrelated list.
|
|
152
|
+
// run nothing to take a resource from.
|
|
153
|
+
// running a start is handed back the live run, so the click does nothing
|
|
154
|
+
// and the button reads as broken.
|
|
155
|
+
// stale the body already offers "Re-run on current cluster" WITH the
|
|
156
|
+
// warning that the context changed; a bare + carries none of it,
|
|
157
|
+
// and the resource may not exist in the context it'd run against.
|
|
158
|
+
// needsConsent the consent card owns the surface until it's answered.
|
|
159
|
+
export function canStartNewInvestigation(
|
|
160
|
+
view: DiagnoseView,
|
|
161
|
+
run: RunSummary | null,
|
|
162
|
+
needsConsent: boolean,
|
|
163
|
+
): boolean {
|
|
164
|
+
return (
|
|
165
|
+
view === "investigation" &&
|
|
166
|
+
!!run &&
|
|
167
|
+
run.status !== "running" &&
|
|
168
|
+
run.status !== "stale" &&
|
|
169
|
+
!needsConsent
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
144
173
|
export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
145
174
|
const d = useDiagnose();
|
|
146
175
|
// Injected settings action: undefined = Radar's own Settings dialog;
|
|
@@ -223,6 +252,7 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
|
223
252
|
profile={d.profile}
|
|
224
253
|
copy={consentCopy}
|
|
225
254
|
onOpenSettings={openSettings ?? undefined}
|
|
255
|
+
error={d.consentError}
|
|
226
256
|
onApprove={d.approveConsent}
|
|
227
257
|
onCancel={d.cancelConsent}
|
|
228
258
|
/>
|
|
@@ -341,6 +371,26 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
|
341
371
|
</div>
|
|
342
372
|
</div>
|
|
343
373
|
<div className="flex shrink-0 items-center gap-0.5">
|
|
374
|
+
{activeRun &&
|
|
375
|
+
canStartNewInvestigation(d.view, activeRun, d.needsConsent) && (
|
|
376
|
+
<Tooltip content="New investigation on this resource" position="bottom">
|
|
377
|
+
<button
|
|
378
|
+
onClick={() =>
|
|
379
|
+
d.openInvestigation({
|
|
380
|
+
kind: activeRun.kind,
|
|
381
|
+
namespace: activeRun.namespace,
|
|
382
|
+
name: activeRun.name,
|
|
383
|
+
issueId: activeRun.issueId,
|
|
384
|
+
fresh: true,
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
388
|
+
aria-label="New investigation on this resource"
|
|
389
|
+
>
|
|
390
|
+
<Plus className="h-4 w-4" />
|
|
391
|
+
</button>
|
|
392
|
+
</Tooltip>
|
|
393
|
+
)}
|
|
344
394
|
{activeRun && <InvestigationMenu run={activeRun} />}
|
|
345
395
|
<Tooltip content={maximized ? "Restore" : "Expand"} position="bottom">
|
|
346
396
|
<button
|
|
@@ -46,10 +46,21 @@ export function InvestigationView({
|
|
|
46
46
|
const { kind, namespace, name } = run;
|
|
47
47
|
// Apply is off for hosted agents (read-only server-side). Keyed on the selected
|
|
48
48
|
// agent, which matches run.agent unless a deployment mixes hosted + local agents.
|
|
49
|
-
const { refreshRuns, openInvestigation, startError, hosted } =
|
|
49
|
+
const { refreshRuns, openInvestigation, startError, dismissError, hosted } =
|
|
50
|
+
useDiagnose();
|
|
51
|
+
// Re-run means look again, so it asks for a new session explicitly and only
|
|
52
|
+
// carries the issue forward — being handed the previous answer is the one
|
|
53
|
+
// thing someone clicking this doesn't want.
|
|
50
54
|
const retryDiagnosis = useCallback(
|
|
51
|
-
() =>
|
|
52
|
-
|
|
55
|
+
() =>
|
|
56
|
+
openInvestigation({
|
|
57
|
+
kind,
|
|
58
|
+
namespace,
|
|
59
|
+
name,
|
|
60
|
+
issueId: run.issueId,
|
|
61
|
+
fresh: true,
|
|
62
|
+
}),
|
|
63
|
+
[openInvestigation, kind, namespace, name, run.issueId],
|
|
53
64
|
);
|
|
54
65
|
const queryClient = useQueryClient();
|
|
55
66
|
const [turns, setTurns] = useState<Turn[]>([]);
|
|
@@ -470,7 +481,7 @@ export function InvestigationView({
|
|
|
470
481
|
</span>
|
|
471
482
|
</div>
|
|
472
483
|
<button
|
|
473
|
-
onClick={
|
|
484
|
+
onClick={retryDiagnosis}
|
|
474
485
|
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-amber-500/50 px-2.5 py-1 font-medium text-amber-600 hover:bg-amber-500/10 dark:text-amber-400"
|
|
475
486
|
>
|
|
476
487
|
<Send className="h-3 w-3" />
|
|
@@ -489,7 +500,7 @@ export function InvestigationView({
|
|
|
489
500
|
</span>
|
|
490
501
|
</div>
|
|
491
502
|
<button
|
|
492
|
-
onClick={
|
|
503
|
+
onClick={retryDiagnosis}
|
|
493
504
|
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-theme-border px-2.5 py-1 font-medium text-theme-text-primary hover:bg-theme-hover"
|
|
494
505
|
>
|
|
495
506
|
<Send className="h-3 w-3" />
|
|
@@ -525,10 +536,36 @@ export function InvestigationView({
|
|
|
525
536
|
/>
|
|
526
537
|
);
|
|
527
538
|
})}
|
|
528
|
-
{
|
|
539
|
+
{actionError && (
|
|
529
540
|
<div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-theme-text-primary">
|
|
530
541
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
|
531
|
-
<span>{actionError
|
|
542
|
+
<span>{actionError}</span>
|
|
543
|
+
</div>
|
|
544
|
+
)}
|
|
545
|
+
{/* A start that failed belongs to the investigation that never began,
|
|
546
|
+
not to the one on screen. Unlabelled at the foot of a finished
|
|
547
|
+
transcript — verdict directly above — it reads as "this
|
|
548
|
+
investigation failed". It also outlives the click that caused it,
|
|
549
|
+
and this is the only place it surfaces while a run is focused, so
|
|
550
|
+
it needs a way out. */}
|
|
551
|
+
{startError && (
|
|
552
|
+
<div
|
|
553
|
+
role="alert"
|
|
554
|
+
className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-theme-text-primary"
|
|
555
|
+
>
|
|
556
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
|
557
|
+
<div className="min-w-0 flex-1">
|
|
558
|
+
<div className="font-medium">
|
|
559
|
+
Couldn't start a new investigation
|
|
560
|
+
</div>
|
|
561
|
+
<div className="text-theme-text-secondary">{startError}</div>
|
|
562
|
+
</div>
|
|
563
|
+
<button
|
|
564
|
+
onClick={dismissError}
|
|
565
|
+
className="shrink-0 rounded px-1.5 py-0.5 text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
566
|
+
>
|
|
567
|
+
Dismiss
|
|
568
|
+
</button>
|
|
532
569
|
</div>
|
|
533
570
|
)}
|
|
534
571
|
</div>
|
|
@@ -123,3 +123,42 @@ describe("ConsentCard execution profile treatment", () => {
|
|
|
123
123
|
expect(html).not.toContain("Radar still enables the agent CLI");
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
// The consent card's error box is the ONLY place a refused approval is
|
|
128
|
+
// explained: the card stays mounted on failure, focus doesn't move, and nothing
|
|
129
|
+
// else on screen changes. Red (not the card's own amber) and role="alert", or a
|
|
130
|
+
// screen-reader user re-presses a button the server will never accept.
|
|
131
|
+
describe("ConsentCard error", () => {
|
|
132
|
+
const base = {
|
|
133
|
+
agentName: "Claude",
|
|
134
|
+
profile: "safeguarded" as ExecutionProfile,
|
|
135
|
+
onApprove: noop,
|
|
136
|
+
onCancel: noop,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
it("renders the server's refusal as an alert", () => {
|
|
140
|
+
const html = renderToStaticMarkup(
|
|
141
|
+
<ConsentCard {...base} error="An owner has to allow it once." />,
|
|
142
|
+
);
|
|
143
|
+
expect(html).toContain('role="alert"');
|
|
144
|
+
expect(html).toContain("An owner has to allow it once.");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("uses the error tone, not the card's warning tone", () => {
|
|
148
|
+
// full-local renders the card itself amber; an amber box on it reads as
|
|
149
|
+
// another paragraph of body copy rather than a failure.
|
|
150
|
+
const html = renderToStaticMarkup(
|
|
151
|
+
<ConsentCard
|
|
152
|
+
{...base}
|
|
153
|
+
profile={"full-local" as ExecutionProfile}
|
|
154
|
+
error="Refused."
|
|
155
|
+
/>,
|
|
156
|
+
);
|
|
157
|
+
expect(html).toContain("border-red-500/30");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("renders no alert when the approval hasn't failed", () => {
|
|
161
|
+
const html = renderToStaticMarkup(<ConsentCard {...base} />);
|
|
162
|
+
expect(html).not.toContain('role="alert"');
|
|
163
|
+
});
|
|
164
|
+
});
|