@skyhook-io/radar-app 1.14.0 → 1.14.2
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 +6 -4
- package/src/api/client.ts +3 -0
- package/src/api/diagnose.ts +31 -1
- package/src/components/CloudConnectFlow.tsx +173 -13
- package/src/components/CloudFunnelButton.tsx +4 -2
- package/src/components/ConnectionErrorView.tsx +9 -10
- package/src/components/DebugOverlay.tsx +10 -6
- package/src/components/cloudConnectHandoff.test.ts +109 -4
- package/src/components/cloudConnectHandoff.ts +154 -2
- package/src/components/curl/ServiceCurlButton.tsx +19 -26
- package/src/components/diagnose/AgentCase.tsx +18 -5
- package/src/components/diagnose/AnalysisStory.test.tsx +308 -0
- package/src/components/diagnose/AnalysisStory.tsx +564 -0
- package/src/components/diagnose/DiagnoseContext.test.ts +10 -0
- package/src/components/diagnose/DiagnoseContext.tsx +25 -8
- package/src/components/diagnose/DiagnoseSurface.tsx +20 -14
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +771 -0
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +8 -33
- package/src/components/diagnose/InvestigationEvidencePane.tsx +809 -168
- package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +30 -0
- package/src/components/diagnose/InvestigationResourceEvidence.tsx +20 -0
- package/src/components/diagnose/InvestigationView.tsx +493 -529
- package/src/components/diagnose/investigationCase.test.tsx +267 -129
- package/src/components/diagnose/investigationCase.ts +122 -77
- package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +27 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +28 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +11 -2
- package/src/components/diagnose/investigationEvidence/identity.test.ts +25 -7
- package/src/components/diagnose/investigationEvidence/identity.ts +4 -4
- package/src/components/diagnose/investigationEvidence/observations.ts +24 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +36 -3
- package/src/components/diagnose/investigationEvidence/types.ts +2 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +2 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +3 -0
- package/src/components/diagnose/investigationState.test.ts +316 -58
- package/src/components/diagnose/investigationState.ts +257 -44
- package/src/components/diagnose/investigationStory.test.ts +161 -0
- package/src/components/diagnose/investigationStory.ts +207 -0
- package/src/components/diagnose/parts.test.tsx +9 -6
- package/src/components/diagnose/parts.tsx +977 -193
- package/src/components/diagnose/storyParts.test.tsx +426 -0
- package/src/components/diagnose/toolCallLabel.test.ts +51 -0
- package/src/components/diagnose/toolCallLabel.ts +135 -0
- package/src/components/diagnose/useDisclosureReveal.ts +10 -11
- package/src/components/helm/HelmCompareRoute.tsx +18 -4
- package/src/components/helm/HelmReleaseDrawer.tsx +11 -26
- package/src/components/helm/InstallWizard.tsx +8 -3
- package/src/components/home/MCPSetupDialog.tsx +15 -9
- package/src/components/portforward/PortForwardManager.tsx +6 -13
- package/src/components/resource/PrometheusChartsGrid.tsx +3 -3
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +3 -3
- package/src/components/resource/WorkloadMetricsSection.tsx +15 -21
- package/src/components/resources/PodFilePreview.tsx +3 -3
- package/src/components/resources/renderers/ServiceRenderer.tsx +2 -1
- package/src/components/settings/SettingsDialog.tsx +4 -2
- package/src/components/ui/CommandPalette.tsx +10 -6
- package/src/components/ui/DiagnosticsOverlay.tsx +10 -6
- package/src/components/ui/Disclosure.tsx +47 -0
- package/src/components/ui/Markdown.tsx +23 -11
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -1
- package/src/components/ui/UpdateNotification.tsx +18 -2
- package/src/index.css +19 -6
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
|
|
4
|
+
import type { Diagnosis } from "../../api/diagnose";
|
|
5
|
+
import { ResultCard, TurnView, assessmentCopyText, type Turn } from "./parts";
|
|
6
|
+
|
|
7
|
+
const storyDiagnosis: Diagnosis = {
|
|
8
|
+
rootCause: "Auth to MongoDB fails after revision 8.",
|
|
9
|
+
summary:
|
|
10
|
+
"The app cannot log in to its database, and it started with the last deploy.",
|
|
11
|
+
certainty: "likely",
|
|
12
|
+
unresolved: ["Whether the Atlas password was rotated on the provider side."],
|
|
13
|
+
report:
|
|
14
|
+
"The container dies on start.\n\n[[radar:evidence=0]]\n\nSo the build changed.",
|
|
15
|
+
remediation: [
|
|
16
|
+
"Roll back to revision 7",
|
|
17
|
+
"Test the stored password with `mongosh`",
|
|
18
|
+
],
|
|
19
|
+
steps: [
|
|
20
|
+
{
|
|
21
|
+
text: "Roll back to revision 7",
|
|
22
|
+
kind: "mitigate",
|
|
23
|
+
precondition: "revision 7 still authenticates",
|
|
24
|
+
},
|
|
25
|
+
{ text: "Test the stored password with `mongosh`", kind: "verify" },
|
|
26
|
+
],
|
|
27
|
+
recommendedIndex: 1,
|
|
28
|
+
recommendedReason: "reversible",
|
|
29
|
+
confidence: 0.8,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe("ResultCard under the story contract", () => {
|
|
33
|
+
it("leads with the summary, the agent's certainty and what is not established", () => {
|
|
34
|
+
const html = renderToStaticMarkup(
|
|
35
|
+
<ResultCard diagnosis={storyDiagnosis} section="conclusion" />,
|
|
36
|
+
);
|
|
37
|
+
expect(html).toContain("data-assessment-headline");
|
|
38
|
+
expect(html).toContain(
|
|
39
|
+
"The app cannot log in to its database, and it started with the last deploy.",
|
|
40
|
+
);
|
|
41
|
+
expect(html).toContain("Likely");
|
|
42
|
+
expect(html).toContain("Auth to MongoDB fails after revision 8.");
|
|
43
|
+
expect(html).toContain("Still open");
|
|
44
|
+
expect(html).toContain("Whether the Atlas password was rotated");
|
|
45
|
+
// The story is the host's to render with placed cards: no second copy.
|
|
46
|
+
expect(html).not.toContain("Full analysis");
|
|
47
|
+
expect(html).not.toContain("[[radar:evidence=0]]");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("frames adverse Radar cards on a healthy verdict by the agent's position, without a coloured box", () => {
|
|
51
|
+
const healthy = {
|
|
52
|
+
...storyDiagnosis,
|
|
53
|
+
healthy: true,
|
|
54
|
+
rootCause: "",
|
|
55
|
+
remediation: [],
|
|
56
|
+
unresolved: [],
|
|
57
|
+
};
|
|
58
|
+
const explained = renderToStaticMarkup(
|
|
59
|
+
<ResultCard
|
|
60
|
+
diagnosis={healthy}
|
|
61
|
+
section="conclusion"
|
|
62
|
+
healthSignals={[
|
|
63
|
+
{
|
|
64
|
+
title: "Readiness probe failing",
|
|
65
|
+
status: "explained",
|
|
66
|
+
claim: "never left endpoints",
|
|
67
|
+
sourceId: "s1",
|
|
68
|
+
},
|
|
69
|
+
]}
|
|
70
|
+
onRevealSource={vi.fn()}
|
|
71
|
+
/>,
|
|
72
|
+
);
|
|
73
|
+
expect(explained).toContain("Still open");
|
|
74
|
+
expect(explained).toContain(
|
|
75
|
+
"reads it as not a live problem: never left endpoints",
|
|
76
|
+
);
|
|
77
|
+
expect(explained).not.toContain("data-health-flag");
|
|
78
|
+
expect(explained).not.toContain("conflicts with captured evidence");
|
|
79
|
+
const flagged = renderToStaticMarkup(
|
|
80
|
+
<ResultCard
|
|
81
|
+
diagnosis={healthy}
|
|
82
|
+
section="conclusion"
|
|
83
|
+
healthSignals={[
|
|
84
|
+
{ title: "Readiness probe failing", status: "unaddressed" },
|
|
85
|
+
]}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
expect(flagged).toContain('data-health-flag="unaddressed"');
|
|
89
|
+
expect(flagged).toContain("no explanation is linked to it");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps the flag banner on a healthy verdict whose story places cards but has no summary", () => {
|
|
93
|
+
const html = renderToStaticMarkup(
|
|
94
|
+
<ResultCard
|
|
95
|
+
diagnosis={{
|
|
96
|
+
...storyDiagnosis,
|
|
97
|
+
healthy: true,
|
|
98
|
+
summary: "",
|
|
99
|
+
rootCause: "",
|
|
100
|
+
remediation: [],
|
|
101
|
+
unresolved: [],
|
|
102
|
+
}}
|
|
103
|
+
section="conclusion"
|
|
104
|
+
healthSignals={[
|
|
105
|
+
{ title: "Readiness probe failing", status: "unaddressed" },
|
|
106
|
+
]}
|
|
107
|
+
/>,
|
|
108
|
+
);
|
|
109
|
+
expect(html).toContain('data-health-flag="unaddressed"');
|
|
110
|
+
expect(html).not.toContain("[[radar:evidence=0]]");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("keeps the inline story on a read-only earlier healthy assessment", () => {
|
|
114
|
+
const html = renderToStaticMarkup(
|
|
115
|
+
<ResultCard
|
|
116
|
+
diagnosis={{
|
|
117
|
+
...storyDiagnosis,
|
|
118
|
+
healthy: true,
|
|
119
|
+
rootCause: "",
|
|
120
|
+
remediation: [],
|
|
121
|
+
unresolved: [],
|
|
122
|
+
report:
|
|
123
|
+
"The pod is fine.\n\n[[radar:evidence=0]]\n\n" +
|
|
124
|
+
"Detail. ".repeat(60),
|
|
125
|
+
}}
|
|
126
|
+
section="conclusion"
|
|
127
|
+
storyInline
|
|
128
|
+
readOnlyAssessment
|
|
129
|
+
/>,
|
|
130
|
+
);
|
|
131
|
+
expect(html).toContain("Full analysis");
|
|
132
|
+
expect(html).not.toContain("[[radar:evidence=0]]");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("lists Radar's own read limits under Still open beside the agent's items", () => {
|
|
136
|
+
const html = renderToStaticMarkup(
|
|
137
|
+
<ResultCard
|
|
138
|
+
diagnosis={{ ...storyDiagnosis, unresolved: [] }}
|
|
139
|
+
section="conclusion"
|
|
140
|
+
assessmentLimits={["Previous logs: could not be read"]}
|
|
141
|
+
/>,
|
|
142
|
+
);
|
|
143
|
+
expect(html).toContain("Still open");
|
|
144
|
+
expect(html).toContain("Previous logs: could not be read");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("says nothing when the agent listed nothing unresolved: the certainty word carries it", () => {
|
|
148
|
+
const html = renderToStaticMarkup(
|
|
149
|
+
<ResultCard
|
|
150
|
+
diagnosis={{
|
|
151
|
+
...storyDiagnosis,
|
|
152
|
+
unresolved: [],
|
|
153
|
+
certainty: "established",
|
|
154
|
+
}}
|
|
155
|
+
section="conclusion"
|
|
156
|
+
/>,
|
|
157
|
+
);
|
|
158
|
+
expect(html).not.toContain("nothing unresolved");
|
|
159
|
+
expect(html).not.toContain("data-assessment-unresolved");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("renders typed steps with their kind and precondition and applies only a mitigate step", () => {
|
|
163
|
+
const onApply = vi.fn();
|
|
164
|
+
const html = renderToStaticMarkup(
|
|
165
|
+
<ResultCard
|
|
166
|
+
diagnosis={storyDiagnosis}
|
|
167
|
+
section="actions"
|
|
168
|
+
onApply={onApply}
|
|
169
|
+
/>,
|
|
170
|
+
);
|
|
171
|
+
expect(html).toContain('data-step-kind="mitigate"');
|
|
172
|
+
expect(html).toContain('data-step-kind="verify"');
|
|
173
|
+
expect(html).toContain("Only if revision 7 still authenticates");
|
|
174
|
+
const doubled = renderToStaticMarkup(
|
|
175
|
+
<ResultCard
|
|
176
|
+
diagnosis={{
|
|
177
|
+
...storyDiagnosis,
|
|
178
|
+
steps: [
|
|
179
|
+
{
|
|
180
|
+
...storyDiagnosis.steps![0],
|
|
181
|
+
precondition: "if the crash is deliberate",
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
remediation: [storyDiagnosis.remediation[0]],
|
|
185
|
+
}}
|
|
186
|
+
section="actions"
|
|
187
|
+
/>,
|
|
188
|
+
);
|
|
189
|
+
expect(doubled).toContain("Only if the crash is deliberate");
|
|
190
|
+
expect(html).toContain("Apply…");
|
|
191
|
+
const full = renderToStaticMarkup(
|
|
192
|
+
<ResultCard
|
|
193
|
+
diagnosis={storyDiagnosis}
|
|
194
|
+
section="full"
|
|
195
|
+
onApply={onApply}
|
|
196
|
+
/>,
|
|
197
|
+
);
|
|
198
|
+
expect(full).toContain("Next steps");
|
|
199
|
+
expect(full).not.toContain(">Remediation<");
|
|
200
|
+
|
|
201
|
+
const verifyRecommended = renderToStaticMarkup(
|
|
202
|
+
<ResultCard
|
|
203
|
+
diagnosis={{ ...storyDiagnosis, recommendedIndex: 2 }}
|
|
204
|
+
section="actions"
|
|
205
|
+
onApply={onApply}
|
|
206
|
+
/>,
|
|
207
|
+
);
|
|
208
|
+
expect(verifyRecommended).not.toContain("Apply…");
|
|
209
|
+
|
|
210
|
+
// Folded alternatives stay readable as one-line rows with their kind.
|
|
211
|
+
const compact = renderToStaticMarkup(
|
|
212
|
+
<ResultCard
|
|
213
|
+
diagnosis={storyDiagnosis}
|
|
214
|
+
section="actions"
|
|
215
|
+
onApply={onApply}
|
|
216
|
+
compactActions
|
|
217
|
+
/>,
|
|
218
|
+
);
|
|
219
|
+
expect(compact).toContain('data-step-folded="verify"');
|
|
220
|
+
expect(compact).toContain("Test the stored password with mongosh");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("keeps the previous shape for a diagnosis without the story fields", () => {
|
|
224
|
+
const html = renderToStaticMarkup(
|
|
225
|
+
<ResultCard
|
|
226
|
+
diagnosis={{
|
|
227
|
+
rootCause: "Image tag is invalid.",
|
|
228
|
+
report: "Long analysis.",
|
|
229
|
+
remediation: ["Fix the tag"],
|
|
230
|
+
}}
|
|
231
|
+
section="full"
|
|
232
|
+
/>,
|
|
233
|
+
);
|
|
234
|
+
expect(html).toContain("Likely cause");
|
|
235
|
+
expect(html).toContain("Full analysis");
|
|
236
|
+
expect(html).toContain("Remediation");
|
|
237
|
+
expect(html).not.toContain("data-assessment-headline");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("shows an inconclusive verdict's blocking question and its verify steps", () => {
|
|
241
|
+
const html = renderToStaticMarkup(
|
|
242
|
+
<ResultCard
|
|
243
|
+
diagnosis={{
|
|
244
|
+
rootCause: "",
|
|
245
|
+
inconclusive: true,
|
|
246
|
+
summary:
|
|
247
|
+
"Radar could not read the pod logs, so the cause is unknown.",
|
|
248
|
+
unresolved: ["Whether the container logs show an error on start."],
|
|
249
|
+
report: "I could not read logs.",
|
|
250
|
+
remediation: ["Grant `get pods/log` and re-run"],
|
|
251
|
+
steps: [
|
|
252
|
+
{ text: "Grant `get pods/log` and re-run", kind: "investigate" },
|
|
253
|
+
],
|
|
254
|
+
}}
|
|
255
|
+
section="full"
|
|
256
|
+
/>,
|
|
257
|
+
);
|
|
258
|
+
expect(html).toContain("What blocked a conclusion");
|
|
259
|
+
expect(html).toContain('data-step-kind="investigate"');
|
|
260
|
+
expect(html).not.toContain("Apply…");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("assessmentCopyText", () => {
|
|
265
|
+
it("carries Radar's own qualifications so the pasted text is no more confident than the screen", () => {
|
|
266
|
+
const text = assessmentCopyText(storyDiagnosis, {
|
|
267
|
+
limits: ["Change history: incomplete"],
|
|
268
|
+
signals: [
|
|
269
|
+
"Radar flagged Warning event · the agent reads it as not a live problem: probe timeout",
|
|
270
|
+
],
|
|
271
|
+
flags: [
|
|
272
|
+
"Radar flagged CrashLoopBackOff · no explanation is linked to it",
|
|
273
|
+
],
|
|
274
|
+
});
|
|
275
|
+
expect(text).toContain(
|
|
276
|
+
"Radar flagged:\n- Radar flagged CrashLoopBackOff · no explanation is linked to it\nRead those cards before treating this as an all-clear.",
|
|
277
|
+
);
|
|
278
|
+
expect(text).toContain("- Change history: incomplete");
|
|
279
|
+
expect(text).toContain(
|
|
280
|
+
"- Radar flagged Warning event · the agent reads it as not a live problem: probe timeout",
|
|
281
|
+
);
|
|
282
|
+
expect(text.indexOf("Radar flagged:")).toBeLessThan(
|
|
283
|
+
text.indexOf("Still open:"),
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
it("names the recommended step and counts the rest for a legacy remediation list", () => {
|
|
287
|
+
const text = assessmentCopyText(
|
|
288
|
+
{
|
|
289
|
+
...storyDiagnosis,
|
|
290
|
+
steps: undefined,
|
|
291
|
+
remediation: ["a", "b", "c"],
|
|
292
|
+
recommendedIndex: 2,
|
|
293
|
+
},
|
|
294
|
+
{ context: { target: "Deployment shop/api" } },
|
|
295
|
+
);
|
|
296
|
+
expect(text).toContain("Next step: b");
|
|
297
|
+
expect(text).toContain("2 more steps in Radar.");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("puts the healthy word on the headline and the flags before Still open", () => {
|
|
301
|
+
const text = assessmentCopyText(
|
|
302
|
+
{
|
|
303
|
+
...storyDiagnosis,
|
|
304
|
+
healthy: true,
|
|
305
|
+
summary: "Fine.",
|
|
306
|
+
rootCause: "",
|
|
307
|
+
unresolved: ["One thing."],
|
|
308
|
+
},
|
|
309
|
+
{ flags: ["Radar flagged BackOff · no explanation is linked to it"] },
|
|
310
|
+
);
|
|
311
|
+
expect(text).toContain("**Fine.**");
|
|
312
|
+
expect(text).toContain("Healthy");
|
|
313
|
+
expect(text.indexOf("Radar flagged:")).toBeLessThan(
|
|
314
|
+
text.indexOf("Still open:"),
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("copies the headline with its caveats, cause, story and steps", () => {
|
|
319
|
+
const text = assessmentCopyText(storyDiagnosis);
|
|
320
|
+
expect(text).toContain("**The app cannot log in to its database");
|
|
321
|
+
expect(text).toContain("— Likely (agent's word)");
|
|
322
|
+
expect(text).toContain("Still open:\n- Whether the Atlas password");
|
|
323
|
+
expect(text).toContain("Auth to MongoDB fails");
|
|
324
|
+
expect(text).toContain("So the build changed.");
|
|
325
|
+
expect(text).not.toContain("[[radar:evidence=0]]");
|
|
326
|
+
expect(text).toContain(
|
|
327
|
+
"1. [Mitigate] Roll back to revision 7 (only if revision 7 still authenticates)",
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("writes a channel-ready note with context, receipts and the one next step", () => {
|
|
332
|
+
const text = assessmentCopyText(storyDiagnosis, {
|
|
333
|
+
context: {
|
|
334
|
+
target: "Deployment shop/api",
|
|
335
|
+
cluster: "prod-east",
|
|
336
|
+
agent: "Claude Code",
|
|
337
|
+
},
|
|
338
|
+
receipts: [
|
|
339
|
+
{
|
|
340
|
+
title: "Current logs · api-7d4 / api",
|
|
341
|
+
role: "Cause",
|
|
342
|
+
lines: ["MongoServerError: Authentication failed.", ""],
|
|
343
|
+
gap: "Only the previous container instance was checked.",
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
});
|
|
347
|
+
expect(
|
|
348
|
+
text.startsWith("**Deployment shop/api** · prod-east · via Claude Code"),
|
|
349
|
+
).toBe(true);
|
|
350
|
+
expect(text).toContain(
|
|
351
|
+
"Evidence (Radar):\n- **Current logs · api-7d4 / api** · Cause\n > MongoServerError: Authentication failed.\n Not shown: Only the previous container instance was checked.",
|
|
352
|
+
);
|
|
353
|
+
expect(text).toContain("Next step: [Mitigate] Roll back to revision 7");
|
|
354
|
+
expect(text).not.toContain("So the build changed.");
|
|
355
|
+
expect(text).toContain("open the investigation in Radar.");
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe("TurnView and assessments", () => {
|
|
360
|
+
const turn = (patch: Partial<Turn>): Turn =>
|
|
361
|
+
({
|
|
362
|
+
status: "done",
|
|
363
|
+
timeline: [],
|
|
364
|
+
diagnosis: storyDiagnosis,
|
|
365
|
+
...patch,
|
|
366
|
+
}) as Turn;
|
|
367
|
+
|
|
368
|
+
it("renders a revised question turn as an assessment, not a conversational answer", () => {
|
|
369
|
+
const html = renderToStaticMarkup(
|
|
370
|
+
<TurnView
|
|
371
|
+
turn={turn({ question: "could it be the build?" })}
|
|
372
|
+
assessment
|
|
373
|
+
turnIndex={1}
|
|
374
|
+
/>,
|
|
375
|
+
);
|
|
376
|
+
expect(html).toContain("data-assessment-headline");
|
|
377
|
+
expect(html).toContain("Earlier assessment");
|
|
378
|
+
expect(html).not.toContain(">Answer<");
|
|
379
|
+
// Read-only: Apply is never offered on a copy kept for the record.
|
|
380
|
+
expect(html).not.toContain("Apply…");
|
|
381
|
+
// The story is inline here as plain prose, markers stripped.
|
|
382
|
+
expect(html).toContain("Full analysis");
|
|
383
|
+
expect(html).not.toContain("[[radar:evidence=0]]");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("folds the agent's evidence ledger under the turn, out of Findings", () => {
|
|
387
|
+
const html = renderToStaticMarkup(
|
|
388
|
+
<TurnView
|
|
389
|
+
turn={turn({
|
|
390
|
+
diagnosis: {
|
|
391
|
+
rootCause: "x",
|
|
392
|
+
report: "The story.",
|
|
393
|
+
remediation: [],
|
|
394
|
+
notes: "Result A shows one exit; it does not cover the other two.",
|
|
395
|
+
},
|
|
396
|
+
})}
|
|
397
|
+
assessment
|
|
398
|
+
hideConclusion
|
|
399
|
+
turnIndex={0}
|
|
400
|
+
/>,
|
|
401
|
+
);
|
|
402
|
+
expect(html).toContain("data-turn-working-notes");
|
|
403
|
+
expect(html).toContain("Evidence considered");
|
|
404
|
+
expect(html).toContain("does not cover the other two");
|
|
405
|
+
expect(html).toContain("Assessment · shown in Findings");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("keeps a pointer for the current assessment and the full answer for a plain question", () => {
|
|
409
|
+
const pointer = renderToStaticMarkup(
|
|
410
|
+
<TurnView turn={turn({})} assessment hideConclusion turnIndex={0} />,
|
|
411
|
+
);
|
|
412
|
+
expect(pointer).toContain("Assessment · shown in Findings");
|
|
413
|
+
expect(pointer).not.toContain("data-assessment-headline");
|
|
414
|
+
const answer = renderToStaticMarkup(
|
|
415
|
+
<TurnView
|
|
416
|
+
turn={turn({
|
|
417
|
+
question: "what is a PDB?",
|
|
418
|
+
diagnosis: { rootCause: "", report: "A budget.", remediation: [] },
|
|
419
|
+
})}
|
|
420
|
+
turnIndex={2}
|
|
421
|
+
/>,
|
|
422
|
+
);
|
|
423
|
+
expect(answer).toContain(">Answer<");
|
|
424
|
+
expect(answer).toContain("A budget.");
|
|
425
|
+
});
|
|
426
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { describeToolCall } from "./toolCallLabel";
|
|
3
|
+
|
|
4
|
+
describe("describeToolCall", () => {
|
|
5
|
+
it("never invents a singular from a kind", () => {
|
|
6
|
+
expect(
|
|
7
|
+
describeToolCall(
|
|
8
|
+
"get_resource",
|
|
9
|
+
JSON.stringify({ kind: "Ingress", namespace: "web", name: "edge" }),
|
|
10
|
+
),
|
|
11
|
+
).toBe("Reading Ingress web/edge");
|
|
12
|
+
expect(
|
|
13
|
+
describeToolCall("list_resources", JSON.stringify({ kind: "ingresses" })),
|
|
14
|
+
).toBe("Listing Ingresses");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("names the target from the call's arguments", () => {
|
|
18
|
+
expect(
|
|
19
|
+
describeToolCall(
|
|
20
|
+
"get_resource",
|
|
21
|
+
JSON.stringify({ kind: "deployment", namespace: "shop", name: "api" }),
|
|
22
|
+
),
|
|
23
|
+
).toBe("Reading Deployment shop/api");
|
|
24
|
+
expect(
|
|
25
|
+
describeToolCall(
|
|
26
|
+
"get_pod_logs",
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
namespace: "shop",
|
|
29
|
+
name: "api-7d4",
|
|
30
|
+
container: "api",
|
|
31
|
+
previous: true,
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
).toBe("Reading logs for pod shop/api-7d4 / api (previous instance)");
|
|
35
|
+
expect(
|
|
36
|
+
describeToolCall(
|
|
37
|
+
"list_resources",
|
|
38
|
+
JSON.stringify({ kind: "configmaps", namespace: "shop" }),
|
|
39
|
+
),
|
|
40
|
+
).toBe("Listing Configmaps in shop");
|
|
41
|
+
expect(
|
|
42
|
+
describeToolCall("search", JSON.stringify({ query: "metrics-server" })),
|
|
43
|
+
).toBe("Searching for “metrics-server”");
|
|
44
|
+
});
|
|
45
|
+
it("falls back to the tool name when arguments are missing or malformed", () => {
|
|
46
|
+
expect(describeToolCall("get_changes", "not json")).toBe(
|
|
47
|
+
"Reading recent changes",
|
|
48
|
+
);
|
|
49
|
+
expect(describeToolCall("some_new_tool", undefined)).toBe("Some New Tool");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { prettyTool } from "./parts";
|
|
2
|
+
|
|
3
|
+
// One readable sentence for a tool call in flight, from the call's arguments:
|
|
4
|
+
// "Reading logs for pod shop/api-7d4 / api" tells the reader what the agent
|
|
5
|
+
// is looking at; "Get Pod Logs" tells them which tool ran.
|
|
6
|
+
export function describeToolCall(tool: string, args?: string): string {
|
|
7
|
+
const a = parseArgs(args);
|
|
8
|
+
const kind = kindLabel(a.kind);
|
|
9
|
+
const target = a.name
|
|
10
|
+
? `${a.namespace ? `${a.namespace}/` : ""}${a.name}`
|
|
11
|
+
: undefined;
|
|
12
|
+
const scoped = (what: string) =>
|
|
13
|
+
`${what}${a.namespace ? ` in ${a.namespace}` : ""}`;
|
|
14
|
+
switch (tool) {
|
|
15
|
+
case "diagnose":
|
|
16
|
+
return target
|
|
17
|
+
? `Diagnosing ${kind ?? "resource"} ${target}`
|
|
18
|
+
: "Diagnosing";
|
|
19
|
+
case "get_resource":
|
|
20
|
+
return target
|
|
21
|
+
? `Reading ${kind ?? "resource"} ${target}`
|
|
22
|
+
: "Reading a resource";
|
|
23
|
+
case "list_resources":
|
|
24
|
+
return scoped(`Listing ${a.kind ? pluralLabel(a.kind) : "resources"}`);
|
|
25
|
+
case "get_events":
|
|
26
|
+
return target ? `Reading events for ${target}` : scoped("Reading events");
|
|
27
|
+
case "get_pod_logs":
|
|
28
|
+
return `Reading logs for pod ${target ?? ""}${a.container ? ` / ${a.container}` : ""}${a.previous ? " (previous instance)" : ""}`.trim();
|
|
29
|
+
case "get_workload_logs":
|
|
30
|
+
return `Reading logs for ${kind ?? "workload"} ${target ?? ""}`.trim();
|
|
31
|
+
case "get_changes":
|
|
32
|
+
return target
|
|
33
|
+
? `Reading recent changes for ${target}`
|
|
34
|
+
: scoped("Reading recent changes");
|
|
35
|
+
case "issues":
|
|
36
|
+
return scoped(`Listing ${a.kind ? `${kindLabel(a.kind)} ` : ""}issues`);
|
|
37
|
+
case "search":
|
|
38
|
+
return a.query ? `Searching for “${a.query}”` : "Searching";
|
|
39
|
+
case "query_prometheus":
|
|
40
|
+
return "Querying Prometheus";
|
|
41
|
+
case "discover_metrics":
|
|
42
|
+
return "Discovering metrics";
|
|
43
|
+
case "get_prometheus_rules":
|
|
44
|
+
return "Reading alert rules";
|
|
45
|
+
case "get_helm_release":
|
|
46
|
+
return `Reading Helm release ${target ?? ""}`.trim();
|
|
47
|
+
case "list_helm_releases":
|
|
48
|
+
return scoped("Listing Helm releases");
|
|
49
|
+
case "get_neighborhood":
|
|
50
|
+
return target
|
|
51
|
+
? `Mapping what ${kind ?? "resource"} ${target} connects to`
|
|
52
|
+
: "Mapping connections";
|
|
53
|
+
case "get_topology":
|
|
54
|
+
return scoped("Mapping the topology");
|
|
55
|
+
case "get_subject_permissions":
|
|
56
|
+
return target
|
|
57
|
+
? `Checking what ${kind ?? "subject"} ${target} may do`
|
|
58
|
+
: "Checking permissions";
|
|
59
|
+
case "list_namespaces":
|
|
60
|
+
return "Listing namespaces";
|
|
61
|
+
case "list_packages":
|
|
62
|
+
return a.chart
|
|
63
|
+
? `Looking up packages matching “${a.chart}”`
|
|
64
|
+
: "Looking up packages";
|
|
65
|
+
case "top_resources":
|
|
66
|
+
return "Ranking resources";
|
|
67
|
+
case "get_dashboard":
|
|
68
|
+
return "Reading the cluster dashboard";
|
|
69
|
+
case "get_cluster_audit":
|
|
70
|
+
return "Running the cluster audit";
|
|
71
|
+
case "get_cluster_upgrade_readiness":
|
|
72
|
+
return "Checking upgrade readiness";
|
|
73
|
+
case "apply_resource":
|
|
74
|
+
return "Applying the change";
|
|
75
|
+
case "patch_resource":
|
|
76
|
+
return target
|
|
77
|
+
? `Patching ${kind ?? "resource"} ${target}`
|
|
78
|
+
: "Patching a resource";
|
|
79
|
+
case "manage_workload":
|
|
80
|
+
case "manage_rollout":
|
|
81
|
+
case "manage_node":
|
|
82
|
+
case "manage_gitops":
|
|
83
|
+
case "manage_cronjob":
|
|
84
|
+
return target
|
|
85
|
+
? `Changing ${kind ?? "resource"} ${target}`
|
|
86
|
+
: "Changing a resource";
|
|
87
|
+
default:
|
|
88
|
+
return prettyTool(tool);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface ToolArgs {
|
|
93
|
+
kind?: string;
|
|
94
|
+
namespace?: string;
|
|
95
|
+
name?: string;
|
|
96
|
+
container?: string;
|
|
97
|
+
previous?: boolean;
|
|
98
|
+
query?: string;
|
|
99
|
+
chart?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseArgs(args: string | undefined): ToolArgs {
|
|
103
|
+
if (!args) return {};
|
|
104
|
+
try {
|
|
105
|
+
const raw = JSON.parse(args) as Record<string, unknown>;
|
|
106
|
+
const str = (key: string) =>
|
|
107
|
+
typeof raw[key] === "string" && (raw[key] as string).trim()
|
|
108
|
+
? (raw[key] as string).trim()
|
|
109
|
+
: undefined;
|
|
110
|
+
return {
|
|
111
|
+
kind: str("kind"),
|
|
112
|
+
namespace: str("namespace"),
|
|
113
|
+
name: str("name"),
|
|
114
|
+
container: str("container"),
|
|
115
|
+
previous: raw.previous === true,
|
|
116
|
+
query: str("query"),
|
|
117
|
+
chart: str("chart"),
|
|
118
|
+
};
|
|
119
|
+
} catch {
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Arguments carry kinds as "deployment", "Deployment" or "configmaps".
|
|
125
|
+
// Capitalise and otherwise leave the word alone: stripping an "s" turns
|
|
126
|
+
// Ingress into Ingres.
|
|
127
|
+
function kindLabel(kind: string | undefined): string | undefined {
|
|
128
|
+
if (!kind) return undefined;
|
|
129
|
+
return kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pluralLabel(kind: string): string {
|
|
133
|
+
const base = kindLabel(kind) ?? kind;
|
|
134
|
+
return base.endsWith("s") ? base : `${base}s`;
|
|
135
|
+
}
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { useCallback, useLayoutEffect, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
DURATION_DISCLOSURE,
|
|
4
|
+
prefersReducedMotion as sharedPrefersReducedMotion,
|
|
5
|
+
} from "@skyhook-io/k8s-ui/utils/animation";
|
|
2
6
|
|
|
3
|
-
// Shared Collapse
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
typeof window !== "undefined" &&
|
|
10
|
-
typeof window.matchMedia === "function" &&
|
|
11
|
-
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
12
|
-
);
|
|
13
|
-
}
|
|
7
|
+
// Shared Collapse runs DURATION_DISCLOSURE. Keep a small paint margin before
|
|
8
|
+
// moving focus so the destination is stationary; derived from the token so a
|
|
9
|
+
// timing change cannot leave this stale. Reduced-motion users get an immediate
|
|
10
|
+
// disclosure and focus hand-off because Collapse disables motion.
|
|
11
|
+
export const INVESTIGATION_DISCLOSURE_SETTLE_MS = DURATION_DISCLOSURE + 20;
|
|
12
|
+
export const prefersReducedMotion = sharedPrefersReducedMotion;
|
|
14
13
|
|
|
15
14
|
export function investigationDisclosureSettleDelay(
|
|
16
15
|
reducedMotion: boolean,
|