@skyhook-io/radar-app 1.13.1 → 1.13.3
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 +19 -1
- package/src/RadarApp.tsx +2 -2
- package/src/api/diagnose.test.ts +268 -0
- package/src/api/diagnose.ts +74 -9
- package/src/components/diagnose/AISettings.tsx +1 -1
- package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
- package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
- package/src/components/diagnose/DiagnoseContext.tsx +12 -17
- package/src/components/diagnose/DiagnoseSurface.test.tsx +211 -16
- package/src/components/diagnose/DiagnoseSurface.tsx +464 -133
- package/src/components/diagnose/Home.test.tsx +293 -0
- package/src/components/diagnose/Home.tsx +289 -119
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
- package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
- package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
- package/src/components/diagnose/InvestigationView.test.ts +17 -0
- package/src/components/diagnose/InvestigationView.tsx +1913 -395
- package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
- package/src/components/diagnose/agentCatalog.ts +1 -1
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
- package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
- package/src/components/diagnose/investigationEvidence.ts +3492 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
- package/src/components/diagnose/investigationExplanation.test.ts +92 -0
- package/src/components/diagnose/investigationExplanation.ts +29 -0
- package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
- package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
- package/src/components/diagnose/investigationSourceFocus.ts +98 -0
- package/src/components/diagnose/investigationState.test.ts +695 -0
- package/src/components/diagnose/investigationState.ts +451 -0
- package/src/components/diagnose/parts.test.tsx +864 -3
- package/src/components/diagnose/parts.tsx +1337 -541
- package/src/components/diagnose/target.test.ts +39 -0
- package/src/components/diagnose/target.ts +36 -0
- package/src/components/diagnose/useDisclosureReveal.ts +117 -0
- package/src/components/home/MCPSetupDialog.tsx +2 -2
- package/src/components/home/mcpToolCatalog.test.ts +22 -0
- package/src/components/home/mcpToolCatalog.ts +3 -2
- package/src/components/issues/IssuesPane.tsx +5 -1
- package/src/components/settings/SettingsDialog.tsx +11 -13
- package/src/components/workload/WorkloadView.tsx +1 -1
- package/src/context/DiagnoseCustomization.tsx +11 -8
- package/src/index.css +63 -79
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.3",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"yaml": "^2.9.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@skyhook-io/k8s-ui": ">=1.
|
|
44
|
+
"@skyhook-io/k8s-ui": ">=1.14.2",
|
|
45
45
|
"@tanstack/react-query": ">=5",
|
|
46
46
|
"@xyflow/react": ">=12.0.0",
|
|
47
47
|
"clsx": ">=2",
|
package/src/App.tsx
CHANGED
|
@@ -2464,7 +2464,25 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2464
2464
|
below the header and right of the nav rail, sharing the frame with the
|
|
2465
2465
|
drawers above. Docked = right slot (pushes content via contentGutter);
|
|
2466
2466
|
maximized = fills the frame. */}
|
|
2467
|
-
{diagnoseOpen &&
|
|
2467
|
+
{diagnoseOpen && (
|
|
2468
|
+
<DiagnoseSurface
|
|
2469
|
+
topInset={chromeless ? 0 : APP_HEADER_HEIGHT}
|
|
2470
|
+
onOpenResource={(ref) => {
|
|
2471
|
+
const resource: SelectedResource = {
|
|
2472
|
+
kind: ref.kind,
|
|
2473
|
+
namespace: ref.namespace ?? '',
|
|
2474
|
+
name: ref.name,
|
|
2475
|
+
group: ref.group,
|
|
2476
|
+
}
|
|
2477
|
+
const path = relatedResourcePath(resource)
|
|
2478
|
+
if (path.startsWith('/workload/')) {
|
|
2479
|
+
navigate(path)
|
|
2480
|
+
return
|
|
2481
|
+
}
|
|
2482
|
+
navigateFromIssue(resource)
|
|
2483
|
+
}}
|
|
2484
|
+
/>
|
|
2485
|
+
)}
|
|
2468
2486
|
|
|
2469
2487
|
{/* Port Forward floating panel (indicator lives in header) */}
|
|
2470
2488
|
<PortForwardPanel />
|
package/src/RadarApp.tsx
CHANGED
|
@@ -106,10 +106,10 @@ export interface RadarAppProps {
|
|
|
106
106
|
*/
|
|
107
107
|
documentTitleSuffix?: string;
|
|
108
108
|
/**
|
|
109
|
-
* Injects a resource-level "
|
|
109
|
+
* Injects a resource-level "Investigate" action (e.g. an "Investigate with AI"
|
|
110
110
|
* button) into every resource detail action bar's right-aligned universal
|
|
111
111
|
* actions. The host returns the node to render given the resource context.
|
|
112
|
-
* Standalone Radar omits this and renders no
|
|
112
|
+
* Standalone Radar omits this and renders no Investigate button — OSS stays
|
|
113
113
|
* agent-free. See ./context/DiagnoseCustomization for the render-prop shape.
|
|
114
114
|
*/
|
|
115
115
|
renderDiagnoseAction?: RenderDiagnoseAction;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createRun, subscribeRun, type DiagnoseStreamEvent } from "./diagnose";
|
|
3
|
+
|
|
4
|
+
type SSEListener = (event: { data: string; lastEventId: string }) => void;
|
|
5
|
+
|
|
6
|
+
class FakeEventSource {
|
|
7
|
+
static CLOSED = 2;
|
|
8
|
+
static instances: FakeEventSource[] = [];
|
|
9
|
+
|
|
10
|
+
readyState = 1;
|
|
11
|
+
onopen: (() => void) | null = null;
|
|
12
|
+
onerror: (() => void) | null = null;
|
|
13
|
+
closed = false;
|
|
14
|
+
private listeners = new Map<string, SSEListener>();
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
readonly url: string,
|
|
18
|
+
readonly options?: EventSourceInit,
|
|
19
|
+
) {
|
|
20
|
+
FakeEventSource.instances.push(this);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
addEventListener(type: string, listener: SSEListener) {
|
|
24
|
+
this.listeners.set(type, listener);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
emit(type: string, event: DiagnoseStreamEvent, lastEventId = "") {
|
|
28
|
+
this.listeners.get(type)?.({ data: JSON.stringify(event), lastEventId });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
close() {
|
|
32
|
+
this.closed = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("createRun", () => {
|
|
37
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
38
|
+
|
|
39
|
+
it.each(["argoproj.io", ""])(
|
|
40
|
+
"sends the target API group %j in the request body",
|
|
41
|
+
async (group) => {
|
|
42
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
43
|
+
ok: true,
|
|
44
|
+
json: async () => ({
|
|
45
|
+
id: "run-1",
|
|
46
|
+
kind: "Rollout",
|
|
47
|
+
group,
|
|
48
|
+
namespace: "prod",
|
|
49
|
+
name: "checkout",
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
53
|
+
|
|
54
|
+
await createRun(
|
|
55
|
+
{
|
|
56
|
+
kind: "Rollout",
|
|
57
|
+
group,
|
|
58
|
+
namespace: "prod",
|
|
59
|
+
name: "checkout",
|
|
60
|
+
},
|
|
61
|
+
{ agent: "codex" },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
|
65
|
+
expect(JSON.parse(String(init.body))).toEqual({
|
|
66
|
+
kind: "Rollout",
|
|
67
|
+
group,
|
|
68
|
+
namespace: "prod",
|
|
69
|
+
name: "checkout",
|
|
70
|
+
agent: "codex",
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("passes durable SSE sequence IDs and explanation origin through replay", () => {
|
|
77
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
78
|
+
const onEvent = vi.fn();
|
|
79
|
+
const close = subscribeRun("sequence-test", { onEvent });
|
|
80
|
+
const source = FakeEventSource.instances.at(-1)!;
|
|
81
|
+
source.emit("done", { type: "done" }, "42");
|
|
82
|
+
source.emit("turn", { type: "turn", explainAssessment: 42 }, "43");
|
|
83
|
+
expect(onEvent).toHaveBeenNthCalledWith(1, { type: "done" }, 42);
|
|
84
|
+
expect(onEvent).toHaveBeenNthCalledWith(
|
|
85
|
+
2,
|
|
86
|
+
{ type: "turn", explainAssessment: 42 },
|
|
87
|
+
43,
|
|
88
|
+
);
|
|
89
|
+
close();
|
|
90
|
+
vi.unstubAllGlobals();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("subscribeRun replay boundaries", () => {
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
FakeEventSource.instances = [];
|
|
96
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
100
|
+
|
|
101
|
+
it("brackets every connection replay and forwards the completion marker", () => {
|
|
102
|
+
const starts = vi.fn();
|
|
103
|
+
const events: DiagnoseStreamEvent[] = [];
|
|
104
|
+
const cancel = subscribeRun("run-1", {
|
|
105
|
+
onReplayStart: starts,
|
|
106
|
+
onEvent: (event) => events.push(event),
|
|
107
|
+
});
|
|
108
|
+
const source = FakeEventSource.instances[0]!;
|
|
109
|
+
|
|
110
|
+
source.onopen?.();
|
|
111
|
+
source.emit("turn", { type: "turn" });
|
|
112
|
+
source.emit("replay_complete", { type: "replay_complete" });
|
|
113
|
+
source.onopen?.(); // EventSource reconnects through the same instance.
|
|
114
|
+
|
|
115
|
+
expect(starts).toHaveBeenCalledTimes(2);
|
|
116
|
+
expect(events.map((event) => event.type)).toEqual([
|
|
117
|
+
"turn",
|
|
118
|
+
"replay_complete",
|
|
119
|
+
]);
|
|
120
|
+
cancel();
|
|
121
|
+
expect(source.closed).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("distinguishes a durable closed sentinel from an unavailable stream", () => {
|
|
125
|
+
const durableClose = vi.fn();
|
|
126
|
+
subscribeRun("run-1", {
|
|
127
|
+
onEvent: vi.fn(),
|
|
128
|
+
onClosed: durableClose,
|
|
129
|
+
});
|
|
130
|
+
const closedSource = FakeEventSource.instances[0]!;
|
|
131
|
+
closedSource.emit("closed", { type: "closed" });
|
|
132
|
+
closedSource.readyState = FakeEventSource.CLOSED;
|
|
133
|
+
closedSource.onerror?.();
|
|
134
|
+
expect(durableClose).toHaveBeenCalledWith("run_closed");
|
|
135
|
+
expect(durableClose).toHaveBeenCalledTimes(1);
|
|
136
|
+
|
|
137
|
+
const unavailable = vi.fn();
|
|
138
|
+
subscribeRun("run-2", {
|
|
139
|
+
onEvent: vi.fn(),
|
|
140
|
+
onClosed: unavailable,
|
|
141
|
+
});
|
|
142
|
+
const source = FakeEventSource.instances[1]!;
|
|
143
|
+
source.readyState = FakeEventSource.CLOSED;
|
|
144
|
+
source.onerror?.();
|
|
145
|
+
expect(unavailable).toHaveBeenCalledWith("unavailable");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("forwards hydration failures while preserving only retryable reconnects", () => {
|
|
149
|
+
const retryEvents: DiagnoseStreamEvent[] = [];
|
|
150
|
+
subscribeRun("run-retry", {
|
|
151
|
+
onEvent: (event) => retryEvents.push(event),
|
|
152
|
+
});
|
|
153
|
+
const retrySource = FakeEventSource.instances[0]!;
|
|
154
|
+
retrySource.emit("history_unavailable", {
|
|
155
|
+
type: "history_unavailable",
|
|
156
|
+
error: "history store is busy",
|
|
157
|
+
retryable: true,
|
|
158
|
+
});
|
|
159
|
+
expect(retryEvents).toEqual([
|
|
160
|
+
{
|
|
161
|
+
type: "history_unavailable",
|
|
162
|
+
error: "history store is busy",
|
|
163
|
+
retryable: true,
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
expect(retrySource.closed).toBe(false);
|
|
167
|
+
|
|
168
|
+
const permanentEvents: DiagnoseStreamEvent[] = [];
|
|
169
|
+
subscribeRun("run-permanent", {
|
|
170
|
+
onEvent: (event) => permanentEvents.push(event),
|
|
171
|
+
});
|
|
172
|
+
const permanentSource = FakeEventSource.instances[1]!;
|
|
173
|
+
permanentSource.emit("history_unavailable", {
|
|
174
|
+
type: "history_unavailable",
|
|
175
|
+
error: "history cannot be decoded",
|
|
176
|
+
retryable: false,
|
|
177
|
+
});
|
|
178
|
+
expect(permanentEvents.map((event) => event.type)).toEqual([
|
|
179
|
+
"history_unavailable",
|
|
180
|
+
]);
|
|
181
|
+
expect(permanentSource.closed).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("ignores a trailing closed frame after a permanent history failure", () => {
|
|
185
|
+
const events: DiagnoseStreamEvent[] = [];
|
|
186
|
+
const onClosed = vi.fn();
|
|
187
|
+
subscribeRun("run-corrupt-history", {
|
|
188
|
+
onEvent: (event) => events.push(event),
|
|
189
|
+
onClosed,
|
|
190
|
+
});
|
|
191
|
+
const source = FakeEventSource.instances[0]!;
|
|
192
|
+
|
|
193
|
+
source.emit("history_unavailable", {
|
|
194
|
+
type: "history_unavailable",
|
|
195
|
+
error: "history cannot be decoded",
|
|
196
|
+
retryable: false,
|
|
197
|
+
});
|
|
198
|
+
// This frame can already be queued when history_unavailable closes the
|
|
199
|
+
// EventSource. It must not relabel the run as evicted in InvestigationView.
|
|
200
|
+
source.emit("closed", { type: "closed" });
|
|
201
|
+
|
|
202
|
+
expect(events).toEqual([
|
|
203
|
+
{
|
|
204
|
+
type: "history_unavailable",
|
|
205
|
+
error: "history cannot be decoded",
|
|
206
|
+
retryable: false,
|
|
207
|
+
},
|
|
208
|
+
]);
|
|
209
|
+
expect(onClosed).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("forwards evidence-backed apply outcomes on terminal events", () => {
|
|
213
|
+
const events: DiagnoseStreamEvent[] = [];
|
|
214
|
+
subscribeRun("run-apply", {
|
|
215
|
+
onEvent: (event) => events.push(event),
|
|
216
|
+
});
|
|
217
|
+
const source = FakeEventSource.instances[0]!;
|
|
218
|
+
source.emit("done", {
|
|
219
|
+
type: "done",
|
|
220
|
+
applyOutcome: "confirmed",
|
|
221
|
+
diagnosis: {
|
|
222
|
+
rootCause: "",
|
|
223
|
+
report: "Deployment updated.",
|
|
224
|
+
remediation: [],
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
source.emit("error", {
|
|
228
|
+
type: "error",
|
|
229
|
+
applyOutcome: "unknown",
|
|
230
|
+
error: "The write result was incomplete.",
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(events).toEqual([
|
|
234
|
+
expect.objectContaining({ type: "done", applyOutcome: "confirmed" }),
|
|
235
|
+
expect.objectContaining({ type: "error", applyOutcome: "unknown" }),
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("investigation start requests", () => {
|
|
241
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
242
|
+
|
|
243
|
+
const target = {
|
|
244
|
+
kind: "Deployment",
|
|
245
|
+
group: "apps",
|
|
246
|
+
namespace: "prod",
|
|
247
|
+
name: "payments",
|
|
248
|
+
issueId: "issue-1",
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
it.each([
|
|
252
|
+
["investigate further", target],
|
|
253
|
+
["start fresh", { ...target, fresh: true }],
|
|
254
|
+
])("preserves the %s intent on the wire", async (_name, request) => {
|
|
255
|
+
const fetch = vi
|
|
256
|
+
.fn()
|
|
257
|
+
.mockResolvedValue(new Response(JSON.stringify({ id: "new-run" })));
|
|
258
|
+
vi.stubGlobal("fetch", fetch);
|
|
259
|
+
await createRun(request, { agent: "hub" });
|
|
260
|
+
const init = fetch.mock.calls[0][1] as RequestInit;
|
|
261
|
+
expect(init.method).toBe("POST");
|
|
262
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
263
|
+
...request,
|
|
264
|
+
agent: "hub",
|
|
265
|
+
});
|
|
266
|
+
expect(JSON.parse(init.body as string).issueId).toBe("issue-1");
|
|
267
|
+
});
|
|
268
|
+
});
|
package/src/api/diagnose.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Client for
|
|
1
|
+
// Client for local AI investigations (OSS BYO-agent). The agent CLI runs
|
|
2
2
|
// on the user's own machine/subscription against Radar's MCP; this just starts
|
|
3
3
|
// the investigation and consumes its SSE event stream.
|
|
4
4
|
import { getApiBase, getAuthHeaders, getCredentialsMode } from "./config";
|
|
@@ -13,12 +13,14 @@ export interface AgentInfo {
|
|
|
13
13
|
profiles?: ExecutionProfile[];
|
|
14
14
|
consentSurfaces?: Partial<Record<ExecutionProfile, string>>;
|
|
15
15
|
hosted?: boolean;
|
|
16
|
+
/** Hosted backends opt in only when they implement assessment-bound, tool-free explanations. */
|
|
17
|
+
assessmentExplanations?: boolean;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface AgentsResponse {
|
|
19
21
|
agents: AgentInfo[];
|
|
20
22
|
enabled: boolean;
|
|
21
|
-
// eligible: this run mode supports local BYO-agent
|
|
23
|
+
// eligible: this run mode supports local BYO-agent investigations (no proxy/OIDC
|
|
22
24
|
// auth, /mcp mounted) — true even when no agent is installed. Lets the UI tell
|
|
23
25
|
// "install an agent to enable this" (eligible && !enabled) apart from "not
|
|
24
26
|
// available here" (auth/cloud/--no-mcp). Absent on older servers / embed hosts.
|
|
@@ -37,13 +39,24 @@ export interface DiagnoseStep {
|
|
|
37
39
|
ms?: number;
|
|
38
40
|
summary?: string; // input args (on running)
|
|
39
41
|
result?: string; // result text (on done), capped
|
|
42
|
+
evidenceRef?: string; // server-issued reference used to bind a root cause to this exact result
|
|
43
|
+
// Server validated Radar's uncapped producer result and this retained result
|
|
44
|
+
// as its exact capped derivative.
|
|
45
|
+
radarEvidence?: boolean;
|
|
46
|
+
isError?: boolean; // authoritative agent-host result; absent means unknown
|
|
40
47
|
truncated?: boolean; // result was capped — payload shown/copied is partial
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
export interface RootCauseEvidence {
|
|
51
|
+
status: "linked" | "missing" | "invalid";
|
|
52
|
+
refs?: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
export interface Diagnosis {
|
|
44
56
|
healthy?: boolean;
|
|
45
57
|
inconclusive?: boolean; // investigated but couldn't determine — distinct from healthy
|
|
46
58
|
rootCause: string;
|
|
59
|
+
rootCauseEvidence?: RootCauseEvidence;
|
|
47
60
|
report: string;
|
|
48
61
|
remediation: string[];
|
|
49
62
|
recommendedIndex?: number; // 1-based index into remediation of the step Apply performs
|
|
@@ -72,8 +85,19 @@ export interface ResourceHealthSignal {
|
|
|
72
85
|
auditFindings?: HealthLine[];
|
|
73
86
|
}
|
|
74
87
|
|
|
88
|
+
export type ApplyMutationOutcome = "confirmed" | "failed" | "unknown";
|
|
89
|
+
|
|
75
90
|
export interface DiagnoseStreamEvent {
|
|
76
|
-
type:
|
|
91
|
+
type:
|
|
92
|
+
| "turn"
|
|
93
|
+
| "phase"
|
|
94
|
+
| "step"
|
|
95
|
+
| "thinking"
|
|
96
|
+
| "done"
|
|
97
|
+
| "error"
|
|
98
|
+
| "closed"
|
|
99
|
+
| "history_unavailable"
|
|
100
|
+
| "replay_complete";
|
|
77
101
|
phase?: string;
|
|
78
102
|
step?: DiagnoseStep;
|
|
79
103
|
token?: string;
|
|
@@ -81,6 +105,16 @@ export interface DiagnoseStreamEvent {
|
|
|
81
105
|
error?: string;
|
|
82
106
|
question?: string; // on "turn"
|
|
83
107
|
apply?: boolean; // on "turn"
|
|
108
|
+
verify?: boolean; // on "turn": explicit post-change re-check
|
|
109
|
+
explainAssessment?: number; // on "turn": originating assessment done-event sequence
|
|
110
|
+
// Evidence-backed mutation truth on an apply turn's terminal event. Only
|
|
111
|
+
// "confirmed" means a Radar write tool authoritatively reported success.
|
|
112
|
+
applyOutcome?: ApplyMutationOutcome;
|
|
113
|
+
// On an apply terminal event, the server durably queued the adjacent
|
|
114
|
+
// read-only verification turn. This prevents an idle control flash between
|
|
115
|
+
// the two SSE events without inferring lifecycle from copy.
|
|
116
|
+
verificationScheduled?: boolean;
|
|
117
|
+
retryable?: boolean; // on "history_unavailable": keep native EventSource reconnect alive
|
|
84
118
|
actor?: string; // human author on shared hosted transcripts
|
|
85
119
|
}
|
|
86
120
|
|
|
@@ -90,6 +124,8 @@ export interface DiagnoseStreamEvent {
|
|
|
90
124
|
export interface RunSummary {
|
|
91
125
|
id: string;
|
|
92
126
|
kind: string;
|
|
127
|
+
/** Kubernetes API group; empty means the core API group. */
|
|
128
|
+
group: string;
|
|
93
129
|
namespace: string;
|
|
94
130
|
name: string;
|
|
95
131
|
/** The issue this session is for, on hosts that key sessions by issue. Always
|
|
@@ -153,6 +189,8 @@ const RUNS = () => `${getApiBase()}/diagnose/runs`;
|
|
|
153
189
|
export async function createRun(
|
|
154
190
|
target: {
|
|
155
191
|
kind: string;
|
|
192
|
+
/** Kubernetes API group; empty means the core API group. */
|
|
193
|
+
group: string;
|
|
156
194
|
namespace: string;
|
|
157
195
|
name: string;
|
|
158
196
|
// Associates the session with the issue it was started from, for hosts that
|
|
@@ -256,7 +294,13 @@ export async function clearHistory(): Promise<void> {
|
|
|
256
294
|
// addTurn appends a follow-up (question) or an apply turn (apply + confirmed fix).
|
|
257
295
|
export async function addTurn(
|
|
258
296
|
id: string,
|
|
259
|
-
body: {
|
|
297
|
+
body: {
|
|
298
|
+
question?: string;
|
|
299
|
+
apply?: boolean;
|
|
300
|
+
fix?: string;
|
|
301
|
+
verify?: boolean;
|
|
302
|
+
explainAssessment?: number;
|
|
303
|
+
},
|
|
260
304
|
): Promise<void> {
|
|
261
305
|
const res = await fetch(`${RUNS()}/${id}/turns`, {
|
|
262
306
|
method: "POST",
|
|
@@ -277,8 +321,11 @@ export async function stopRun(id: string): Promise<void> {
|
|
|
277
321
|
}
|
|
278
322
|
|
|
279
323
|
export interface SubscribeHandlers {
|
|
280
|
-
onEvent: (ev: DiagnoseStreamEvent) => void;
|
|
281
|
-
|
|
324
|
+
onEvent: (ev: DiagnoseStreamEvent, sequence?: number) => void;
|
|
325
|
+
// EventSource fires open before each initial/reconnect replay. Together with
|
|
326
|
+
// replay_complete this brackets history so consumers can rebuild silently.
|
|
327
|
+
onReplayStart?: () => void;
|
|
328
|
+
onClosed?: (reason: "run_closed" | "unavailable") => void;
|
|
282
329
|
}
|
|
283
330
|
|
|
284
331
|
/**
|
|
@@ -302,6 +349,11 @@ export function subscribeRun(
|
|
|
302
349
|
es.close();
|
|
303
350
|
};
|
|
304
351
|
const dispatch = (e: MessageEvent) => {
|
|
352
|
+
// close() can run while an earlier SSE callback is being dispatched. Ignore
|
|
353
|
+
// any already-queued trailing frame (notably the server's `closed` sentinel
|
|
354
|
+
// after a permanent history_unavailable) so it cannot replace the specific
|
|
355
|
+
// failure explanation with a generic eviction state.
|
|
356
|
+
if (closed) return;
|
|
305
357
|
let ev: DiagnoseStreamEvent;
|
|
306
358
|
try {
|
|
307
359
|
ev = JSON.parse(e.data);
|
|
@@ -310,11 +362,21 @@ export function subscribeRun(
|
|
|
310
362
|
}
|
|
311
363
|
if (ev.type === "closed") {
|
|
312
364
|
close();
|
|
313
|
-
handlers.onClosed?.();
|
|
365
|
+
handlers.onClosed?.("run_closed");
|
|
314
366
|
return;
|
|
315
367
|
}
|
|
316
|
-
|
|
368
|
+
const sequence = Number(e.lastEventId);
|
|
369
|
+
handlers.onEvent(
|
|
370
|
+
ev,
|
|
371
|
+
Number.isSafeInteger(sequence) && sequence > 0 ? sequence : undefined,
|
|
372
|
+
);
|
|
373
|
+
// Hydration failures stay inside the SSE protocol. Retryable failures end
|
|
374
|
+
// this response and let native EventSource reconnect without advancing its
|
|
375
|
+
// cursor; a permanent failure must stop that reconnect loop after the UI
|
|
376
|
+
// receives the explanatory event.
|
|
377
|
+
if (ev.type === "history_unavailable" && ev.retryable !== true) close();
|
|
317
378
|
};
|
|
379
|
+
es.onopen = () => handlers.onReplayStart?.();
|
|
318
380
|
for (const t of [
|
|
319
381
|
"turn",
|
|
320
382
|
"phase",
|
|
@@ -323,10 +385,13 @@ export function subscribeRun(
|
|
|
323
385
|
"done",
|
|
324
386
|
"error",
|
|
325
387
|
"closed",
|
|
388
|
+
"history_unavailable",
|
|
389
|
+
"replay_complete",
|
|
326
390
|
] as const) {
|
|
327
391
|
es.addEventListener(t, dispatch);
|
|
328
392
|
}
|
|
329
393
|
es.onerror = () => {
|
|
394
|
+
if (closed) return;
|
|
330
395
|
// A permanent failure (readyState CLOSED) means the run is gone — a 404 because
|
|
331
396
|
// it was evicted (retention cap) or lost on a server restart. Surface it as
|
|
332
397
|
// closed so the view shows a "no longer available" state instead of a silent
|
|
@@ -334,7 +399,7 @@ export function subscribeRun(
|
|
|
334
399
|
// replays only what we missed), so leave those to EventSource.
|
|
335
400
|
if (es.readyState === EventSource.CLOSED) {
|
|
336
401
|
close();
|
|
337
|
-
handlers.onClosed?.();
|
|
402
|
+
handlers.onClosed?.("unavailable");
|
|
338
403
|
}
|
|
339
404
|
};
|
|
340
405
|
return close;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The agent/model/effort controls + a clear-history action for the Settings
|
|
2
|
-
// "AI
|
|
2
|
+
// "AI investigations" tab. Controlled by the dialog: it edits a STAGED draft committed
|
|
3
3
|
// on Save (like the rest of Settings), not on every keystroke. The heading,
|
|
4
4
|
// description, and Save button live in the dialog so this tab matches the other
|
|
5
5
|
// Settings tabs' layout — this renders only the controls (no card, no heading).
|
|
@@ -52,7 +52,7 @@ function AgentRow({ agent }: { agent: AgentInstall }) {
|
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
// Shown in the AI surface's Home when
|
|
55
|
+
// Shown in the AI surface's Home when investigations are eligible in this deployment
|
|
56
56
|
// but not runnable yet — either no agent CLI is installed ("needs-install") or a
|
|
57
57
|
// supported one appeared after Radar booted ("needs-restart"). Radar decides the
|
|
58
58
|
// engine once at startup, so a fresh install needs a restart to take effect.
|
|
@@ -69,8 +69,8 @@ export function AgentSetupNotice({
|
|
|
69
69
|
</div>
|
|
70
70
|
<h3 className="text-base font-semibold text-theme-text-primary">
|
|
71
71
|
{needsRestart
|
|
72
|
-
? "Restart Radar to enable AI
|
|
73
|
-
: "Set up AI
|
|
72
|
+
? "Restart Radar to enable AI investigations"
|
|
73
|
+
: "Set up AI investigations"}
|
|
74
74
|
</h3>
|
|
75
75
|
<p className="mt-1 text-sm text-theme-text-secondary">
|
|
76
76
|
{needsRestart ? (
|
|
@@ -109,8 +109,8 @@ export function AgentSetupNotice({
|
|
|
109
109
|
</button>
|
|
110
110
|
|
|
111
111
|
<p className="mt-4 text-xs text-theme-text-tertiary">
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
Your agent runs locally. Resource details and logs are sent to its model
|
|
113
|
+
provider under your account, not to Radar.
|
|
114
114
|
</p>
|
|
115
115
|
</div>
|
|
116
116
|
);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { ApplyDialog } from "./parts";
|
|
5
|
+
import { AgentSetupNotice } from "./AgentSetupNotice";
|
|
6
|
+
|
|
7
|
+
vi.mock("@skyhook-io/k8s-ui/components/ui/DialogPortal", () => ({
|
|
8
|
+
DialogPortal: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
|
9
|
+
open ? children : null,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe("Apply confirmation context", () => {
|
|
13
|
+
it.each([
|
|
14
|
+
"gke_project-a_us-east1-b_nonprod",
|
|
15
|
+
"gke_project-b_us-east1-b_nonprod",
|
|
16
|
+
])(
|
|
17
|
+
"identifies the exact destination %s even when display names collide",
|
|
18
|
+
(context) => {
|
|
19
|
+
const html = renderToStaticMarkup(
|
|
20
|
+
<ApplyDialog
|
|
21
|
+
open
|
|
22
|
+
onClose={() => {}}
|
|
23
|
+
onConfirm={() => {}}
|
|
24
|
+
agentLabel="Local agent"
|
|
25
|
+
resourceLabel="Deployment dev/api"
|
|
26
|
+
context={context}
|
|
27
|
+
fix="Only after verifying credentials, update the Secret reference."
|
|
28
|
+
/>,
|
|
29
|
+
);
|
|
30
|
+
expect(html).toContain("Cluster:");
|
|
31
|
+
expect(html).toContain("nonprod");
|
|
32
|
+
expect(html).toContain(context);
|
|
33
|
+
expect(html).toContain("Deployment dev/api");
|
|
34
|
+
expect(html).toContain("Proposed change");
|
|
35
|
+
expect(html).not.toContain("What will happen");
|
|
36
|
+
expect(html).not.toContain("max-h-48");
|
|
37
|
+
expect(html).toContain("Only after verifying credentials");
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
it("keeps managed-resource acknowledgement and low-confidence warning", () => {
|
|
42
|
+
const html = renderToStaticMarkup(
|
|
43
|
+
<ApplyDialog
|
|
44
|
+
open
|
|
45
|
+
onClose={() => {}}
|
|
46
|
+
onConfirm={() => {}}
|
|
47
|
+
agentLabel="Local agent"
|
|
48
|
+
resourceLabel="Deployment dev/api"
|
|
49
|
+
context="kind-dev"
|
|
50
|
+
fix="Update its configuration"
|
|
51
|
+
managedBy="Argo CD"
|
|
52
|
+
confidence={0.3}
|
|
53
|
+
/>,
|
|
54
|
+
);
|
|
55
|
+
expect(html).toContain("I understand Argo CD may revert this");
|
|
56
|
+
const applyButton = html
|
|
57
|
+
.match(/<button\b[^>]*>[\s\S]*?<\/button>/g)
|
|
58
|
+
?.find((button) => button.includes("Apply fix"));
|
|
59
|
+
expect(applyButton).toContain('disabled=""');
|
|
60
|
+
expect(html).toContain("low confidence");
|
|
61
|
+
expect(html.match(/kubeconfig/g)).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("distinguishes local execution from sending resource data to the model provider", () => {
|
|
66
|
+
const html = renderToStaticMarkup(
|
|
67
|
+
<AgentSetupNotice setupState="needs-install" />,
|
|
68
|
+
);
|
|
69
|
+
expect(html).toContain("Your agent runs locally");
|
|
70
|
+
expect(html).toContain("model provider under your account, not to Radar");
|
|
71
|
+
expect(html).not.toContain("never leaves your machine");
|
|
72
|
+
});
|