@skyhook-io/radar-app 0.2.2 → 0.3.1
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/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// A view over one durable, server-side investigation run. It SUBSCRIBES to the
|
|
2
|
+
// run's event stream (replay + live) and reconstructs the transcript; it does not
|
|
3
|
+
// own the run's lifetime — the server does. So closing the panel or navigating
|
|
4
|
+
// away just unsubscribes; the run keeps going and re-subscribing replays it.
|
|
5
|
+
import {
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
13
|
+
import { Send, AlertTriangle, ArrowDown } from "lucide-react";
|
|
14
|
+
import {
|
|
15
|
+
subscribeRun,
|
|
16
|
+
addTurn,
|
|
17
|
+
stopRun,
|
|
18
|
+
DiagnoseError,
|
|
19
|
+
type Diagnosis,
|
|
20
|
+
type DiagnoseStreamEvent,
|
|
21
|
+
type RunSummary,
|
|
22
|
+
} from "../../api/diagnose";
|
|
23
|
+
import { useDiagnose } from "./DiagnoseContext";
|
|
24
|
+
import {
|
|
25
|
+
TurnView,
|
|
26
|
+
ResultCard,
|
|
27
|
+
ApplyDialog,
|
|
28
|
+
RunContextCard,
|
|
29
|
+
appendThinking,
|
|
30
|
+
upsertTool,
|
|
31
|
+
type Turn,
|
|
32
|
+
} from "./parts";
|
|
33
|
+
|
|
34
|
+
const RECHECK_QUESTION =
|
|
35
|
+
"Did the fix resolve the issue? Re-check the resource's current status and health now, and say whether it's healthy.";
|
|
36
|
+
|
|
37
|
+
export function InvestigationView({
|
|
38
|
+
run,
|
|
39
|
+
agentLabel,
|
|
40
|
+
maximized,
|
|
41
|
+
}: {
|
|
42
|
+
run: RunSummary;
|
|
43
|
+
agentLabel: string;
|
|
44
|
+
maximized: boolean;
|
|
45
|
+
}) {
|
|
46
|
+
const { kind, namespace, name } = run;
|
|
47
|
+
// Apply is off for hosted agents (read-only server-side). Keyed on the selected
|
|
48
|
+
// agent, which matches run.agent unless a deployment mixes hosted + local agents.
|
|
49
|
+
const { refreshRuns, openInvestigation, startError, hosted } = useDiagnose();
|
|
50
|
+
const retryDiagnosis = useCallback(
|
|
51
|
+
() => openInvestigation({ kind, namespace, name }),
|
|
52
|
+
[openInvestigation, kind, namespace, name],
|
|
53
|
+
);
|
|
54
|
+
const queryClient = useQueryClient();
|
|
55
|
+
const [turns, setTurns] = useState<Turn[]>([]);
|
|
56
|
+
// The run is gone server-side (evicted past the retention cap, or lost on a
|
|
57
|
+
// restart) — the stream 404s / closes with nothing to replay. Without this we'd
|
|
58
|
+
// show a silent blank panel; instead we render a "no longer available" state.
|
|
59
|
+
const [gone, setGone] = useState(false);
|
|
60
|
+
const [busy, setBusy] = useState(false);
|
|
61
|
+
const [input, setInput] = useState("");
|
|
62
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
63
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
64
|
+
const pendingApplyRef = useRef(false);
|
|
65
|
+
// Set when THIS view initiates an apply, consumed once on that apply's done event
|
|
66
|
+
// to auto-run the health re-check (the verification). Ref, not derived from the
|
|
67
|
+
// stream, so replaying a past apply on reopen never re-fires the re-check.
|
|
68
|
+
const autoRecheckRef = useRef(false);
|
|
69
|
+
// Stick-to-bottom: follow streaming output while the user is at/near the bottom,
|
|
70
|
+
// detach the moment they scroll up to read history, re-attach when they return.
|
|
71
|
+
// Tracked from scroll events (the user's intent) — NOT post-render geometry, which
|
|
72
|
+
// mis-detaches whenever a streamed chunk is taller than the threshold.
|
|
73
|
+
const pinnedRef = useRef(true);
|
|
74
|
+
const [showJump, setShowJump] = useState(false);
|
|
75
|
+
const STICK_THRESHOLD = 64; // px from bottom counted as "at the bottom"
|
|
76
|
+
|
|
77
|
+
// Staged synthesis beats: when a real diagnosis is ready, hold it for a beat and
|
|
78
|
+
// narrate the closing reasoning ("Formulating the root cause…" → "Weighing
|
|
79
|
+
// remediation options…") before revealing the verdict. These ARE the phases the
|
|
80
|
+
// model just ran; pacing their presentation makes the payoff feel earned instead
|
|
81
|
+
// of dumped. Apply outcomes and plain follow-ups skip it (no root cause to build).
|
|
82
|
+
const [synth, setSynth] = useState<string | null>(null);
|
|
83
|
+
// Controls how much of a freshly-revealed diagnosis the card shows, so the verdict
|
|
84
|
+
// unfolds in beats: "rca" = root cause only (+ a "weighing remediation" beat),
|
|
85
|
+
// "full" = everything. null/"full" for replayed turns (no choreography on rebuild).
|
|
86
|
+
const [reveal, setReveal] = useState<"rca" | "full" | null>(null);
|
|
87
|
+
const synthTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
88
|
+
const clearSynth = () => {
|
|
89
|
+
synthTimers.current.forEach(clearTimeout);
|
|
90
|
+
synthTimers.current = [];
|
|
91
|
+
setSynth(null);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// After a successful apply, refresh the cluster-state views so the fix shows in
|
|
95
|
+
// the surrounding UI (Issues, the resource, topology, …), not just the transcript.
|
|
96
|
+
const refreshClusterState = useCallback(() => {
|
|
97
|
+
for (const key of [
|
|
98
|
+
["issues"],
|
|
99
|
+
["dashboard"],
|
|
100
|
+
["topology"],
|
|
101
|
+
["applications"],
|
|
102
|
+
["audit"],
|
|
103
|
+
["gitops-insights"],
|
|
104
|
+
["gitops-tree"],
|
|
105
|
+
["resource", kind, namespace, name],
|
|
106
|
+
]) {
|
|
107
|
+
queryClient.invalidateQueries({ queryKey: key });
|
|
108
|
+
}
|
|
109
|
+
}, [queryClient, kind, namespace, name]);
|
|
110
|
+
|
|
111
|
+
const updateLast = (fn: (t: Turn) => Turn) =>
|
|
112
|
+
setTurns((prev) => prev.map((t, i) => (i === prev.length - 1 ? fn(t) : t)));
|
|
113
|
+
|
|
114
|
+
// Progressive reasoning reveal: the agent hands us each thinking block whole, but
|
|
115
|
+
// dumping a paragraph at once reads as a jarring pop. Instead we buffer it and
|
|
116
|
+
// drip it into the transcript line-by-line so it streams the way Claude Code /
|
|
117
|
+
// Codex feel live. A tool call, the final report, or an error flushes the buffer
|
|
118
|
+
// instantly (reasoning must fully precede its own tool, and the result can't wait
|
|
119
|
+
// on an animation) — which also makes tab-reopen replay fast-forward for free,
|
|
120
|
+
// since every turn ends in one of those events.
|
|
121
|
+
const revealBufRef = useRef("");
|
|
122
|
+
const revealTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
123
|
+
const stopReveal = () => {
|
|
124
|
+
if (revealTimerRef.current) {
|
|
125
|
+
clearInterval(revealTimerRef.current);
|
|
126
|
+
revealTimerRef.current = null;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const flushReveal = () => {
|
|
130
|
+
stopReveal();
|
|
131
|
+
const rest = revealBufRef.current;
|
|
132
|
+
revealBufRef.current = "";
|
|
133
|
+
if (rest)
|
|
134
|
+
updateLast((t) => ({ ...t, timeline: appendThinking(t.timeline, rest) }));
|
|
135
|
+
};
|
|
136
|
+
// Next reveal unit: a whole line, but cap a long unwrapped line at a sentence
|
|
137
|
+
// boundary so prose paragraphs (no hard breaks) still reveal in pieces.
|
|
138
|
+
const nextRevealUnit = (buf: string): [string, string] => {
|
|
139
|
+
const nl = buf.indexOf("\n");
|
|
140
|
+
let cut = nl === -1 ? buf.length : nl + 1;
|
|
141
|
+
if (cut > 160) {
|
|
142
|
+
const seg = buf.slice(0, 160);
|
|
143
|
+
const s = Math.max(
|
|
144
|
+
seg.lastIndexOf(". "),
|
|
145
|
+
seg.lastIndexOf("? "),
|
|
146
|
+
seg.lastIndexOf("! "),
|
|
147
|
+
);
|
|
148
|
+
cut = s > 40 ? s + 2 : 160;
|
|
149
|
+
}
|
|
150
|
+
return [buf.slice(0, cut), buf.slice(cut)];
|
|
151
|
+
};
|
|
152
|
+
const pumpReveal = () => {
|
|
153
|
+
if (revealTimerRef.current) return;
|
|
154
|
+
revealTimerRef.current = setInterval(() => {
|
|
155
|
+
if (!revealBufRef.current) {
|
|
156
|
+
stopReveal();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Drain faster when a backlog builds so the reveal can't fall behind a fast
|
|
160
|
+
// model — pace is cosmetic, never a bottleneck on the actual investigation.
|
|
161
|
+
const units = revealBufRef.current.length > 900 ? 3 : 1;
|
|
162
|
+
let take = "";
|
|
163
|
+
for (let k = 0; k < units && revealBufRef.current; k++) {
|
|
164
|
+
const [u, rest] = nextRevealUnit(revealBufRef.current);
|
|
165
|
+
take += u;
|
|
166
|
+
revealBufRef.current = rest;
|
|
167
|
+
}
|
|
168
|
+
if (take)
|
|
169
|
+
updateLast((t) => ({
|
|
170
|
+
...t,
|
|
171
|
+
timeline: appendThinking(t.timeline, take),
|
|
172
|
+
}));
|
|
173
|
+
}, 150);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Subscribe to the run's event stream; rebuild the transcript from scratch on
|
|
177
|
+
// (re)subscribe — the server replays everything, so a fresh tab reconstructs the
|
|
178
|
+
// whole conversation.
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
setTurns([]);
|
|
181
|
+
setGone(false);
|
|
182
|
+
setBusy(false);
|
|
183
|
+
setActionError(null);
|
|
184
|
+
pendingApplyRef.current = false;
|
|
185
|
+
revealBufRef.current = "";
|
|
186
|
+
stopReveal();
|
|
187
|
+
clearSynth();
|
|
188
|
+
setReveal(null);
|
|
189
|
+
// Was the run ALREADY finished when we opened it? Then this subscribe is a
|
|
190
|
+
// replay of history — show every verdict immediately, no staged-reveal beats.
|
|
191
|
+
// The choreography is only for a verdict we watch land live (status running at
|
|
192
|
+
// open). Captured here, not read live, so a follow-up later doesn't re-trigger
|
|
193
|
+
// it for the replayed turns.
|
|
194
|
+
const replaying = run.status !== "running";
|
|
195
|
+
const cancel = subscribeRun(run.id, {
|
|
196
|
+
onEvent: (ev: DiagnoseStreamEvent) => {
|
|
197
|
+
switch (ev.type) {
|
|
198
|
+
case "turn":
|
|
199
|
+
flushReveal(); // close out the prior turn's reasoning before the new one
|
|
200
|
+
clearSynth();
|
|
201
|
+
setReveal(null);
|
|
202
|
+
if (ev.apply) pendingApplyRef.current = true;
|
|
203
|
+
setBusy(true);
|
|
204
|
+
setTurns((prev) => [
|
|
205
|
+
...prev,
|
|
206
|
+
{
|
|
207
|
+
question: ev.question,
|
|
208
|
+
timeline: [],
|
|
209
|
+
diagnosis: null,
|
|
210
|
+
error: null,
|
|
211
|
+
status: "running",
|
|
212
|
+
apply: ev.apply,
|
|
213
|
+
},
|
|
214
|
+
]);
|
|
215
|
+
break;
|
|
216
|
+
case "thinking":
|
|
217
|
+
if (ev.token) {
|
|
218
|
+
revealBufRef.current += ev.token;
|
|
219
|
+
pumpReveal();
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
case "step":
|
|
223
|
+
flushReveal(); // reasoning fully precedes the tool it led to
|
|
224
|
+
if (ev.step)
|
|
225
|
+
updateLast((t) => ({
|
|
226
|
+
...t,
|
|
227
|
+
timeline: upsertTool(t.timeline, ev.step!),
|
|
228
|
+
}));
|
|
229
|
+
break;
|
|
230
|
+
case "done": {
|
|
231
|
+
flushReveal(); // the result can't wait on a reveal animation
|
|
232
|
+
const dx = (ev.diagnosis ?? null) as Diagnosis | null;
|
|
233
|
+
const isApply = pendingApplyRef.current;
|
|
234
|
+
const finalize = () => {
|
|
235
|
+
setBusy(false);
|
|
236
|
+
if (isApply) {
|
|
237
|
+
pendingApplyRef.current = false;
|
|
238
|
+
refreshClusterState();
|
|
239
|
+
// Verify the write automatically: re-check health as a follow-up
|
|
240
|
+
// turn. Guarded by autoRecheckRef so replaying a past apply on
|
|
241
|
+
// reopen never re-fires it.
|
|
242
|
+
if (autoRecheckRef.current && run.status !== "stale") {
|
|
243
|
+
autoRecheckRef.current = false;
|
|
244
|
+
setTimeout(() => {
|
|
245
|
+
addTurn(run.id, { question: RECHECK_QUESTION }).catch(
|
|
246
|
+
() => {},
|
|
247
|
+
);
|
|
248
|
+
}, 900);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
refreshRuns();
|
|
252
|
+
};
|
|
253
|
+
const showCard = (stage: "rca" | "full") => {
|
|
254
|
+
setReveal(stage);
|
|
255
|
+
updateLast((t) => ({ ...t, diagnosis: dx, status: "done" }));
|
|
256
|
+
};
|
|
257
|
+
// Only a real, structured diagnosis earns the staged reveal — and only
|
|
258
|
+
// when watched live. On replay (the run was already finished when we
|
|
259
|
+
// opened it) the beats would just stall showing a verdict that's
|
|
260
|
+
// already known, so we skip straight to the full card.
|
|
261
|
+
const hasRC = !!dx?.rootCause;
|
|
262
|
+
const hasRem = (dx?.remediation?.length ?? 0) > 0;
|
|
263
|
+
const allClear = !!dx?.healthy && !hasRC;
|
|
264
|
+
const inconclusive = !!dx?.inconclusive && !hasRC;
|
|
265
|
+
const structured =
|
|
266
|
+
!!dx && (allClear || inconclusive || hasRC || hasRem);
|
|
267
|
+
if (!isApply && structured && !replaying) {
|
|
268
|
+
const STEP = 2000;
|
|
269
|
+
// Beat 1 (in the timeline): formulating, before any card is shown.
|
|
270
|
+
setReveal(null);
|
|
271
|
+
setSynth(
|
|
272
|
+
allClear
|
|
273
|
+
? "Confirming health"
|
|
274
|
+
: inconclusive
|
|
275
|
+
? "Weighing the evidence"
|
|
276
|
+
: hasRC
|
|
277
|
+
? "Formulating the root cause"
|
|
278
|
+
: "Analyzing the findings",
|
|
279
|
+
);
|
|
280
|
+
synthTimers.current.push(
|
|
281
|
+
setTimeout(() => {
|
|
282
|
+
setSynth(null);
|
|
283
|
+
// Reveal the root cause. If remediation follows, the card shows a
|
|
284
|
+
// "weighing remediation options" beat where the steps will land.
|
|
285
|
+
showCard(hasRC && hasRem ? "rca" : "full");
|
|
286
|
+
if (hasRC && hasRem) {
|
|
287
|
+
synthTimers.current.push(
|
|
288
|
+
setTimeout(() => {
|
|
289
|
+
setReveal("full");
|
|
290
|
+
finalize();
|
|
291
|
+
}, STEP),
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
finalize();
|
|
295
|
+
}
|
|
296
|
+
}, STEP),
|
|
297
|
+
);
|
|
298
|
+
} else {
|
|
299
|
+
setReveal("full");
|
|
300
|
+
updateLast((t) => ({ ...t, diagnosis: dx, status: "done" }));
|
|
301
|
+
finalize();
|
|
302
|
+
}
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case "error":
|
|
306
|
+
flushReveal();
|
|
307
|
+
updateLast((t) => ({
|
|
308
|
+
...t,
|
|
309
|
+
error: ev.error || "The investigation failed.",
|
|
310
|
+
status: "error",
|
|
311
|
+
}));
|
|
312
|
+
setBusy(false);
|
|
313
|
+
pendingApplyRef.current = false;
|
|
314
|
+
refreshRuns();
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
// The run can no longer produce events (evicted / gone). Stale runs emit their
|
|
319
|
+
// own error event + banner before closing, so this only bites the case where a
|
|
320
|
+
// run vanishes while we still think it's running — clear the spinner and mark
|
|
321
|
+
// the open turn terminal so it can't shimmer forever.
|
|
322
|
+
onClosed: () => {
|
|
323
|
+
stopReveal();
|
|
324
|
+
clearSynth();
|
|
325
|
+
setBusy(false);
|
|
326
|
+
setGone(true); // render decides: empty → gone state; mid-run → terminal error
|
|
327
|
+
updateLast((t) =>
|
|
328
|
+
t.status === "running"
|
|
329
|
+
? {
|
|
330
|
+
...t,
|
|
331
|
+
status: "error",
|
|
332
|
+
error:
|
|
333
|
+
"This investigation is no longer available. Re-run Diagnose to analyze the current cluster.",
|
|
334
|
+
}
|
|
335
|
+
: t,
|
|
336
|
+
);
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
return () => {
|
|
340
|
+
stopReveal();
|
|
341
|
+
clearSynth();
|
|
342
|
+
cancel();
|
|
343
|
+
};
|
|
344
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
345
|
+
}, [run.id]);
|
|
346
|
+
|
|
347
|
+
// Follow the bottom IFF still pinned, on anything that changes rendered height:
|
|
348
|
+
// new transcript content (turns), the staged verdict reveal (reveal: rca→full
|
|
349
|
+
// adds the remediation card), and synthesis beats (synth). useLayoutEffect runs
|
|
350
|
+
// before paint, so the jump is invisible and it overrides browser scroll-anchoring
|
|
351
|
+
// (which would otherwise nudge us off the bottom when the remediation card lands).
|
|
352
|
+
useLayoutEffect(() => {
|
|
353
|
+
const el = scrollRef.current;
|
|
354
|
+
if (el && pinnedRef.current) el.scrollTop = el.scrollHeight;
|
|
355
|
+
}, [turns, reveal, synth]);
|
|
356
|
+
|
|
357
|
+
// User scroll updates the pin state: scrolling up past the threshold detaches;
|
|
358
|
+
// scrolling back within it re-attaches. Programmatic scroll-to-bottom lands at
|
|
359
|
+
// distance≈0, so it keeps us pinned — no fight with the auto-follow.
|
|
360
|
+
const onScroll = () => {
|
|
361
|
+
const el = scrollRef.current;
|
|
362
|
+
if (!el) return;
|
|
363
|
+
const atBottom =
|
|
364
|
+
el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
|
|
365
|
+
pinnedRef.current = atBottom;
|
|
366
|
+
setShowJump(!atBottom);
|
|
367
|
+
};
|
|
368
|
+
const jumpToBottom = () => {
|
|
369
|
+
const el = scrollRef.current;
|
|
370
|
+
if (!el) return;
|
|
371
|
+
pinnedRef.current = true;
|
|
372
|
+
setShowJump(false);
|
|
373
|
+
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const stale = run.status === "stale";
|
|
377
|
+
|
|
378
|
+
const submitFollowup = () => {
|
|
379
|
+
const q = input.trim();
|
|
380
|
+
if (!q || busy || stale) return;
|
|
381
|
+
setInput("");
|
|
382
|
+
setActionError(null);
|
|
383
|
+
pinnedRef.current = true; // a user-initiated turn always follows to the bottom
|
|
384
|
+
addTurn(run.id, { question: q }).catch((e) =>
|
|
385
|
+
setActionError(e instanceof DiagnoseError ? e.message : "Couldn't send."),
|
|
386
|
+
);
|
|
387
|
+
};
|
|
388
|
+
const stop = () => stopRun(run.id);
|
|
389
|
+
|
|
390
|
+
// Ask a canned follow-up (e.g. "explain simply") — a one-tap path that turns the
|
|
391
|
+
// prompt's plain-language instruction into something the user controls.
|
|
392
|
+
const askFollowup = (q: string) => {
|
|
393
|
+
if (busy || stale) return;
|
|
394
|
+
setActionError(null);
|
|
395
|
+
pinnedRef.current = true;
|
|
396
|
+
addTurn(run.id, { question: q }).catch((e) =>
|
|
397
|
+
setActionError(e instanceof DiagnoseError ? e.message : "Couldn't send."),
|
|
398
|
+
);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// Apply: a user-confirmed remediation turn. Any step is applyable; the chosen
|
|
402
|
+
// step's text is sent so the server binds the apply to it.
|
|
403
|
+
const [confirmApply, setConfirmApply] = useState(false);
|
|
404
|
+
const [pendingFix, setPendingFix] = useState("");
|
|
405
|
+
const requestApply = (fix: string) => {
|
|
406
|
+
setPendingFix(fix);
|
|
407
|
+
setConfirmApply(true);
|
|
408
|
+
};
|
|
409
|
+
const runApply = () => {
|
|
410
|
+
setConfirmApply(false);
|
|
411
|
+
setActionError(null);
|
|
412
|
+
autoRecheckRef.current = true; // verify the write automatically once it lands
|
|
413
|
+
addTurn(run.id, { apply: true, fix: pendingFix }).catch((e) => {
|
|
414
|
+
autoRecheckRef.current = false; // the apply never started — don't auto-recheck
|
|
415
|
+
setActionError(e instanceof DiagnoseError ? e.message : "Couldn't apply.");
|
|
416
|
+
});
|
|
417
|
+
};
|
|
418
|
+
const checkStatus = () => addTurn(run.id, { question: RECHECK_QUESTION }).catch(() => {});
|
|
419
|
+
|
|
420
|
+
// Apply tracks the latest turn that produced remediation (so follow-ups don't
|
|
421
|
+
// strip it) and is blocked on a stale (context-switched) run.
|
|
422
|
+
let lastRemediationIdx = -1;
|
|
423
|
+
turns.forEach((t, i) => {
|
|
424
|
+
if (
|
|
425
|
+
t.status === "done" &&
|
|
426
|
+
!t.apply &&
|
|
427
|
+
(t.diagnosis?.remediation?.length ?? 0) > 0
|
|
428
|
+
)
|
|
429
|
+
lastRemediationIdx = i;
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// The "primary verdict" — the latest initial-style structured diagnosis (root
|
|
433
|
+
// cause / remediation / healthy / inconclusive), excluding apply outcomes and
|
|
434
|
+
// conversational follow-ups. In the maximized workspace this pins to a side rail
|
|
435
|
+
// so it (and Apply) stay in view while the transcript scrolls as evidence.
|
|
436
|
+
let pinnedIdx = -1;
|
|
437
|
+
turns.forEach((t, i) => {
|
|
438
|
+
const dx = t.diagnosis;
|
|
439
|
+
const structured =
|
|
440
|
+
!!dx &&
|
|
441
|
+
(!!dx.rootCause ||
|
|
442
|
+
(dx.remediation?.length ?? 0) > 0 ||
|
|
443
|
+
dx.healthy ||
|
|
444
|
+
dx.inconclusive);
|
|
445
|
+
if (t.status === "done" && !t.apply && !t.question && structured)
|
|
446
|
+
pinnedIdx = i;
|
|
447
|
+
});
|
|
448
|
+
const pinned = maximized && pinnedIdx >= 0;
|
|
449
|
+
|
|
450
|
+
return (
|
|
451
|
+
<div className="relative flex min-h-0 flex-1 flex-col">
|
|
452
|
+
<div className="flex min-h-0 flex-1">
|
|
453
|
+
<div
|
|
454
|
+
ref={scrollRef}
|
|
455
|
+
onScroll={onScroll}
|
|
456
|
+
className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-3 [scrollbar-gutter:stable]"
|
|
457
|
+
>
|
|
458
|
+
<div className={maximized ? "mx-auto max-w-3xl" : ""}>
|
|
459
|
+
<div className="space-y-4">
|
|
460
|
+
{stale && (
|
|
461
|
+
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-theme-text-secondary">
|
|
462
|
+
<div className="flex items-start gap-2">
|
|
463
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
|
464
|
+
<span>
|
|
465
|
+
This investigation ran against{" "}
|
|
466
|
+
<span className="font-medium text-theme-text-primary">
|
|
467
|
+
{run.context || "a different cluster"}
|
|
468
|
+
</span>
|
|
469
|
+
. The cluster context has changed — it's read-only now.
|
|
470
|
+
</span>
|
|
471
|
+
</div>
|
|
472
|
+
<button
|
|
473
|
+
onClick={() => openInvestigation({ kind, namespace, name })}
|
|
474
|
+
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
|
+
>
|
|
476
|
+
<Send className="h-3 w-3" />
|
|
477
|
+
Re-run on current cluster
|
|
478
|
+
</button>
|
|
479
|
+
</div>
|
|
480
|
+
)}
|
|
481
|
+
{gone && turns.length === 0 && (
|
|
482
|
+
<div className="rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
|
|
483
|
+
<div className="flex items-start gap-2">
|
|
484
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
|
485
|
+
<span>
|
|
486
|
+
This investigation is no longer available — history keeps
|
|
487
|
+
the most recent investigations, and this one has been
|
|
488
|
+
cleared. Re-run Diagnose to analyze the current cluster.
|
|
489
|
+
</span>
|
|
490
|
+
</div>
|
|
491
|
+
<button
|
|
492
|
+
onClick={() => openInvestigation({ kind, namespace, name })}
|
|
493
|
+
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
|
+
>
|
|
495
|
+
<Send className="h-3 w-3" />
|
|
496
|
+
Re-run Diagnose
|
|
497
|
+
</button>
|
|
498
|
+
</div>
|
|
499
|
+
)}
|
|
500
|
+
<RunContextCard run={run} />
|
|
501
|
+
{turns.map((t, i) => {
|
|
502
|
+
const isLast = i === turns.length - 1;
|
|
503
|
+
// Hosted runners are read-only — the server refuses apply turns.
|
|
504
|
+
const canApply = i === lastRemediationIdx && !stale && !hosted;
|
|
505
|
+
const canCheck = isLast && t.status === "done" && !!t.apply;
|
|
506
|
+
return (
|
|
507
|
+
<TurnView
|
|
508
|
+
key={i}
|
|
509
|
+
turn={t}
|
|
510
|
+
synthLabel={isLast ? synth : null}
|
|
511
|
+
reveal={isLast ? (reveal ?? "full") : "full"}
|
|
512
|
+
onApply={canApply ? requestApply : undefined}
|
|
513
|
+
onAsk={isLast && !busy && !stale ? askFollowup : undefined}
|
|
514
|
+
onCheckStatus={canCheck ? checkStatus : undefined}
|
|
515
|
+
onRetryDiagnosis={
|
|
516
|
+
isLast &&
|
|
517
|
+
t.status === "error" &&
|
|
518
|
+
!t.question &&
|
|
519
|
+
!t.apply &&
|
|
520
|
+
!stale
|
|
521
|
+
? retryDiagnosis
|
|
522
|
+
: undefined
|
|
523
|
+
}
|
|
524
|
+
hideVerdict={pinned && i === pinnedIdx}
|
|
525
|
+
/>
|
|
526
|
+
);
|
|
527
|
+
})}
|
|
528
|
+
{(actionError || startError) && (
|
|
529
|
+
<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
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
|
531
|
+
<span>{actionError || startError}</span>
|
|
532
|
+
</div>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
{pinned && (
|
|
538
|
+
<aside
|
|
539
|
+
className={`w-[400px] shrink-0 overflow-y-auto border-l border-theme-border px-4 py-3 ${busy ? "opacity-70" : ""}`}
|
|
540
|
+
>
|
|
541
|
+
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
542
|
+
{busy ? "Verdict · revising…" : "Verdict"}
|
|
543
|
+
</div>
|
|
544
|
+
<ResultCard
|
|
545
|
+
diagnosis={turns[pinnedIdx].diagnosis!}
|
|
546
|
+
onApply={
|
|
547
|
+
pinnedIdx === lastRemediationIdx && !stale && !hosted
|
|
548
|
+
? requestApply
|
|
549
|
+
: undefined
|
|
550
|
+
}
|
|
551
|
+
onAsk={!busy && !stale ? askFollowup : undefined}
|
|
552
|
+
reveal="full"
|
|
553
|
+
/>
|
|
554
|
+
</aside>
|
|
555
|
+
)}
|
|
556
|
+
</div>
|
|
557
|
+
|
|
558
|
+
{showJump && (
|
|
559
|
+
<button
|
|
560
|
+
onClick={jumpToBottom}
|
|
561
|
+
className="absolute bottom-20 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-theme-border bg-theme-elevated px-3 py-1.5 text-xs font-medium text-theme-text-secondary shadow-theme-md transition hover:bg-theme-hover hover:text-theme-text-primary"
|
|
562
|
+
>
|
|
563
|
+
<ArrowDown className="h-3.5 w-3.5" />
|
|
564
|
+
{busy ? "Jump to latest" : "Scroll to bottom"}
|
|
565
|
+
</button>
|
|
566
|
+
)}
|
|
567
|
+
|
|
568
|
+
<ApplyDialog
|
|
569
|
+
open={confirmApply}
|
|
570
|
+
onClose={() => setConfirmApply(false)}
|
|
571
|
+
onConfirm={runApply}
|
|
572
|
+
agentLabel={agentLabel}
|
|
573
|
+
resourceLabel={`${kind} ${namespace ? `${namespace}/` : ""}${name}`}
|
|
574
|
+
fix={pendingFix}
|
|
575
|
+
managedBy={run.managedBy}
|
|
576
|
+
confidence={turns[lastRemediationIdx]?.diagnosis?.confidence}
|
|
577
|
+
/>
|
|
578
|
+
|
|
579
|
+
<div
|
|
580
|
+
className={`border-t border-theme-border px-3 py-2.5 ${maximized ? "[&>*]:mx-auto [&>*]:max-w-3xl" : ""}`}
|
|
581
|
+
>
|
|
582
|
+
{busy ? (
|
|
583
|
+
<button
|
|
584
|
+
onClick={stop}
|
|
585
|
+
className="w-full rounded-lg border border-theme-border py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
|
|
586
|
+
>
|
|
587
|
+
Stop
|
|
588
|
+
</button>
|
|
589
|
+
) : (
|
|
590
|
+
<div className="flex items-end gap-2">
|
|
591
|
+
<textarea
|
|
592
|
+
value={input}
|
|
593
|
+
onChange={(e) => setInput(e.target.value)}
|
|
594
|
+
onKeyDown={(e) => {
|
|
595
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
596
|
+
e.preventDefault();
|
|
597
|
+
submitFollowup();
|
|
598
|
+
}
|
|
599
|
+
}}
|
|
600
|
+
rows={1}
|
|
601
|
+
disabled={stale}
|
|
602
|
+
placeholder={
|
|
603
|
+
stale
|
|
604
|
+
? "Cluster changed — re-run Diagnose"
|
|
605
|
+
: "Ask a follow-up or refine…"
|
|
606
|
+
}
|
|
607
|
+
className="max-h-32 min-h-[38px] flex-1 resize-none rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-sm text-theme-text-primary placeholder:text-theme-text-tertiary focus:border-accent focus:outline-none disabled:opacity-50"
|
|
608
|
+
/>
|
|
609
|
+
<button
|
|
610
|
+
onClick={submitFollowup}
|
|
611
|
+
disabled={!input.trim() || stale}
|
|
612
|
+
className="shrink-0 rounded-lg btn-brand p-2 disabled:opacity-40"
|
|
613
|
+
aria-label="Send follow-up"
|
|
614
|
+
>
|
|
615
|
+
<Send className="h-4 w-4" />
|
|
616
|
+
</button>
|
|
617
|
+
</div>
|
|
618
|
+
)}
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
);
|
|
622
|
+
}
|