@skyhook-io/radar-app 1.16.2 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +60 -12
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +168 -55
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.test.ts +40 -0
  8. package/src/api/diagnose.ts +12 -1
  9. package/src/api/radarFeatures.render.test.tsx +19 -1
  10. package/src/api/radarFeatures.ts +3 -0
  11. package/src/api/timelineSource.ts +1 -1
  12. package/src/api/traffic.sources.test.ts +55 -0
  13. package/src/api/traffic.ts +173 -14
  14. package/src/components/NoClusterAccessBanner.tsx +68 -0
  15. package/src/components/applications/ApplicationsView.tsx +3 -0
  16. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  17. package/src/components/capacity/CapacityView.test.tsx +31 -1
  18. package/src/components/capacity/shared.tsx +12 -21
  19. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  20. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  21. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  22. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  23. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  24. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  25. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  26. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  27. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  28. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  29. package/src/components/cnpg/CNPGView.tsx +156 -0
  30. package/src/components/cnpg/paths.ts +13 -0
  31. package/src/components/cnpg/routes.test.ts +60 -0
  32. package/src/components/cnpg/routes.ts +112 -0
  33. package/src/components/cnpg/shared.tsx +290 -0
  34. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  35. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  36. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  37. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  38. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  39. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  40. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  41. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  42. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  43. package/src/components/diagnose/InvestigationView.tsx +39 -2
  44. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  45. package/src/components/diagnose/agentCatalog.ts +2 -2
  46. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  47. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  48. package/src/components/execution/BatchExecutionView.tsx +11 -11
  49. package/src/components/helm/ChartBrowser.tsx +9 -10
  50. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  51. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  52. package/src/components/helm/InstallWizard.tsx +5 -2
  53. package/src/components/helm/RevisionHistory.tsx +3 -2
  54. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  55. package/src/components/helm/ValuesViewer.tsx +1 -1
  56. package/src/components/home/ClusterHealthCard.tsx +7 -0
  57. package/src/components/home/mcpToolCatalog.ts +1 -1
  58. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  59. package/src/components/resources/ResourcesView.tsx +6 -38
  60. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  61. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  63. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  64. package/src/components/settings/SettingsDialog.tsx +122 -19
  65. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  66. package/src/components/timeline/TimelineList.test.ts +47 -29
  67. package/src/components/timeline/TimelineList.tsx +60 -11
  68. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  69. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  70. package/src/components/timeline/TimelineView.tsx +16 -2
  71. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  72. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  73. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  74. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  75. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  76. package/src/components/traffic/TrafficGraph.tsx +109 -26
  77. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  78. package/src/components/traffic/TrafficView.tsx +582 -149
  79. package/src/components/traffic/trafficFilters.test.ts +398 -1
  80. package/src/components/traffic/trafficFilters.ts +408 -0
  81. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  82. package/src/components/whats-new/WhatsNew.tsx +146 -86
  83. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  84. package/src/components/whats-new/releaseNotes.ts +155 -24
  85. package/src/components/workload/WorkloadView.test.ts +2 -1
  86. package/src/components/workload/WorkloadView.tsx +165 -294
  87. package/src/context/TimelineCustomization.tsx +15 -0
  88. package/src/contexts/CapabilitiesContext.tsx +9 -1
  89. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  90. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  91. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  92. package/src/hooks/useResourceCounts.ts +49 -0
  93. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -60,15 +60,31 @@ export type DiagnoseView = "home" | "investigation";
60
60
  // Setup readiness of local AI investigations, derived from the agents API:
61
61
  // - "ready": an agent is installed and the engine is running (available)
62
62
  // - "needs-install": the feature is supported here but no agent CLI is installed
63
- // - "needs-restart": a supported agent is now on PATH but Radar booted before it
64
- // existed (the engine is decided once, at startup)
63
+ // - "needs-restart": a supported agent is installed but investigations are
64
+ // still off. The server picks up new installs whenever the
65
+ // agent list is fetched, so locally this means RADAR_AI_CLI_BIN
66
+ // pins the engine to a file Radar can't run (cliOverride); an
67
+ // embedding host may report it when its own runner is down
65
68
  // - "off": not available in this deployment (proxy/OIDC auth, --no-mcp,
66
69
  // or an embed host) — no install nudge would help
67
- export type DiagnoseSetup = "ready" | "needs-install" | "needs-restart" | "off";
70
+ // - "unknown": the agents probe hasn't answered (in flight, or it failed
71
+ // and was swallowed). Nothing about the machine or the
72
+ // deployment is established yet, so say nothing about either
73
+ export type DiagnoseSetup =
74
+ "ready" | "needs-install" | "needs-restart" | "off" | "unknown";
68
75
 
69
76
  interface DiagnoseCtx {
70
77
  available: boolean; // an agent CLI is present (button/entry gate)
71
78
  setupState: DiagnoseSetup; // readiness for the setup nudge (see DiagnoseSetup)
79
+ // Re-reads the agent list; the server turns investigations on if a CLI was
80
+ // installed since the last check. Resolves once the new state is applied.
81
+ recheckAgents: () => Promise<void>;
82
+ checkingAgents: boolean;
83
+ // The latest agent check failed to reach the server, so nothing about the
84
+ // machine is known from it.
85
+ agentsCheckFailed: boolean;
86
+ // RADAR_AI_CLI_BIN is set; with "needs-restart" it is what's broken.
87
+ cliOverride: boolean;
72
88
  agentLabel: string; // label of the selected agent, e.g. "Claude Code"
73
89
  hosted: boolean; // selected agent runs on the host's backend, not this machine
74
90
  agents: AgentInfo[]; // supported agents detected on PATH (for the picker)
@@ -172,6 +188,31 @@ const MIN_APP_LEFT_OF_PANEL = 900;
172
188
  // area instead. Radar's own minimum width is about 856px, so this is the
173
189
  // drawer's floor: below it the investigation is the screen.
174
190
  const MIN_APP_PEEK_BESIDE_OVERLAY = 320;
191
+ // resolveSetupState is the ONE mapping from the agents probe to what every
192
+ // surface renders. Exported so it can be pinned by test, since each state drives
193
+ // a different user-facing claim.
194
+ //
195
+ // "unknown" is not the same as "off". `eligible` starts false and the probe's
196
+ // catch leaves it that way, so without a separate state a pending or failed
197
+ // request reads as a confirmed deployment restriction. A non-empty agent list
198
+ // while the engine is off means a drivable CLI appeared after Radar booted.
199
+ export function resolveSetupState({
200
+ available,
201
+ eligibilityResolved,
202
+ eligible,
203
+ supportedAgentCount,
204
+ }: {
205
+ available: boolean;
206
+ eligibilityResolved: boolean;
207
+ eligible: boolean;
208
+ supportedAgentCount: number;
209
+ }): DiagnoseSetup {
210
+ if (available) return "ready";
211
+ if (!eligibilityResolved) return "unknown";
212
+ if (!eligible) return "off";
213
+ return supportedAgentCount > 0 ? "needs-restart" : "needs-install";
214
+ }
215
+
175
216
  export function investigationPanelFillsViewport(
176
217
  viewportWidth: number,
177
218
  panelWidth: number,
@@ -452,52 +493,79 @@ function RoutedDiagnoseProvider({
452
493
  const narrow = viewportW - width < MIN_APP_LEFT_OF_PANEL;
453
494
  const fullWidthForced = investigationPanelFillsViewport(viewportW, width);
454
495
 
496
+ const mountedRef = useRef(true);
455
497
  useEffect(() => {
456
- let live = true;
457
- fetchAgents()
458
- .then((r) => {
459
- if (!live) return;
460
- setConsented(r.consented ?? {});
461
- setEligible(!!r.eligible);
462
- const supported = r.agents.filter(
463
- (a) =>
464
- a.supported &&
465
- (a.hosted ||
466
- (!!a.profiles?.length &&
467
- a.profiles.every((p) => !!a.consentSurfaces?.[p]))),
468
- );
469
- setAvailable(r.enabled && supported.length > 0);
470
- setAgents(supported);
471
- // Keep the stored pick only if it's still installed; else default to the
472
- // first supported agent (matches the server's default selection).
473
- const stored = readStored(AGENT_KEY) || "";
474
- const next =
475
- stored && supported.some((a) => a.name === stored)
476
- ? stored
477
- : (supported[0]?.name ?? "");
478
- setSelectedAgentState(next);
479
- const profiles = supported.find((a) => a.name === next)?.profiles ?? [];
480
- const storedProfile =
481
- (readStored(PROFILE_KEY) as ExecutionProfile) || "safeguarded";
482
- if (!profiles.includes(storedProfile) && profiles[0]) {
483
- setProfileState(profiles[0]);
484
- writeStored(PROFILE_KEY, profiles[0]);
485
- }
486
- // Model/effort are agent-specific; if the stored agent is gone, its values
487
- // don't apply to the fallback agent (e.g. a Codex slug under Claude) — drop them.
488
- if (next !== stored) {
489
- setModelState("");
490
- writeStored(MODEL_KEY, "");
491
- setEffortState("");
492
- writeStored(EFFORT_KEY, "");
493
- }
494
- setAgentEligibilityResolved(true);
495
- })
496
- .catch(() => {});
498
+ mountedRef.current = true;
497
499
  return () => {
498
- live = false;
500
+ mountedRef.current = false;
499
501
  };
500
502
  }, []);
503
+ const [checkingAgents, setCheckingAgents] = useState(false);
504
+ const [agentsCheckFailed, setAgentsCheckFailed] = useState(false);
505
+ const [cliOverride, setCliOverride] = useState(false);
506
+ // A click and a window-focus re-check can overlap; only the newest one may
507
+ // apply its answer, or a stale "nothing installed" could land last.
508
+ const agentCheckSeq = useRef(0);
509
+ // The agent in use now, picked by hand or by default. A check compares
510
+ // against it, not just the stored pick, so seeing the same agent again
511
+ // keeps the model and effort set for it.
512
+ const agentInUseRef = useRef(selectedAgent);
513
+ const recheckAgents = useCallback(async () => {
514
+ const seq = ++agentCheckSeq.current;
515
+ const latest = () => mountedRef.current && seq === agentCheckSeq.current;
516
+ setCheckingAgents(true);
517
+ try {
518
+ const r = await fetchAgents();
519
+ if (!latest()) return;
520
+ setAgentsCheckFailed(false);
521
+ setCliOverride(!!r.cliOverride);
522
+ setConsented(r.consented ?? {});
523
+ setEligible(!!r.eligible);
524
+ const supported = r.agents.filter(
525
+ (a) =>
526
+ a.supported &&
527
+ (a.hosted ||
528
+ (!!a.profiles?.length &&
529
+ a.profiles.every((p) => !!a.consentSurfaces?.[p]))),
530
+ );
531
+ setAvailable(r.enabled && supported.length > 0);
532
+ setAgents(supported);
533
+ // Keep the stored pick only if it's still installed; else default to the
534
+ // first supported agent. Runs always name it, so the server's own default
535
+ // doesn't apply.
536
+ const stored = readStored(AGENT_KEY) || "";
537
+ const next =
538
+ stored && supported.some((a) => a.name === stored)
539
+ ? stored
540
+ : (supported[0]?.name ?? "");
541
+ setSelectedAgentState(next);
542
+ const profiles = supported.find((a) => a.name === next)?.profiles ?? [];
543
+ const storedProfile =
544
+ (readStored(PROFILE_KEY) as ExecutionProfile) || "safeguarded";
545
+ if (!profiles.includes(storedProfile) && profiles[0]) {
546
+ setProfileState(profiles[0]);
547
+ writeStored(PROFILE_KEY, profiles[0]);
548
+ }
549
+ // Model/effort are agent-specific; if the agent changed, its values
550
+ // don't apply to the new one (e.g. a Codex slug under Claude), so drop them.
551
+ if (next !== agentInUseRef.current) {
552
+ setModelState("");
553
+ writeStored(MODEL_KEY, "");
554
+ setEffortState("");
555
+ writeStored(EFFORT_KEY, "");
556
+ }
557
+ agentInUseRef.current = next;
558
+ setAgentEligibilityResolved(true);
559
+ } catch {
560
+ // Leaves the previous state in place; "unknown" until a probe answers.
561
+ if (latest()) setAgentsCheckFailed(true);
562
+ } finally {
563
+ if (latest()) setCheckingAgents(false);
564
+ }
565
+ }, []);
566
+ useEffect(() => {
567
+ void recheckAgents();
568
+ }, [recheckAgents]);
501
569
 
502
570
  const setModel = useCallback((v: string) => {
503
571
  setModelState(v);
@@ -511,6 +579,7 @@ function RoutedDiagnoseProvider({
511
579
  (name: string) => {
512
580
  setSelectedAgentState(name);
513
581
  writeStored(AGENT_KEY, name);
582
+ agentInUseRef.current = name;
514
583
  const nextProfile = agents.find((agent) => agent.name === name)
515
584
  ?.profiles?.[0];
516
585
  if (nextProfile) {
@@ -541,15 +610,21 @@ function RoutedDiagnoseProvider({
541
610
  ? "standard"
542
611
  : (selectedAgentInfo?.consentSurfaces?.[effectiveProfile] ?? "");
543
612
 
544
- // `agents` holds only supported CLIs (filtered on fetch), so a non-empty list
545
- // while the engine is off means a drivable agent appeared on PATH after boot.
546
- const setupState: DiagnoseSetup = available
547
- ? "ready"
548
- : !eligible
549
- ? "off"
550
- : agents.length > 0
551
- ? "needs-restart"
552
- : "needs-install";
613
+ const setupState = resolveSetupState({
614
+ available,
615
+ eligibilityResolved: agentEligibilityResolved,
616
+ eligible,
617
+ supportedAgentCount: agents.length,
618
+ });
619
+
620
+ // Coming back from a terminal after installing a CLI is when to look again,
621
+ // so the setup notice clears without the user hunting for a button.
622
+ useEffect(() => {
623
+ if (setupState === "ready" || setupState === "off") return;
624
+ const onFocus = () => void recheckAgents();
625
+ window.addEventListener("focus", onFocus);
626
+ return () => window.removeEventListener("focus", onFocus);
627
+ }, [setupState, recheckAgents]);
553
628
 
554
629
  useEffect(() => {
555
630
  const onResize = () => setViewportW(window.innerWidth);
@@ -701,6 +776,9 @@ function RoutedDiagnoseProvider({
701
776
  writeFocusedRunID(run.id, true);
702
777
  })
703
778
  .catch((e) => {
779
+ // The picked agent may have been uninstalled since the list was
780
+ // fetched; refresh it so the picker shows what's installed now.
781
+ void recheckAgents();
704
782
  if (seq !== startSeqRef.current) return;
705
783
  setStartFailure({
706
784
  message:
@@ -1021,6 +1099,10 @@ function RoutedDiagnoseProvider({
1021
1099
  const value: DiagnoseCtx = {
1022
1100
  available,
1023
1101
  setupState,
1102
+ recheckAgents,
1103
+ checkingAgents,
1104
+ agentsCheckFailed,
1105
+ cliOverride,
1024
1106
  agentLabel,
1025
1107
  hosted,
1026
1108
  agents,
@@ -502,7 +502,8 @@ export function DiagnoseSurface({
502
502
  };
503
503
 
504
504
  // Feature is eligible here but not runnable yet (no agent installed, or one
505
- // appeared after boot) — Home leads with the setup notice instead of an empty list.
505
+ // found that investigations couldn't start with): Home leads with the setup
506
+ // notice instead of an empty list.
506
507
  const setupPending =
507
508
  d.setupState === "needs-install" || d.setupState === "needs-restart";
508
509
 
@@ -617,7 +618,13 @@ export function DiagnoseSurface({
617
618
  </div>
618
619
  ) : setupPending ? (
619
620
  <div className="flex-1 overflow-y-auto">
620
- <AgentSetupNotice setupState={d.setupState} />
621
+ <AgentSetupNotice
622
+ setupState={d.setupState}
623
+ cliOverride={d.cliOverride}
624
+ checkingAgents={d.checkingAgents}
625
+ agentsCheckFailed={d.agentsCheckFailed}
626
+ recheckAgents={d.recheckAgents}
627
+ />
621
628
  </div>
622
629
  ) : (
623
630
  <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-theme-text-tertiary">
@@ -639,7 +646,13 @@ export function DiagnoseSurface({
639
646
  </div>
640
647
  ) : setupPending ? (
641
648
  <div className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6">
642
- <AgentSetupNotice setupState={d.setupState} />
649
+ <AgentSetupNotice
650
+ setupState={d.setupState}
651
+ cliOverride={d.cliOverride}
652
+ checkingAgents={d.checkingAgents}
653
+ agentsCheckFailed={d.agentsCheckFailed}
654
+ recheckAgents={d.recheckAgents}
655
+ />
643
656
  </div>
644
657
  ) : (
645
658
  <InvestigationHome
@@ -58,7 +58,9 @@ import {
58
58
  type KeyboardEvent,
59
59
  } from "react";
60
60
  import { useQueryClient } from "@tanstack/react-query";
61
- import { Badge } from "@skyhook-io/k8s-ui";
61
+ import { useNavigate } from "react-router-dom";
62
+ import { Badge, gitOpsRouteForOwner, type GitOpsWrite } from "@skyhook-io/k8s-ui";
63
+ import { useGitOpsWriteGuard } from "../../hooks/useGitOpsWriteGuard";
62
64
  import {
63
65
  Send,
64
66
  AlertTriangle,
@@ -264,6 +266,8 @@ function captureEvidenceCardLayout(container: HTMLElement) {
264
266
  );
265
267
  }
266
268
 
269
+ const APPLY_WRITES: GitOpsWrite[] = [{ scope: "spec", description: "The proposed change" }];
270
+
267
271
  export function InvestigationView({
268
272
  run,
269
273
  agentLabel,
@@ -949,6 +953,38 @@ export function InvestigationView({
949
953
  // Apply: a user-confirmed remediation turn. Any step is applyable; the chosen
950
954
  // step's text is sent so the server binds the apply to it.
951
955
  const [confirmApply, setConfirmApply] = useState(false);
956
+ // The agent's change can touch any field of the target, so the guard
957
+ // classifies an unknown spec write.
958
+ const { guard: applyGitOpsGuard, ownership: applyOwnership } =
959
+ useGitOpsWriteGuard({
960
+ target: { kind, group: run.group, namespace, name },
961
+ writes: APPLY_WRITES,
962
+ declaredManager: run.managedBy,
963
+ enabled: confirmApply,
964
+ });
965
+ const navigateRouter = useNavigate();
966
+ const {
967
+ owner: applyOwner,
968
+ ownerVerified: applyOwnerVerified,
969
+ helmOwner: applyHelmOwner,
970
+ } = applyOwnership;
971
+ const openApplyOwner = useMemo(() => {
972
+ // A host without resource navigation (Radar Hub's fleet Diagnose panel)
973
+ // has no route for the owner either.
974
+ if (!onOpenResource) return undefined;
975
+ const owner = applyOwner;
976
+ const helmOwner = applyHelmOwner;
977
+ if (owner && applyOwnerVerified) {
978
+ return () => navigateRouter(gitOpsRouteForOwner(owner));
979
+ }
980
+ if (helmOwner) {
981
+ return () =>
982
+ navigateRouter(
983
+ `/helm?release=${encodeURIComponent(`${helmOwner.namespace}/${helmOwner.name}`)}`,
984
+ );
985
+ }
986
+ return undefined;
987
+ }, [applyOwner, applyOwnerVerified, applyHelmOwner, navigateRouter, onOpenResource]);
952
988
  const [pendingFix, setPendingFix] = useState("");
953
989
  const requestApply = (fix: string) => {
954
990
  if (interactionsBlocked) return;
@@ -2546,7 +2582,8 @@ export function InvestigationView({
2546
2582
  ]?.precondition
2547
2583
  : undefined
2548
2584
  }
2549
- managedBy={run.managedBy}
2585
+ gitOpsGuard={applyGitOpsGuard}
2586
+ onOpenGitOpsOwner={openApplyOwner}
2550
2587
  confidence={turns[lastRemediationIdx]?.diagnosis?.confidence}
2551
2588
  />
2552
2589
  </div>
@@ -1,13 +1,18 @@
1
1
  import { Loader2, Sparkles } from "lucide-react";
2
- import { useDiagnose, useDiagnoseLayout } from "./DiagnoseContext";
2
+ import {
3
+ type DiagnoseSetup,
4
+ useDiagnose,
5
+ useDiagnoseLayout,
6
+ } from "./DiagnoseContext";
3
7
  import { runTargetKey } from "./target";
4
8
  import { Tooltip } from "../ui/Tooltip";
5
9
  import type { RenderDiagnoseAction } from "../../context/DiagnoseCustomization";
6
10
 
7
11
  // The per-resource AI entry point. It no longer owns a panel — it just dispatches
8
12
  // to the single app-level AI surface (DiagnoseContext), opening a new investigation
9
- // for this resource. Self-hides when no agent CLI is present. Hosts can override
10
- // this slot with their own action.
13
+ // for this resource. Hidden only where investigations can't run here; with no
14
+ // agent CLI it stays, and opens the setup notice. Hosts can override this slot
15
+ // with their own action.
11
16
  //
12
17
  // Adaptive by health: on a resource with a live problem it reads as a prominent
13
18
  // "Investigate" action; when the resource is fine or health is unknown it shrinks
@@ -33,13 +38,13 @@ function DiagnoseResourceButton({
33
38
  // Hidden only when the feature can't work here (auth/cloud/--no-mcp). When it's
34
39
  // supported but no agent is installed we KEEP the button — clicking opens the
35
40
  // setup notice so the feature is discoverable rather than silently absent.
36
- if (d.setupState === "off") return null;
41
+ if (d.setupState === "off" || d.setupState === "unknown") return null;
37
42
  const ready = d.available;
38
43
  const problem = health === "problem";
39
44
  const running =
40
45
  ready && runningKeys.has(runTargetKey(kind, namespace, name, group ?? ""));
41
46
  const tooltip = !ready
42
- ? "Set up AI investigations — runs your own agent locally."
47
+ ? notReadyTooltip(d)
43
48
  : running
44
49
  ? `${d.agentLabel} is investigating this resource — click to watch it live.`
45
50
  : d.hosted
@@ -67,7 +72,7 @@ function DiagnoseResourceButton({
67
72
  }
68
73
  aria-label={
69
74
  !ready
70
- ? "Set up AI investigations"
75
+ ? notReadyTooltip(d)
71
76
  : running
72
77
  ? "Investigation running — click to view"
73
78
  : problem
@@ -122,18 +127,16 @@ export function IssueDiagnoseButton({
122
127
  name: string;
123
128
  }) {
124
129
  const d = useDiagnose();
125
- if (d.setupState === "off") return null;
130
+ if (d.setupState === "off" || d.setupState === "unknown") return null;
126
131
  const ready = d.available;
127
132
  return (
128
133
  <Tooltip
129
134
  content={
130
- d.setupState === "needs-restart"
131
- ? "Restart Radar to enable AI investigations — a supported agent is installed"
132
- : !ready
133
- ? "Set up AI investigations — install a local agent"
134
- : d.hosted
135
- ? `Sends this resource's context to ${d.agentLabel} for investigation`
136
- : `Runs ${d.agentLabel} on your machine to investigate this resource`
135
+ !ready
136
+ ? notReadyTooltip(d)
137
+ : d.hosted
138
+ ? `Sends this resource's context to ${d.agentLabel} for investigation`
139
+ : `Runs ${d.agentLabel} on your machine to investigate this resource`
137
140
  }
138
141
  position="left"
139
142
  >
@@ -158,12 +161,27 @@ export function IssueDiagnoseButton({
158
161
  );
159
162
  }
160
163
 
164
+ // The tooltip every AI entry point shows while investigations aren't runnable.
165
+ // It gives the same reason as the setup notice the button opens.
166
+ function notReadyTooltip(d: {
167
+ cliOverride: boolean;
168
+ setupState: DiagnoseSetup;
169
+ }): string {
170
+ if (d.cliOverride) {
171
+ return "AI investigations are off: can't run RADAR_AI_CLI_BIN";
172
+ }
173
+ if (d.setupState === "needs-restart") {
174
+ return "AI investigations aren't available right now";
175
+ }
176
+ return "Set up AI investigations";
177
+ }
178
+
161
179
  // Global top-bar entry into the AI surface (opens its Home / recent
162
- // investigations). Self-hides when no agent CLI is present.
180
+ // investigations). Hidden only where investigations can't run here.
163
181
  export function GlobalDiagnoseButton() {
164
182
  const d = useDiagnose();
165
183
  const { runningCount } = useDiagnoseLayout();
166
- if (d.setupState === "off") return null;
184
+ if (d.setupState === "off" || d.setupState === "unknown") return null;
167
185
  const ready = d.available;
168
186
  const agentSuffix = d.hosted
169
187
  ? `powered by ${d.agentLabel}`
@@ -172,7 +190,7 @@ export function GlobalDiagnoseButton() {
172
190
  <Tooltip
173
191
  content={
174
192
  !ready
175
- ? "Set up AI investigations — runs your own agent locally"
193
+ ? notReadyTooltip(d)
176
194
  : runningCount > 0
177
195
  ? `${runningCount} investigation${runningCount > 1 ? "s" : ""} running — ${agentSuffix}`
178
196
  : `AI investigations — ${agentSuffix}`
@@ -183,9 +201,11 @@ export function GlobalDiagnoseButton() {
183
201
  onClick={() => d.openWorkspace()}
184
202
  className="relative rounded-md bg-theme-elevated p-1.5 text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
185
203
  aria-label={
186
- runningCount > 0
187
- ? `AI investigations (${runningCount} running)`
188
- : "AI investigations"
204
+ !ready
205
+ ? notReadyTooltip(d)
206
+ : runningCount > 0
207
+ ? `AI investigations (${runningCount} running)`
208
+ : "AI investigations"
189
209
  }
190
210
  >
191
211
  <Sparkles className="h-4 w-4 text-accent" />
@@ -13,7 +13,7 @@ export const SUPPORTED_AGENTS: AgentInstall[] = [
13
13
  name: "claude",
14
14
  label: "Claude Code",
15
15
  install: "npm install -g @anthropic-ai/claude-code",
16
- docs: "https://docs.claude.com/en/docs/claude-code/setup",
16
+ docs: "https://code.claude.com/docs/en/setup",
17
17
  },
18
18
  {
19
19
  name: "codex",
@@ -25,7 +25,7 @@ export const SUPPORTED_AGENTS: AgentInstall[] = [
25
25
  name: "cursor-agent",
26
26
  label: "Cursor CLI",
27
27
  install: "curl https://cursor.com/install -fsS | bash",
28
- docs: "https://docs.cursor.com/en/cli/overview",
28
+ docs: "https://cursor.com/docs/cli/overview",
29
29
  },
30
30
  {
31
31
  name: "opencode",
@@ -93,6 +93,8 @@ export interface DiagnosisResourceContext {
93
93
  };
94
94
  runsOn?: DiagnosisResourceRef;
95
95
  scaledBy?: DiagnosisScalerRef[];
96
+ dependencies?: DiagnosisResourceRef[];
97
+ dependents?: DiagnosisResourceRef[];
96
98
  statusSummary?: {
97
99
  phase?: string;
98
100
  conditions?: Array<{
@@ -1,18 +1,39 @@
1
1
  import { useTrafficFlowList } from '../traffic/TrafficFlowListContext'
2
2
  import { TrafficFlowList } from '../traffic/TrafficFlowList'
3
- import { List } from 'lucide-react'
3
+ import { List, Loader2 } from 'lucide-react'
4
4
 
5
5
  export function TrafficFlowListTab() {
6
- const { flows, responsesCallerOriented } = useTrafficFlowList()
6
+ const { flows, responsesCallerOriented, graphSelection, loading, note } = useTrafficFlowList()
7
7
 
8
- if (flows.length === 0) {
8
+ if (loading) {
9
9
  return (
10
10
  <div className="flex items-center justify-center h-full text-sm text-theme-text-tertiary gap-2">
11
- <List className="w-4 h-4" />
12
- Navigate to Traffic view to see flows
11
+ <Loader2 className="w-4 h-4 animate-spin" />
12
+ Loading flows for the selection…
13
+ </div>
14
+ )
15
+ }
16
+
17
+ if (flows.length === 0) {
18
+ return (
19
+ <div className="flex flex-col items-center justify-center h-full gap-1 px-4 text-center">
20
+ <div className="flex items-center gap-2 text-sm text-theme-text-tertiary">
21
+ <List className="w-4 h-4" />
22
+ {graphSelection ? 'No flow records for this selection' : 'Navigate to Traffic view to see flows'}
23
+ </div>
24
+ {note && <div className="text-[11px] text-theme-text-tertiary">{note}</div>}
13
25
  </div>
14
26
  )
15
27
  }
16
28
 
17
- return <TrafficFlowList flows={flows} responsesCallerOriented={responsesCallerOriented} />
29
+ return (
30
+ <div className="flex flex-col h-full">
31
+ {note && (
32
+ <div className="px-3 py-1 border-b border-theme-border text-[11px] text-theme-text-tertiary">{note}</div>
33
+ )}
34
+ <div className="relative flex-1 min-h-0">
35
+ <TrafficFlowList flows={flows} responsesCallerOriented={responsesCallerOriented} />
36
+ </div>
37
+ </div>
38
+ )
18
39
  }
@@ -389,10 +389,10 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou
389
389
  </div>
390
390
  <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
391
391
  <span>{RUN_KIND_LABEL[selectedRun.kind] ?? selectedRun.kind}</span>
392
- {selectedRun.startedAt && <span>started {formatAge(selectedRun.startedAt)}</span>}
392
+ {selectedRun.startedAt && <span>started {formatTimeAgo(selectedRun.startedAt)}</span>}
393
393
  {selectedRun.trigger === 'manual' && <span>manual trigger</span>}
394
394
  {selectedRun.trigger === 'event' && <span>event triggered</span>}
395
- {selectedRun.scheduledAt && <span>scheduled {formatAge(selectedRun.scheduledAt)}</span>}
395
+ {selectedRun.scheduledAt && <span>scheduled {formatTimeAgo(selectedRun.scheduledAt)}</span>}
396
396
  {selectedResourceQuery.isFetching && <span>refreshing</span>}
397
397
  </div>
398
398
  </div>
@@ -568,8 +568,8 @@ function sourceFacts(kind: string, resource: any, runs: WorkloadRun[]) {
568
568
  duration: latest ? formatRunDuration(latest) : '',
569
569
  work: `${status.active?.length ?? 0} active`,
570
570
  facts: [
571
- ['Last schedule', status.lastScheduleTime ? formatAge(status.lastScheduleTime) : 'Never'],
572
- ['Last success', status.lastSuccessfulTime ? formatAge(status.lastSuccessfulTime) : 'Never'],
571
+ ['Last schedule', status.lastScheduleTime ? formatTimeAgo(status.lastScheduleTime) : 'Never'],
572
+ ['Last success', status.lastSuccessfulTime ? formatTimeAgo(status.lastSuccessfulTime) : 'Never'],
573
573
  ['Starting deadline', spec.startingDeadlineSeconds ? `${spec.startingDeadlineSeconds}s` : 'None'],
574
574
  ],
575
575
  definition,
@@ -818,15 +818,15 @@ function RunDetailList({ run, resource, workflowExecution, scheduledParent, memb
818
818
  ...(run.jobset?.replicatedJob ? [['Role', run.jobset?.replicatedJob]] as Array<[string, string]> : []),
819
819
  ...(run.jobset?.restartAttempt ? [['JobSet restart attempt', `#${run.jobset?.restartAttempt}`]] as Array<[string, string]> : []),
820
820
  ...(run.jobset?.jobRestartAttempt ? [['Job restart attempt', `#${run.jobset?.jobRestartAttempt}`]] as Array<[string, string]> : []),
821
- ['Started', run.startedAt ? formatAge(run.startedAt) : '-'],
822
- ['Finished', run.finishedAt ? formatAge(run.finishedAt) : run.active ? 'Running' : '-'],
821
+ ['Started', run.startedAt ? formatTimeAgo(run.startedAt) : '-'],
822
+ ['Finished', run.finishedAt ? formatTimeAgo(run.finishedAt) : run.active ? 'Running' : '-'],
823
823
  ['Duration', formatRunDuration(run) || '-'],
824
824
  ...(run.progress ? [['Progress', run.progress]] as Array<[string, string]> : []),
825
825
  ...(!memberCollection && (scheduledParent || run.trigger || run.scheduledAt) ? [
826
826
  ['Trigger', run.trigger === 'manual' ? 'Manual' : run.trigger === 'event' ? 'Event' : run.scheduledAt ? 'Cron schedule' : '-'],
827
827
  ] as Array<[string, string]> : []),
828
828
  ...(run.scheduledAt ? [
829
- ['Cron scheduled', formatAge(run.scheduledAt)],
829
+ ['Cron scheduled', formatTimeAgo(run.scheduledAt)],
830
830
  ['Start delay', formatScheduleDelay(run) || '-'],
831
831
  ] as Array<[string, string]> : []),
832
832
  ['Parallelism', run.parallelism ? String(run.parallelism) : '-'],
@@ -1008,7 +1008,7 @@ function RunActivityRow({ item, runMessage }: { item: WorkflowExecutionActivity;
1008
1008
  <div className="min-w-0 flex-1">
1009
1009
  <div className="flex items-start justify-between gap-3">
1010
1010
  <div className="truncate text-sm font-medium text-theme-text-primary">{item.label}</div>
1011
- <div className="shrink-0 text-xs text-theme-text-tertiary">{formatAge(item.at)}</div>
1011
+ <div className="shrink-0 text-xs text-theme-text-tertiary">{formatTimeAgo(item.at)}</div>
1012
1012
  </div>
1013
1013
  {item.detail && item.detail !== runMessage && item.tone !== 'danger' && <div className="mt-0.5 break-words text-xs text-theme-text-secondary">{item.detail}</div>}
1014
1014
  </div>
@@ -1539,10 +1539,10 @@ function formatRunDuration(run: WorkloadRun): string {
1539
1539
 
1540
1540
  function formatRunTime(run: WorkloadRun): string {
1541
1541
  const raw = run.startedAt || run.scheduledAt || run.finishedAt
1542
- return raw ? formatAge(raw) : ''
1542
+ return raw ? formatTimeAgo(raw) : ''
1543
1543
  }
1544
1544
 
1545
- function formatAge(value: string): string {
1545
+ function formatTimeAgo(value: string): string {
1546
1546
  const t = Date.parse(value)
1547
1547
  if (Number.isNaN(t)) return value
1548
1548
  const diff = Date.now() - t
@@ -1577,7 +1577,7 @@ function emptyRunsCopy(kind: string, resource: any): { headline: string; body: s
1577
1577
  }
1578
1578
  const lastScheduled = resource?.status?.lastScheduleTime || resource?.status?.lastScheduledTime
1579
1579
  if (lastScheduled) {
1580
- return { headline: 'No retained runs', body: `This resource last scheduled work ${formatAge(lastScheduled)}, but its ${runKindPluralForSchedule(kind)} are no longer retained in Kubernetes.` }
1580
+ return { headline: 'No retained runs', body: `This resource last scheduled work ${formatTimeAgo(lastScheduled)}, but its ${runKindPluralForSchedule(kind)} are no longer retained in Kubernetes.` }
1581
1581
  }
1582
1582
  return { headline: 'No runs yet', body: `Kubernetes does not currently have retained ${runKindPluralForSchedule(kind)} for this resource.` }
1583
1583
  }