@skyhook-io/radar-app 1.13.3 → 1.13.5
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 +6 -6
- package/src/App.tsx +68 -23
- package/src/RadarApp.tsx +17 -1
- package/src/api/client.ts +28 -10
- package/src/api/diagnose.ts +61 -15
- package/src/components/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- package/src/components/diagnose/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseContext.test.ts +95 -0
- package/src/components/diagnose/DiagnoseContext.tsx +402 -77
- package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
- package/src/components/diagnose/Home.test.tsx +23 -1
- package/src/components/diagnose/Home.tsx +49 -3
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
- package/src/components/diagnose/InvestigationView.tsx +227 -5
- package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
- package/src/components/diagnose/investigationCase.test.tsx +1568 -0
- package/src/components/diagnose/investigationCase.ts +439 -0
- package/src/components/diagnose/investigationEvidence.test.ts +3525 -17
- package/src/components/diagnose/investigationEvidence.ts +2246 -166
- package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
- package/src/components/diagnose/investigationMetrics.test.ts +712 -0
- package/src/components/diagnose/investigationMetrics.ts +393 -0
- package/src/components/diagnose/investigationSourceFocus.ts +6 -0
- package/src/components/diagnose/investigationState.test.ts +322 -0
- package/src/components/diagnose/investigationState.ts +147 -25
- package/src/components/diagnose/parts.test.tsx +537 -1
- package/src/components/diagnose/parts.tsx +486 -42
- package/src/components/resource/HPACharts.render.test.tsx +77 -0
- package/src/components/resource/HPACharts.tsx +32 -25
- package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
- package/src/components/resources/PodFilePreview.test.tsx +131 -0
- package/src/components/resources/PodFilePreview.tsx +394 -0
- package/src/components/resources/PodFilesystemModal.tsx +157 -67
- package/src/components/resources/ResourcesView.tsx +27 -2
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +14 -2
- package/src/index.ts +8 -5
- package/src/utils/shell-safe.test.ts +25 -1
- package/src/utils/shell-safe.ts +16 -0
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
type AgentInfo,
|
|
38
38
|
type ApplyMutationOutcome,
|
|
39
39
|
type ExecutionProfile,
|
|
40
|
+
type DiagnoseStreamEvent,
|
|
41
|
+
type MCPServerStatus,
|
|
40
42
|
} from "../../api/diagnose";
|
|
41
43
|
import { Collapse, CollapseChevron } from "@skyhook-io/k8s-ui";
|
|
42
44
|
import { Markdown } from "../ui/Markdown";
|
|
@@ -52,6 +54,11 @@ import {
|
|
|
52
54
|
type InvestigationRootCauseEvidenceResolution,
|
|
53
55
|
type InvestigationEvidenceSource,
|
|
54
56
|
} from "./investigationEvidence";
|
|
57
|
+
import type {
|
|
58
|
+
InvestigationCaseItem,
|
|
59
|
+
InvestigationCaseResolution,
|
|
60
|
+
} from "./investigationCase";
|
|
61
|
+
import { AgentClaimNote } from "./AgentCase";
|
|
55
62
|
|
|
56
63
|
import { useDisclosureReveal } from "./useDisclosureReveal";
|
|
57
64
|
|
|
@@ -448,8 +455,69 @@ export type Turn = {
|
|
|
448
455
|
// Set from the replay/live boundary when the terminal event arrives. Historical
|
|
449
456
|
// conclusions render immediately; conclusions observed live enter smoothly.
|
|
450
457
|
animateResult?: boolean;
|
|
458
|
+
// The latest startup phase the server reported before the agent's first
|
|
459
|
+
// message. It drives the pending status line only; it is never a transcript item.
|
|
460
|
+
startup?: StartupSignal;
|
|
451
461
|
};
|
|
452
462
|
|
|
463
|
+
export type StartupSignal = {
|
|
464
|
+
phase: "investigating" | "connected" | "ready";
|
|
465
|
+
model?: string;
|
|
466
|
+
toolCount?: number;
|
|
467
|
+
mcpServers?: MCPServerStatus[];
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const STARTUP_PHASE_RANK: Record<StartupSignal["phase"], number> = {
|
|
471
|
+
investigating: 0,
|
|
472
|
+
connected: 1,
|
|
473
|
+
ready: 2,
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// Folds a phase event into the turn's startup signal. The handshake and the
|
|
477
|
+
// CLI's init line are reported by different goroutines, so a later event can
|
|
478
|
+
// name an earlier phase; the furthest phase wins and the init facts are kept.
|
|
479
|
+
export function mergeStartupSignal(
|
|
480
|
+
prev: StartupSignal | undefined,
|
|
481
|
+
event: Pick<
|
|
482
|
+
DiagnoseStreamEvent,
|
|
483
|
+
"phase" | "model" | "toolCount" | "mcpServers"
|
|
484
|
+
>,
|
|
485
|
+
): StartupSignal | undefined {
|
|
486
|
+
const phase = event.phase;
|
|
487
|
+
if (phase !== "investigating" && phase !== "connected" && phase !== "ready")
|
|
488
|
+
return prev;
|
|
489
|
+
const facts =
|
|
490
|
+
phase === "ready"
|
|
491
|
+
? {
|
|
492
|
+
model: event.model,
|
|
493
|
+
toolCount: event.toolCount,
|
|
494
|
+
mcpServers: event.mcpServers,
|
|
495
|
+
}
|
|
496
|
+
: {};
|
|
497
|
+
if (prev && STARTUP_PHASE_RANK[prev.phase] >= STARTUP_PHASE_RANK[phase]) {
|
|
498
|
+
return phase === "ready" ? { ...prev, ...facts } : prev;
|
|
499
|
+
}
|
|
500
|
+
return { ...prev, ...facts, phase };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function startupLabel(startup: StartupSignal, agentLabel: string): string {
|
|
504
|
+
switch (startup.phase) {
|
|
505
|
+
case "investigating":
|
|
506
|
+
return `${agentLabel} starting…`;
|
|
507
|
+
case "connected":
|
|
508
|
+
return "Connected to Radar's tools";
|
|
509
|
+
case "ready": {
|
|
510
|
+
const parts = [`${agentLabel} ready`];
|
|
511
|
+
if (startup.model) parts.push(startup.model);
|
|
512
|
+
if (startup.toolCount !== undefined)
|
|
513
|
+
parts.push(
|
|
514
|
+
`${startup.toolCount} Radar ${startup.toolCount === 1 ? "tool" : "tools"}`,
|
|
515
|
+
);
|
|
516
|
+
return parts.join(" · ");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
453
521
|
// TimelineItem is one ordered transcript entry: agent reasoning, or a tool call.
|
|
454
522
|
export type TimelineItem =
|
|
455
523
|
| { kind: "thinking"; text: string; animate?: boolean }
|
|
@@ -542,6 +610,7 @@ export function upsertTool(
|
|
|
542
610
|
|
|
543
611
|
export function TurnView({
|
|
544
612
|
turn,
|
|
613
|
+
agentLabel,
|
|
545
614
|
onApply,
|
|
546
615
|
onViewExplanation,
|
|
547
616
|
onCheckStatus,
|
|
@@ -551,12 +620,16 @@ export function TurnView({
|
|
|
551
620
|
evidenceStepIds,
|
|
552
621
|
onViewEvidence,
|
|
553
622
|
sourceRevealRequest,
|
|
623
|
+
assessmentSources,
|
|
554
624
|
}: {
|
|
555
625
|
turn: Turn;
|
|
626
|
+
agentLabel?: string;
|
|
556
627
|
onApply?: (fix: string) => void;
|
|
557
628
|
onViewExplanation?: () => void;
|
|
558
629
|
onCheckStatus?: () => void;
|
|
559
630
|
onRetryDiagnosis?: () => void;
|
|
631
|
+
/** Sources and agent items an answer turn cited; answers otherwise show none. */
|
|
632
|
+
assessmentSources?: ReactNode;
|
|
560
633
|
// In the maximized workspace the pinned turn's conclusion renders in the side rail,
|
|
561
634
|
// so the transcript suppresses its own copy (reasoning + tool calls still show).
|
|
562
635
|
hideConclusion?: boolean;
|
|
@@ -637,6 +710,8 @@ export function TurnView({
|
|
|
637
710
|
running={turn.status === "running"}
|
|
638
711
|
applyMode={turn.apply}
|
|
639
712
|
followup={followup}
|
|
713
|
+
startup={turn.startup}
|
|
714
|
+
agentLabel={agentLabel}
|
|
640
715
|
turnIndex={turnIndex}
|
|
641
716
|
evidenceStepIds={evidenceStepIds}
|
|
642
717
|
onViewEvidence={onViewEvidence}
|
|
@@ -658,6 +733,7 @@ export function TurnView({
|
|
|
658
733
|
followup={followup}
|
|
659
734
|
onCheckStatus={onCheckStatus}
|
|
660
735
|
animate={turn.animateResult !== false}
|
|
736
|
+
assessmentSources={assessmentSources}
|
|
661
737
|
/>
|
|
662
738
|
) : (
|
|
663
739
|
<EmptyResult animate={turn.animateResult !== false} />
|
|
@@ -1069,6 +1145,8 @@ export function Timeline({
|
|
|
1069
1145
|
running,
|
|
1070
1146
|
applyMode,
|
|
1071
1147
|
followup,
|
|
1148
|
+
startup,
|
|
1149
|
+
agentLabel = "Agent",
|
|
1072
1150
|
turnIndex,
|
|
1073
1151
|
evidenceStepIds,
|
|
1074
1152
|
onViewEvidence,
|
|
@@ -1078,6 +1156,8 @@ export function Timeline({
|
|
|
1078
1156
|
running: boolean;
|
|
1079
1157
|
applyMode?: boolean;
|
|
1080
1158
|
followup?: boolean;
|
|
1159
|
+
startup?: StartupSignal;
|
|
1160
|
+
agentLabel?: string;
|
|
1081
1161
|
turnIndex?: number;
|
|
1082
1162
|
evidenceStepIds?: ReadonlySet<string>;
|
|
1083
1163
|
onViewEvidence?: (sourceId: string) => void;
|
|
@@ -1093,7 +1173,8 @@ export function Timeline({
|
|
|
1093
1173
|
? "Working"
|
|
1094
1174
|
: "Investigation";
|
|
1095
1175
|
// The live status verb tracks the running tool ("Reading logs…") so the wait is
|
|
1096
|
-
// informative, not a generic spinner;
|
|
1176
|
+
// informative, not a generic spinner; before the first item it reports the
|
|
1177
|
+
// startup phases the server actually observed, never a timer-based guess.
|
|
1097
1178
|
const activeTool = [...items]
|
|
1098
1179
|
.reverse()
|
|
1099
1180
|
.find((it) => it.kind === "tool" && it.status !== "done") as
|
|
@@ -1104,9 +1185,18 @@ export function Timeline({
|
|
|
1104
1185
|
? toolActivity(activeTool.tool)
|
|
1105
1186
|
: items.length > 0
|
|
1106
1187
|
? "Working…"
|
|
1107
|
-
:
|
|
1108
|
-
?
|
|
1109
|
-
:
|
|
1188
|
+
: startup
|
|
1189
|
+
? startupLabel(startup, agentLabel)
|
|
1190
|
+
: followup
|
|
1191
|
+
? "Thinking…"
|
|
1192
|
+
: "Starting investigation…";
|
|
1193
|
+
const failedServers = (startup?.mcpServers ?? []).filter(
|
|
1194
|
+
(server) => server.status !== "connected",
|
|
1195
|
+
);
|
|
1196
|
+
const radarServer = failedServers.find((server) => server.name === "radar");
|
|
1197
|
+
const allDefiniteFailures = failedServers.every((server) =>
|
|
1198
|
+
mcpStatusIsFailure(server.status),
|
|
1199
|
+
);
|
|
1110
1200
|
return (
|
|
1111
1201
|
<div className="space-y-1.5">
|
|
1112
1202
|
{items.length > 0 && (
|
|
@@ -1114,6 +1204,33 @@ export function Timeline({
|
|
|
1114
1204
|
{heading}
|
|
1115
1205
|
</div>
|
|
1116
1206
|
)}
|
|
1207
|
+
{failedServers.length > 0 && (
|
|
1208
|
+
<div
|
|
1209
|
+
role="status"
|
|
1210
|
+
className="flex items-start gap-1.5 rounded border border-semantic-warning/40 bg-semantic-warning/10 p-2 text-[11px] leading-snug text-theme-text-secondary"
|
|
1211
|
+
>
|
|
1212
|
+
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-semantic-warning" />
|
|
1213
|
+
<span>
|
|
1214
|
+
{failedServers.map((server, i) => (
|
|
1215
|
+
<span key={server.name}>
|
|
1216
|
+
{i > 0 ? "; " : ""}
|
|
1217
|
+
MCP server{" "}
|
|
1218
|
+
<span className="font-medium text-theme-text-primary">
|
|
1219
|
+
{server.name}
|
|
1220
|
+
</span>{" "}
|
|
1221
|
+
{mcpStatusPhrase(server.status)}
|
|
1222
|
+
</span>
|
|
1223
|
+
))}
|
|
1224
|
+
{radarServer
|
|
1225
|
+
? mcpStatusIsFailure(radarServer.status)
|
|
1226
|
+
? ` at startup. ${agentLabel} had no Radar tools this turn, so it could not use Radar's cluster evidence.`
|
|
1227
|
+
: ` at startup. Radar's tools may not have been available to ${agentLabel} this turn.`
|
|
1228
|
+
: allDefiniteFailures
|
|
1229
|
+
? ` at startup. ${agentLabel} ran this turn without those tools.`
|
|
1230
|
+
: ` at startup. Those tools may not have been available to ${agentLabel} this turn.`}
|
|
1231
|
+
</span>
|
|
1232
|
+
</div>
|
|
1233
|
+
)}
|
|
1117
1234
|
{items.map((it, i) => {
|
|
1118
1235
|
if (it.kind === "thinking") {
|
|
1119
1236
|
return (
|
|
@@ -1254,22 +1371,45 @@ function RunningStatus({ label }: { label: string }) {
|
|
|
1254
1371
|
}, []);
|
|
1255
1372
|
const sinceChange = elapsed - lastChangeRef.current;
|
|
1256
1373
|
const stalled = elapsed >= 30 && sinceChange >= 30;
|
|
1374
|
+
const counter = runningElapsedLabel(elapsed);
|
|
1257
1375
|
return (
|
|
1258
1376
|
<div className="flex items-center gap-2 pt-1 text-xs">
|
|
1259
1377
|
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />
|
|
1260
|
-
<span className="ai-shimmer">{label}</span>
|
|
1261
|
-
{elapsed >= 3 && (
|
|
1262
|
-
<span className="shrink-0 text-theme-text-tertiary">· {elapsed}s</span>
|
|
1263
|
-
)}
|
|
1378
|
+
<span className="ai-shimmer min-w-0 truncate">{label}</span>
|
|
1264
1379
|
{stalled && (
|
|
1265
1380
|
<span className="shrink-0 text-theme-text-tertiary">
|
|
1266
1381
|
· still working — no update for {sinceChange}s
|
|
1267
1382
|
</span>
|
|
1268
1383
|
)}
|
|
1384
|
+
{counter && (
|
|
1385
|
+
<span className="ml-auto shrink-0 tabular-nums text-theme-text-tertiary">
|
|
1386
|
+
{counter}
|
|
1387
|
+
</span>
|
|
1388
|
+
)}
|
|
1269
1389
|
</div>
|
|
1270
1390
|
);
|
|
1271
1391
|
}
|
|
1272
1392
|
|
|
1393
|
+
// The wait the operator feels is the whole turn's, so the counter is a
|
|
1394
|
+
// row-level figure set apart from the label: "Connected to Radar's tools"
|
|
1395
|
+
// followed by "10s" read as if the handshake took that long.
|
|
1396
|
+
export function runningElapsedLabel(elapsed: number): string | undefined {
|
|
1397
|
+
return elapsed >= 3 ? `${elapsed}s elapsed` : undefined;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Claude Code reports each MCP server as connected, failed, needs-auth, or
|
|
1401
|
+
// pending. Only the first two are definite outcomes; anything else is left as
|
|
1402
|
+
// the CLI's own word so the warning never claims more than it knows.
|
|
1403
|
+
function mcpStatusIsFailure(status: string): boolean {
|
|
1404
|
+
return status === "failed" || status === "needs-auth";
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function mcpStatusPhrase(status: string): string {
|
|
1408
|
+
if (status === "failed") return "failed to connect";
|
|
1409
|
+
if (status === "needs-auth") return "needs authentication";
|
|
1410
|
+
return `is ${status}`;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1273
1413
|
// Maps a running tool to a human verb so the status line reads as activity, not
|
|
1274
1414
|
// machinery. Falls back to the prettified tool name for anything unmapped.
|
|
1275
1415
|
function toolActivity(tool: string): string {
|
|
@@ -1321,6 +1461,9 @@ function ToolRow({
|
|
|
1321
1461
|
const richResult =
|
|
1322
1462
|
!!step.result && (isJsonPayload(step.result) || step.result.length > 200);
|
|
1323
1463
|
const done = step.status === "done";
|
|
1464
|
+
const durationLabel = toolDurationLabel(step.ms);
|
|
1465
|
+
const errorReason =
|
|
1466
|
+
step.isError === true ? toolErrorReason(step.result) : undefined;
|
|
1324
1467
|
const outcomeLabel = !done
|
|
1325
1468
|
? "Running"
|
|
1326
1469
|
: step.isError === true
|
|
@@ -1359,9 +1502,16 @@ function ToolRow({
|
|
|
1359
1502
|
{argumentsPreview}
|
|
1360
1503
|
</span>
|
|
1361
1504
|
)}
|
|
1362
|
-
{
|
|
1505
|
+
{errorReason && !open && (
|
|
1506
|
+
// The arguments give way first: they are still readable expanded,
|
|
1507
|
+
// while the reason is the one thing this row exists to say.
|
|
1508
|
+
<span className="investigation-tool-reason max-w-full shrink-0 truncate text-[11px] text-semantic-error">
|
|
1509
|
+
{middleTruncate(errorReason)}
|
|
1510
|
+
</span>
|
|
1511
|
+
)}
|
|
1512
|
+
{durationLabel && (
|
|
1363
1513
|
<span className="ml-auto shrink-0 text-[11px] text-theme-text-tertiary">
|
|
1364
|
-
{
|
|
1514
|
+
{durationLabel}
|
|
1365
1515
|
</span>
|
|
1366
1516
|
)}
|
|
1367
1517
|
{hasDetail && <CollapseChevron open={open} className="h-3.5 w-3.5" />}
|
|
@@ -1454,6 +1604,7 @@ function ToolRow({
|
|
|
1454
1604
|
{step.result && (
|
|
1455
1605
|
<PayloadBlock
|
|
1456
1606
|
label="Original result"
|
|
1607
|
+
detail={step.ms != null ? `${step.ms}ms` : undefined}
|
|
1457
1608
|
text={step.result}
|
|
1458
1609
|
sourceExcerpt={sourceExcerpt}
|
|
1459
1610
|
revealRequestId={revealRequestId}
|
|
@@ -1488,6 +1639,59 @@ function ToolRow({
|
|
|
1488
1639
|
);
|
|
1489
1640
|
}
|
|
1490
1641
|
|
|
1642
|
+
// Only outliers carry information on the collapsed row: a slow log fetch or a
|
|
1643
|
+
// probe that hung. Sub-second calls stay silent; the exact figure lives in the
|
|
1644
|
+
// expanded result header.
|
|
1645
|
+
export function toolDurationLabel(ms: number | undefined): string | undefined {
|
|
1646
|
+
if (ms == null || ms < 2000) return undefined;
|
|
1647
|
+
return `${Math.round(ms / 1000)}s`;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// The producer's own words for a failed call, reduced to one line. Radar's MCP
|
|
1651
|
+
// errors are plain text; a JSON envelope with an `error` field is unwrapped.
|
|
1652
|
+
// Radar's not-found errors append retry hints for the agent after an em dash;
|
|
1653
|
+
// only the clause before it says what failed, so the hints are dropped.
|
|
1654
|
+
export function toolErrorReason(
|
|
1655
|
+
result: string | undefined,
|
|
1656
|
+
): string | undefined {
|
|
1657
|
+
if (!result) return undefined;
|
|
1658
|
+
let text = result;
|
|
1659
|
+
try {
|
|
1660
|
+
const parsed: unknown = JSON.parse(result);
|
|
1661
|
+
if (
|
|
1662
|
+
parsed &&
|
|
1663
|
+
typeof parsed === "object" &&
|
|
1664
|
+
typeof (parsed as { error?: unknown }).error === "string"
|
|
1665
|
+
) {
|
|
1666
|
+
text = (parsed as { error: string }).error;
|
|
1667
|
+
}
|
|
1668
|
+
} catch {
|
|
1669
|
+
// plain text
|
|
1670
|
+
}
|
|
1671
|
+
const line = text
|
|
1672
|
+
.split("\n")
|
|
1673
|
+
.map((part) => part.trim())
|
|
1674
|
+
.find((part) => part.length > 0);
|
|
1675
|
+
if (!line) return undefined;
|
|
1676
|
+
const clause = line.split(" — ")[0].trim();
|
|
1677
|
+
return clause || line;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
const COLLAPSED_REASON_MAX = 100;
|
|
1681
|
+
const COLLAPSED_REASON_TAIL = 36;
|
|
1682
|
+
|
|
1683
|
+
// Keeps both ends of a long reason: the leading words say what failed and the
|
|
1684
|
+
// tail usually carries the identifier, which a plain end-truncation would lose.
|
|
1685
|
+
export function middleTruncate(
|
|
1686
|
+
text: string,
|
|
1687
|
+
max = COLLAPSED_REASON_MAX,
|
|
1688
|
+
tail = COLLAPSED_REASON_TAIL,
|
|
1689
|
+
): string {
|
|
1690
|
+
if (text.length <= max) return text;
|
|
1691
|
+
const head = Math.max(1, max - tail - 1);
|
|
1692
|
+
return `${text.slice(0, head).trimEnd()}…${text.slice(-tail).trimStart()}`;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1491
1695
|
// isJsonPayload / formatJson — a tool result is "structured" if it parses as JSON.
|
|
1492
1696
|
function isJsonPayload(text: string): boolean {
|
|
1493
1697
|
try {
|
|
@@ -1509,6 +1713,7 @@ function formatJson(text: string): string | null {
|
|
|
1509
1713
|
// to keep indentation) or wrapped text (logs/prose), with copy + optional action.
|
|
1510
1714
|
function PayloadBlock({
|
|
1511
1715
|
label,
|
|
1716
|
+
detail,
|
|
1512
1717
|
text,
|
|
1513
1718
|
truncated,
|
|
1514
1719
|
action,
|
|
@@ -1516,6 +1721,7 @@ function PayloadBlock({
|
|
|
1516
1721
|
revealRequestId,
|
|
1517
1722
|
}: {
|
|
1518
1723
|
label: string;
|
|
1724
|
+
detail?: string;
|
|
1519
1725
|
text: string;
|
|
1520
1726
|
truncated?: boolean;
|
|
1521
1727
|
action?: ReactNode;
|
|
@@ -1547,6 +1753,11 @@ function PayloadBlock({
|
|
|
1547
1753
|
<div className="mb-0.5 flex items-center justify-between gap-2">
|
|
1548
1754
|
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
1549
1755
|
{label}
|
|
1756
|
+
{detail && (
|
|
1757
|
+
<span className="ml-1.5 normal-case tracking-normal">
|
|
1758
|
+
· {detail}
|
|
1759
|
+
</span>
|
|
1760
|
+
)}
|
|
1550
1761
|
</span>
|
|
1551
1762
|
<div className="flex items-center gap-2">
|
|
1552
1763
|
{action}
|
|
@@ -1711,6 +1922,7 @@ export function ResultCard({
|
|
|
1711
1922
|
showDisclaimer = true,
|
|
1712
1923
|
coverageLimited = false,
|
|
1713
1924
|
evidenceConflict = false,
|
|
1925
|
+
evidenceConflictExplainedBy,
|
|
1714
1926
|
compactActions = false,
|
|
1715
1927
|
assessmentAction,
|
|
1716
1928
|
assessmentSources,
|
|
@@ -1734,6 +1946,12 @@ export function ResultCard({
|
|
|
1734
1946
|
coverageLimited?: boolean;
|
|
1735
1947
|
/** Marks a healthy agent assessment that conflicts with same-turn Key evidence. */
|
|
1736
1948
|
evidenceConflict?: boolean;
|
|
1949
|
+
/**
|
|
1950
|
+
* Titles of the conflicting cards when the agent placed a "not a problem"
|
|
1951
|
+
* note on every one of them; the banner then points at the agent's reasons
|
|
1952
|
+
* rather than accusing evidence it already addressed.
|
|
1953
|
+
*/
|
|
1954
|
+
evidenceConflictExplainedBy?: string[];
|
|
1737
1955
|
/** Show only the recommended (or first) action until the user asks for more. */
|
|
1738
1956
|
compactActions?: boolean;
|
|
1739
1957
|
actionNotice?: string;
|
|
@@ -1753,7 +1971,14 @@ export function ResultCard({
|
|
|
1753
1971
|
// flag in its structured envelope. Never promote an ordinary answer into an
|
|
1754
1972
|
// authoritative investigation conclusion.
|
|
1755
1973
|
if (followup)
|
|
1756
|
-
return
|
|
1974
|
+
return (
|
|
1975
|
+
<>
|
|
1976
|
+
<FollowupAnswer diagnosis={diagnosis} animate={animate} />
|
|
1977
|
+
{assessmentSources ? (
|
|
1978
|
+
<AssessmentSourceDetails>{assessmentSources}</AssessmentSourceDetails>
|
|
1979
|
+
) : null}
|
|
1980
|
+
</>
|
|
1981
|
+
);
|
|
1757
1982
|
if (diagnosis.healthy && !diagnosis.rootCause)
|
|
1758
1983
|
return section === "actions" ? null : (
|
|
1759
1984
|
<AllClearCard
|
|
@@ -1762,6 +1987,7 @@ export function ResultCard({
|
|
|
1762
1987
|
showDisclaimer={showDisclaimer}
|
|
1763
1988
|
coverageLimited={coverageLimited}
|
|
1764
1989
|
evidenceConflict={evidenceConflict}
|
|
1990
|
+
evidenceConflictExplainedBy={evidenceConflictExplainedBy}
|
|
1765
1991
|
assessmentAction={assessmentAction}
|
|
1766
1992
|
assessmentSources={assessmentSources}
|
|
1767
1993
|
/>
|
|
@@ -1814,40 +2040,173 @@ export function ResultCard({
|
|
|
1814
2040
|
);
|
|
1815
2041
|
}
|
|
1816
2042
|
|
|
2043
|
+
/**
|
|
2044
|
+
* Sources the assessment cites: root-cause links first, then every source an
|
|
2045
|
+
* agent item cites. Each row shows the roles the agent gave that source; a
|
|
2046
|
+
* claim the pane could not pin to one observation is shown here in full.
|
|
2047
|
+
*/
|
|
2048
|
+
export function assessmentSourceRows(
|
|
2049
|
+
resolution: InvestigationRootCauseEvidenceResolution | undefined,
|
|
2050
|
+
investigationCase: InvestigationCaseResolution | undefined,
|
|
2051
|
+
): Array<{
|
|
2052
|
+
source: InvestigationEvidenceSource;
|
|
2053
|
+
items: InvestigationCaseItem[];
|
|
2054
|
+
}> {
|
|
2055
|
+
const rows = new Map<
|
|
2056
|
+
string,
|
|
2057
|
+
{ source: InvestigationEvidenceSource; items: InvestigationCaseItem[] }
|
|
2058
|
+
>();
|
|
2059
|
+
if (resolution?.status === "linked") {
|
|
2060
|
+
for (const link of resolution.links) {
|
|
2061
|
+
rows.set(link.source.id, { source: link.source, items: [] });
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
for (const item of investigationCase?.items ?? []) {
|
|
2065
|
+
const row = rows.get(item.source.id) ?? { source: item.source, items: [] };
|
|
2066
|
+
row.items.push(item);
|
|
2067
|
+
rows.set(item.source.id, row);
|
|
2068
|
+
}
|
|
2069
|
+
return [...rows.values()];
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
function joinTitles(titles: string[]): string {
|
|
2073
|
+
if (titles.length <= 1) return titles[0] ?? "";
|
|
2074
|
+
if (titles.length === 2) return `${titles[0]} and ${titles[1]}`;
|
|
2075
|
+
return `${titles.slice(0, -1).join(", ")}, and ${titles[titles.length - 1]}`;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
/**
|
|
2079
|
+
* Provenance for one assessment: the exact tool results it cited, each with
|
|
2080
|
+
* its source, and under a source only the agent notes that are not already
|
|
2081
|
+
* shown on a card. Notes that live on cards are counted, not repeated; an
|
|
2082
|
+
* earlier assessment no longer annotates the Evidence pane, so all of its
|
|
2083
|
+
* notes are listed here instead of being lost.
|
|
2084
|
+
*/
|
|
1817
2085
|
export function AssessmentSources({
|
|
1818
2086
|
resolution,
|
|
2087
|
+
investigationCase,
|
|
2088
|
+
unlinkedEvidence = 0,
|
|
2089
|
+
evidenceMalformed = false,
|
|
2090
|
+
readOnly = false,
|
|
2091
|
+
renderedGroupIds,
|
|
1819
2092
|
onViewSource,
|
|
1820
2093
|
}: {
|
|
1821
2094
|
resolution?: InvestigationRootCauseEvidenceResolution;
|
|
2095
|
+
investigationCase?: InvestigationCaseResolution;
|
|
2096
|
+
/**
|
|
2097
|
+
* Notes the agent wrote that could not be tied to a Radar result: a
|
|
2098
|
+
* reference that named nothing, a role Radar does not know, a sentence over
|
|
2099
|
+
* the length limit. Radar does not repair them, because repairing one means
|
|
2100
|
+
* deciding what the agent meant, so it says how many were lost instead.
|
|
2101
|
+
*/
|
|
2102
|
+
unlinkedEvidence?: number;
|
|
2103
|
+
/**
|
|
2104
|
+
* The agent's notes were not a list at all, so none of them could be read
|
|
2105
|
+
* and no count describes how many were lost.
|
|
2106
|
+
*/
|
|
2107
|
+
evidenceMalformed?: boolean;
|
|
2108
|
+
readOnly?: boolean;
|
|
2109
|
+
/**
|
|
2110
|
+
* Groups the Evidence pane actually rendered. A card-placed note whose card
|
|
2111
|
+
* was withheld is shown here instead of being counted as visible elsewhere;
|
|
2112
|
+
* without this the note renders in neither place. Omitted by hosts that do
|
|
2113
|
+
* not know, which keeps the original counting.
|
|
2114
|
+
*/
|
|
2115
|
+
renderedGroupIds?: ReadonlySet<string>;
|
|
1822
2116
|
onViewSource: (sourceId: string) => void;
|
|
1823
2117
|
}) {
|
|
1824
|
-
|
|
2118
|
+
const rows = assessmentSourceRows(resolution, investigationCase);
|
|
2119
|
+
if (rows.length === 0 && unlinkedEvidence === 0 && !evidenceMalformed)
|
|
2120
|
+
return null;
|
|
1825
2121
|
return (
|
|
1826
2122
|
<div className="mt-3 border-t border-theme-border/60 pt-2">
|
|
1827
|
-
<h4 className="text-
|
|
2123
|
+
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
1828
2124
|
Sources used for this assessment
|
|
2125
|
+
<span className="ml-1.5 font-medium normal-case tracking-normal text-theme-text-tertiary">
|
|
2126
|
+
{rows.length}
|
|
2127
|
+
</span>
|
|
1829
2128
|
</h4>
|
|
1830
|
-
<ul className="mt-1
|
|
1831
|
-
{
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
<
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2129
|
+
<ul className="mt-1 divide-y divide-theme-border/50">
|
|
2130
|
+
{rows.map(({ source, items }) => {
|
|
2131
|
+
const shownOnCard = (item: InvestigationCaseItem) =>
|
|
2132
|
+
!!item.claim &&
|
|
2133
|
+
item.placement !== "source" &&
|
|
2134
|
+
(!renderedGroupIds ||
|
|
2135
|
+
(!!item.groupId && renderedGroupIds.has(item.groupId)));
|
|
2136
|
+
const onCards = items.filter(shownOnCard).length;
|
|
2137
|
+
const notes = items.filter(
|
|
2138
|
+
(item) => item.claim && (readOnly || !shownOnCard(item)),
|
|
2139
|
+
);
|
|
2140
|
+
return (
|
|
2141
|
+
<li key={source.id} className="py-1.5">
|
|
2142
|
+
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-2 px-2">
|
|
2143
|
+
<FileSearch
|
|
2144
|
+
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
|
|
2145
|
+
aria-hidden
|
|
2146
|
+
/>
|
|
2147
|
+
<div className="min-w-0 text-xs">
|
|
2148
|
+
<div className="font-medium text-theme-text-primary">
|
|
2149
|
+
{prettyTool(source.tool)}
|
|
2150
|
+
</div>
|
|
2151
|
+
<CitedSourceScope source={source} />
|
|
2152
|
+
{!readOnly && onCards > 0 ? (
|
|
2153
|
+
<div className="mt-0.5 text-[11px] text-theme-text-tertiary">
|
|
2154
|
+
{onCards === 1
|
|
2155
|
+
? "1 agent note on an evidence card"
|
|
2156
|
+
: `${onCards} agent notes on evidence cards`}
|
|
2157
|
+
</div>
|
|
2158
|
+
) : null}
|
|
2159
|
+
</div>
|
|
2160
|
+
<button
|
|
2161
|
+
type="button"
|
|
2162
|
+
aria-label={`View ${prettyTool(source.tool)} source used for this assessment`}
|
|
2163
|
+
onClick={() => onViewSource(source.id)}
|
|
2164
|
+
className="inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-accent-text hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
2165
|
+
>
|
|
2166
|
+
View source
|
|
2167
|
+
</button>
|
|
2168
|
+
</div>
|
|
2169
|
+
{notes.length > 0 ? (
|
|
2170
|
+
<div
|
|
2171
|
+
className="ml-[1.9rem] mr-2 mt-1.5 space-y-1.5"
|
|
2172
|
+
data-source-placed-claims
|
|
2173
|
+
>
|
|
2174
|
+
<div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
2175
|
+
{readOnly
|
|
2176
|
+
? "Notes from this assessment"
|
|
2177
|
+
: "Notes not shown on a card"}
|
|
2178
|
+
</div>
|
|
2179
|
+
{notes.map((item) => (
|
|
2180
|
+
<AgentClaimNote
|
|
2181
|
+
key={item.index}
|
|
2182
|
+
claim={item.claim}
|
|
2183
|
+
role={item.role}
|
|
2184
|
+
subject={
|
|
2185
|
+
item.placement === "source"
|
|
2186
|
+
? undefined
|
|
2187
|
+
: item.observation?.title
|
|
2188
|
+
}
|
|
2189
|
+
className="border-t-0"
|
|
2190
|
+
/>
|
|
2191
|
+
))}
|
|
2192
|
+
</div>
|
|
2193
|
+
) : null}
|
|
2194
|
+
</li>
|
|
2195
|
+
);
|
|
2196
|
+
})}
|
|
1850
2197
|
</ul>
|
|
2198
|
+
{evidenceMalformed ? (
|
|
2199
|
+
<p className="mt-2 text-[11px] text-theme-text-tertiary">
|
|
2200
|
+
The agent's notes could not be read, so none are shown.
|
|
2201
|
+
</p>
|
|
2202
|
+
) : null}
|
|
2203
|
+
{unlinkedEvidence > 0 ? (
|
|
2204
|
+
<p className="mt-2 text-[11px] text-theme-text-tertiary">
|
|
2205
|
+
{unlinkedEvidence === 1
|
|
2206
|
+
? "1 agent note could not be linked to a Radar result and is not shown."
|
|
2207
|
+
: `${unlinkedEvidence} agent notes could not be linked to Radar results and are not shown.`}
|
|
2208
|
+
</p>
|
|
2209
|
+
) : null}
|
|
1851
2210
|
</div>
|
|
1852
2211
|
);
|
|
1853
2212
|
}
|
|
@@ -2045,7 +2404,17 @@ function DiagnosisResult({
|
|
|
2045
2404
|
Apply…
|
|
2046
2405
|
</button>
|
|
2047
2406
|
)}
|
|
2048
|
-
|
|
2407
|
+
{remediationCommands(r).map((command, c, all) => (
|
|
2408
|
+
<CopyButton
|
|
2409
|
+
key={c}
|
|
2410
|
+
text={command}
|
|
2411
|
+
label={
|
|
2412
|
+
all.length > 1
|
|
2413
|
+
? `Copy command ${c + 1} of step ${i + 1}`
|
|
2414
|
+
: `Copy command from step ${i + 1}`
|
|
2415
|
+
}
|
|
2416
|
+
/>
|
|
2417
|
+
))}
|
|
2049
2418
|
</div>
|
|
2050
2419
|
</div>
|
|
2051
2420
|
</div>
|
|
@@ -2306,6 +2675,7 @@ function AllClearCard({
|
|
|
2306
2675
|
showDisclaimer,
|
|
2307
2676
|
coverageLimited,
|
|
2308
2677
|
evidenceConflict,
|
|
2678
|
+
evidenceConflictExplainedBy,
|
|
2309
2679
|
assessmentAction,
|
|
2310
2680
|
assessmentSources,
|
|
2311
2681
|
}: {
|
|
@@ -2314,6 +2684,7 @@ function AllClearCard({
|
|
|
2314
2684
|
showDisclaimer: boolean;
|
|
2315
2685
|
coverageLimited: boolean;
|
|
2316
2686
|
evidenceConflict: boolean;
|
|
2687
|
+
evidenceConflictExplainedBy?: string[];
|
|
2317
2688
|
assessmentAction?: ReactNode;
|
|
2318
2689
|
assessmentSources?: ReactNode;
|
|
2319
2690
|
}) {
|
|
@@ -2327,11 +2698,16 @@ function AllClearCard({
|
|
|
2327
2698
|
const summary = detailed
|
|
2328
2699
|
? "The agent found no active problem in the evidence it reviewed."
|
|
2329
2700
|
: report;
|
|
2701
|
+
const explained =
|
|
2702
|
+
evidenceConflict &&
|
|
2703
|
+
!!evidenceConflictExplainedBy &&
|
|
2704
|
+
evidenceConflictExplainedBy.length > 0;
|
|
2705
|
+
const unexplainedConflict = evidenceConflict && !explained;
|
|
2330
2706
|
return (
|
|
2331
2707
|
<div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
|
|
2332
2708
|
<div
|
|
2333
2709
|
className={`rounded-lg border p-3 ${
|
|
2334
|
-
|
|
2710
|
+
unexplainedConflict || explained
|
|
2335
2711
|
? "border-amber-500/40 bg-amber-500/5"
|
|
2336
2712
|
: coverageLimited
|
|
2337
2713
|
? "border-amber-500/30 bg-amber-500/5"
|
|
@@ -2341,33 +2717,44 @@ function AllClearCard({
|
|
|
2341
2717
|
<div className="mb-1 flex items-center justify-between gap-2">
|
|
2342
2718
|
<div
|
|
2343
2719
|
className={`flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide ${
|
|
2344
|
-
|
|
2720
|
+
unexplainedConflict || explained || coverageLimited
|
|
2345
2721
|
? "text-amber-500"
|
|
2346
2722
|
: "text-emerald-500"
|
|
2347
2723
|
}`}
|
|
2348
2724
|
>
|
|
2349
|
-
{
|
|
2725
|
+
{unexplainedConflict || explained || coverageLimited ? (
|
|
2350
2726
|
<AlertTriangle className="h-3.5 w-3.5" />
|
|
2351
2727
|
) : (
|
|
2352
2728
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
2353
2729
|
)}
|
|
2354
|
-
{
|
|
2730
|
+
{unexplainedConflict
|
|
2355
2731
|
? "Assessment conflicts with captured evidence"
|
|
2356
|
-
:
|
|
2357
|
-
? "
|
|
2358
|
-
:
|
|
2732
|
+
: explained
|
|
2733
|
+
? "Agent reports no active problem; adverse evidence remains"
|
|
2734
|
+
: coverageLimited
|
|
2735
|
+
? "No problem identified in available evidence"
|
|
2736
|
+
: "No problem found in checked evidence"}
|
|
2359
2737
|
</div>
|
|
2360
2738
|
<CopyButton text={report} label="Copy assessment" />
|
|
2361
2739
|
</div>
|
|
2362
2740
|
<AIMarkdown className="text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_li]:text-theme-text-primary [&_p]:my-1 [&_p]:text-theme-text-primary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
|
|
2363
2741
|
{summary}
|
|
2364
2742
|
</AIMarkdown>
|
|
2365
|
-
{
|
|
2743
|
+
{unexplainedConflict ? (
|
|
2366
2744
|
<p className="mt-2 text-xs text-theme-text-secondary">
|
|
2367
2745
|
Radar also captured evidence of an active problem. Review that
|
|
2368
2746
|
evidence before treating the agent's conclusion as an
|
|
2369
2747
|
all-clear.
|
|
2370
2748
|
</p>
|
|
2749
|
+
) : explained ? (
|
|
2750
|
+
<p className="mt-2 text-xs text-theme-text-secondary">
|
|
2751
|
+
Radar captured evidence of an active problem. The agent explains its
|
|
2752
|
+
interpretation in the note on{" "}
|
|
2753
|
+
{joinTitles(evidenceConflictExplainedBy!)}.
|
|
2754
|
+
{coverageLimited
|
|
2755
|
+
? " Evidence coverage is also limited — review the limitations in Evidence."
|
|
2756
|
+
: ""}
|
|
2757
|
+
</p>
|
|
2371
2758
|
) : coverageLimited ? (
|
|
2372
2759
|
<p className="mt-2 text-xs text-theme-text-secondary">
|
|
2373
2760
|
Evidence coverage is limited. Review the limitations in Evidence
|
|
@@ -2603,6 +2990,63 @@ function ApplyOutcomeCard({
|
|
|
2603
2990
|
);
|
|
2604
2991
|
}
|
|
2605
2992
|
|
|
2993
|
+
const COMMAND_BINARIES = new Set([
|
|
2994
|
+
"kubectl",
|
|
2995
|
+
"helm",
|
|
2996
|
+
"argocd",
|
|
2997
|
+
"flux",
|
|
2998
|
+
"kustomize",
|
|
2999
|
+
"docker",
|
|
3000
|
+
"gcloud",
|
|
3001
|
+
"aws",
|
|
3002
|
+
"az",
|
|
3003
|
+
"mongosh",
|
|
3004
|
+
"psql",
|
|
3005
|
+
"redis-cli",
|
|
3006
|
+
"curl",
|
|
3007
|
+
"git",
|
|
3008
|
+
"istioctl",
|
|
3009
|
+
"velero",
|
|
3010
|
+
"cilium",
|
|
3011
|
+
"calicoctl",
|
|
3012
|
+
"terraform",
|
|
3013
|
+
"kn",
|
|
3014
|
+
"oc",
|
|
3015
|
+
"k9s",
|
|
3016
|
+
"skyhook",
|
|
3017
|
+
]);
|
|
3018
|
+
|
|
3019
|
+
/**
|
|
3020
|
+
* The commands inside a remediation step, in order: every fenced block and
|
|
3021
|
+
* every inline code span that reads as a shell invocation. A step's prose is
|
|
3022
|
+
* never worth copying; a command is. The prompt asks the agent to wrap
|
|
3023
|
+
* commands in backticks, so this is the seam to read them from.
|
|
3024
|
+
*/
|
|
3025
|
+
export function remediationCommands(step: string): string[] {
|
|
3026
|
+
const commands: string[] = [];
|
|
3027
|
+
// One pass in reading order, so button N is the Nth command in the text.
|
|
3028
|
+
const code = /```[a-zA-Z]*\n([\s\S]*?)```|`([^`\n]+)`/g;
|
|
3029
|
+
let match: RegExpExecArray | null;
|
|
3030
|
+
while ((match = code.exec(step))) {
|
|
3031
|
+
if (match[1] !== undefined) {
|
|
3032
|
+
const body = match[1].trim();
|
|
3033
|
+
if (body) commands.push(body);
|
|
3034
|
+
continue;
|
|
3035
|
+
}
|
|
3036
|
+
const span = match[2].trim();
|
|
3037
|
+
if (looksLikeCommand(span)) commands.push(span);
|
|
3038
|
+
}
|
|
3039
|
+
return commands;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
function looksLikeCommand(span: string): boolean {
|
|
3043
|
+
if (!/\s/.test(span)) return false;
|
|
3044
|
+
const first = span.split(/\s+/)[0];
|
|
3045
|
+
if (COMMAND_BINARIES.has(first)) return true;
|
|
3046
|
+
// An unknown binary still reads as a command when it takes flags.
|
|
3047
|
+
return /^[a-z][a-z0-9._-]*$/.test(first) && /(^|\s)--?[a-zA-Z]/.test(span);
|
|
3048
|
+
}
|
|
3049
|
+
|
|
2606
3050
|
function CopyButton({ text, label }: { text: string; label: string }) {
|
|
2607
3051
|
const [copied, setCopied] = useState(false);
|
|
2608
3052
|
return (
|