@skyhook-io/radar-app 1.16.3 → 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.
- package/package.json +2 -2
- package/src/App.tsx +14 -7
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +115 -8
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +19 -1
- package/src/api/radarFeatures.ts +3 -0
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
- package/src/components/capacity/CapacityView.test.tsx +31 -1
- package/src/components/capacity/shared.tsx +12 -21
- package/src/components/cnpg/CNPGProtection.tsx +2 -2
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/helm/ChartBrowser.tsx +3 -3
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/SettingsDialog.tsx +122 -19
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.tsx +3 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +7 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +135 -290
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
|
@@ -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
|
|
64
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
:
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
10
|
-
//
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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
|
-
|
|
131
|
-
?
|
|
132
|
-
:
|
|
133
|
-
?
|
|
134
|
-
: d.
|
|
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).
|
|
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
|
-
?
|
|
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
|
-
|
|
187
|
-
?
|
|
188
|
-
:
|
|
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://
|
|
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://
|
|
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 (
|
|
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
|
-
<
|
|
12
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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 ?
|
|
572
|
-
['Last success', status.lastSuccessfulTime ?
|
|
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 ?
|
|
822
|
-
['Finished', run.finishedAt ?
|
|
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',
|
|
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">{
|
|
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 ?
|
|
1542
|
+
return raw ? formatTimeAgo(raw) : ''
|
|
1543
1543
|
}
|
|
1544
1544
|
|
|
1545
|
-
function
|
|
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 ${
|
|
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
|
}
|
|
@@ -5,10 +5,10 @@ import { clsx } from 'clsx'
|
|
|
5
5
|
import { useHelmRepositories, useSearchCharts, useUpdateRepository, useUpdateRepositorySilent, useArtifactHubSearch, type ArtifactHubSortOption } from '../../api/client'
|
|
6
6
|
import { useCanHelmWrite, useIsAuthEnabled } from '../../contexts/CapabilitiesContext'
|
|
7
7
|
import type { ChartInfo, HelmRepository, ArtifactHubChart, ChartSource } from '../../types'
|
|
8
|
-
import { formatAge } from './helm-utils'
|
|
9
8
|
import { SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
10
9
|
import { Tooltip } from '../ui/Tooltip'
|
|
11
10
|
import { useToast } from '../ui/Toast'
|
|
11
|
+
import { formatRelativeAgeTime } from '@skyhook-io/k8s-ui/utils/format'
|
|
12
12
|
|
|
13
13
|
interface ChartBrowserProps {
|
|
14
14
|
onChartSelect: (repo: string, chart: string, version: string, source: ChartSource) => void
|
|
@@ -431,7 +431,7 @@ function RepoDropdownItem({ repo, isSelected, onSelect, onUpdate, isUpdating, ca
|
|
|
431
431
|
{repo.name}
|
|
432
432
|
{repo.lastUpdated && (
|
|
433
433
|
<span className="text-xs text-theme-text-tertiary ml-2">
|
|
434
|
-
{
|
|
434
|
+
{formatRelativeAgeTime(repo.lastUpdated)}
|
|
435
435
|
</span>
|
|
436
436
|
)}
|
|
437
437
|
</button>
|
|
@@ -514,7 +514,7 @@ interface ArtifactHubChartCardProps {
|
|
|
514
514
|
function ArtifactHubChartCard({ chart, onSelect }: ArtifactHubChartCardProps) {
|
|
515
515
|
// Format Unix timestamp to relative age
|
|
516
516
|
const lastUpdated = chart.updatedAt
|
|
517
|
-
?
|
|
517
|
+
? formatRelativeAgeTime(new Date(chart.updatedAt * 1000).toISOString())
|
|
518
518
|
: null
|
|
519
519
|
|
|
520
520
|
return (
|