@skyhook-io/radar-app 1.9.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.9.6",
3
+ "version": "1.9.7",
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",
@@ -75,7 +75,7 @@
75
75
  "prettier": "^3.9.5",
76
76
  "react": "^19.2.8",
77
77
  "react-dom": "^19.2.8",
78
- "react-router-dom": "^7.18.1",
78
+ "react-router-dom": "^7.18.2",
79
79
  "tailwind-merge": "^3.6.0",
80
80
  "tailwindcss": "^4.3.3",
81
81
  "typescript": "^6.0.2",
package/src/api/client.ts CHANGED
@@ -313,6 +313,7 @@ export interface DashboardProblem {
313
313
  ageSeconds: number;
314
314
  duration: string;
315
315
  durationSeconds: number;
316
+ onsetUnknown?: boolean;
316
317
  podCount?: number;
317
318
  }
318
319
 
@@ -3621,6 +3622,7 @@ export function useUpdateResource() {
3621
3622
  // Cascade delete preview — shows resources that will be garbage-collected
3622
3623
  export interface CascadeDeletePreview {
3623
3624
  root: { kind: string; namespace: string; name: string; group?: string };
3625
+ rootResolved: boolean;
3624
3626
  dependents: {
3625
3627
  kind: string;
3626
3628
  namespace: string;
@@ -3633,13 +3635,14 @@ export function useCascadeDeletePreview(
3633
3635
  kind: string,
3634
3636
  namespace: string,
3635
3637
  name: string,
3638
+ group: string | undefined,
3636
3639
  enabled: boolean,
3637
3640
  ) {
3638
3641
  return useQuery<CascadeDeletePreview>({
3639
- queryKey: ["cascade-preview", kind, namespace, name],
3642
+ queryKey: ["cascade-preview", kind, group, namespace, name],
3640
3643
  queryFn: () =>
3641
3644
  fetchJSON<CascadeDeletePreview>(
3642
- `/resources/${kind}/${namespace}/${name}/cascade-preview`,
3645
+ `/resources/${kind}/${namespace}/${name}/cascade-preview${group ? `?group=${encodeURIComponent(group)}` : ""}`,
3643
3646
  ),
3644
3647
  enabled,
3645
3648
  staleTime: 30_000,
@@ -91,6 +91,9 @@ export interface RunSummary {
91
91
  kind: string;
92
92
  namespace: string;
93
93
  name: string;
94
+ /** The issue this session is for, on hosts that key sessions by issue. Always
95
+ * absent from Radar's own backend, which records no issue. */
96
+ issueId?: string;
94
97
  context: string;
95
98
  agent?: string; // backend CLI that drove this run ("claude"/"codex")
96
99
  profile?: ExecutionProfile;
@@ -144,6 +147,15 @@ export async function createRun(
144
147
  kind: string;
145
148
  namespace: string;
146
149
  name: string;
150
+ // Associates the session with the issue it was started from, for hosts that
151
+ // group sessions that way. Inert for Radar's own backend, which neither reads
152
+ // it on start nor emits it on RunSummary — carried so both hosts share one
153
+ // request shape.
154
+ issueId?: string;
155
+ // Start a new session rather than continuing whatever the backend would
156
+ // otherwise hand back for this target. Inert for Radar's own backend, which
157
+ // only ever continues an in-flight run — and that one is never bypassed.
158
+ fresh?: boolean;
147
159
  },
148
160
  opts?: {
149
161
  agent?: string;
@@ -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) => `${r.kind}\x00${r.namespace}\x00${r.name}`)
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(() => setView("home"), []);
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
- setStartError("Radar can’t record consent for this agent.");
484
+ setConsentError("Radar can’t record consent for this agent.");
463
485
  return;
464
486
  }
465
487
  consentBusyRef.current = true;
466
- setStartError(null);
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
- setStartError("Couldn't record your consent try again.");
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 } = useDiagnose();
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
- () => openInvestigation({ kind, namespace, name }),
52
- [openInvestigation, kind, namespace, name],
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={() => openInvestigation({ kind, namespace, name })}
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={() => openInvestigation({ kind, namespace, name })}
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
- {(actionError || startError) && (
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 || startError}</span>
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&apos;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
+ });
@@ -649,11 +649,17 @@ function ConsentCardShell({
649
649
  settingsLabel,
650
650
  approveLabel = "Approve & investigate",
651
651
  warning = false,
652
+ error,
652
653
  onOpenSettings,
653
654
  onApprove,
654
655
  onCancel,
655
656
  }: DiagnoseConsentCopy & {
656
657
  warning?: boolean;
658
+ // Why the last approval failed, straight from the server. The card stays up on
659
+ // failure so retrying works in place, which means this is the ONLY chance to
660
+ // say why — a host that records consent above the individual refuses the
661
+ // wrong person here, and only its message knows who to ask.
662
+ error?: string | null;
657
663
  onOpenSettings?: () => void;
658
664
  onApprove: () => void;
659
665
  onCancel: () => void;
@@ -698,6 +704,19 @@ function ConsentCardShell({
698
704
  {resolvedSettingsLabel}
699
705
  </button>
700
706
  )}
707
+ {/* Red, not amber: the full-local card is itself amber, so an amber box on
708
+ it reads as another paragraph of body copy. role="alert" because nothing
709
+ else moves on failure — focus stays on Approve, so without it a screen
710
+ reader says nothing and the user re-presses a button that cannot succeed. */}
711
+ {error && (
712
+ <div
713
+ role="alert"
714
+ className="mt-3 flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-2 text-xs text-theme-text-primary"
715
+ >
716
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-400" />
717
+ <span>{error}</span>
718
+ </div>
719
+ )}
701
720
  <div className="mt-4 flex gap-2">
702
721
  <button
703
722
  onClick={onCancel}
@@ -731,6 +750,7 @@ export function ConsentCard({
731
750
  agent,
732
751
  profile,
733
752
  copy,
753
+ error,
734
754
  onOpenSettings,
735
755
  onApprove,
736
756
  onCancel,
@@ -739,11 +759,12 @@ export function ConsentCard({
739
759
  agent?: string;
740
760
  profile: ExecutionProfile;
741
761
  copy?: DiagnoseConsentCopy;
762
+ error?: string | null;
742
763
  onOpenSettings?: () => void;
743
764
  onApprove: () => void;
744
765
  onCancel: () => void;
745
766
  }) {
746
- const chrome = { onOpenSettings, onApprove, onCancel };
767
+ const chrome = { onOpenSettings, onApprove, onCancel, error };
747
768
 
748
769
  // Tier 1: a host (e.g. radar-hub-web) supplied its own copy — use it verbatim.
749
770
  if (copy) return <ConsentCardShell {...copy} {...chrome} />;
@@ -390,9 +390,14 @@ function ProblemsPanel({
390
390
  <div className="flex items-center gap-1.5">
391
391
  <span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
392
392
  <span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
393
- {(age || timing) && (
393
+ {(age || timing || issue.onset_unknown) && (
394
394
  <span className="ml-auto flex shrink-0 items-center gap-1">
395
395
  {age && <span className="text-[10px] text-theme-text-tertiary tabular-nums">{age}</span>}
396
+ {issue.onset_unknown && (
397
+ <Tooltip content="Radar can confirm this issue is active, but current Kubernetes state does not reveal when it began." delay={100}>
398
+ <span className="text-[10px] text-theme-text-tertiary">Onset unknown</span>
399
+ </Tooltip>
400
+ )}
396
401
  {timing && (
397
402
  <Tooltip content={timing.tooltip} delay={100}>
398
403
  <span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
@@ -248,7 +248,13 @@ interface WorkloadViewProps {
248
248
  pushTabHistory?: boolean
249
249
  }
250
250
 
251
- function useActionsBarProps(kind: string, namespace: string, name: string) {
251
+ function useActionsBarProps(
252
+ kind: string,
253
+ namespace: string,
254
+ name: string,
255
+ group: string | undefined,
256
+ cascadeEnabled: boolean,
257
+ ) {
252
258
  const { showCopied } = useToast()
253
259
  const openTerminal = useOpenTerminal()
254
260
  const openLogs = useOpenLogs()
@@ -284,11 +290,16 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
284
290
  const argoSuspendMutation = useArgoSuspend()
285
291
  const argoResumeMutation = useArgoResume()
286
292
 
287
- const { data: cascadePreview, isLoading: cascadeLoading } = useCascadeDeletePreview(
293
+ const {
294
+ data: cascadePreview,
295
+ isLoading: cascadeLoading,
296
+ isError: cascadeError,
297
+ } = useCascadeDeletePreview(
288
298
  kind,
289
299
  namespace,
290
300
  name,
291
- true,
301
+ group,
302
+ cascadeEnabled,
292
303
  )
293
304
 
294
305
  const canNodeWrite = useCanNodeWrite()
@@ -327,6 +338,7 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
327
338
  isDeleting: deleteMutation.isPending,
328
339
  cascadeDependents: cascadePreview?.dependents,
329
340
  cascadeLoading,
341
+ cascadeRootResolved: cascadeError ? false : cascadePreview?.rootResolved,
330
342
  onRestart: (params: Parameters<typeof restartWorkloadMutation.mutate>[0]) =>
331
343
  restartWorkloadMutation.mutate(params),
332
344
  isRestarting: restartWorkloadMutation.isPending,
@@ -675,7 +687,13 @@ export function WorkloadView({
675
687
  )
676
688
  const updateResource = useUpdateResource()
677
689
  const previewResources = usePreviewResources()
678
- const baseActionsBarProps = useActionsBarProps(apiKind, namespace, name)
690
+ const baseActionsBarProps = useActionsBarProps(
691
+ apiKind,
692
+ namespace,
693
+ name,
694
+ effectiveGroup,
695
+ !resourceLoading && Boolean(resource),
696
+ )
679
697
  const desktopDownload = useDesktopDownload()
680
698
 
681
699
  // Live Operational Issues for this resource. Fetched here (not inside the lead