@skyhook-io/radar-app 1.14.4 → 1.14.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.
@@ -34,6 +34,53 @@ import { groupEvidenceCoverage } from "./investigationEvidencePresentation";
34
34
  import { metricsChangeMarkers } from "./investigationMetrics";
35
35
 
36
36
  const onViewSource = vi.fn();
37
+
38
+ describe("coverage already summarized by the assessment", () => {
39
+ it("removes only the repeated collapsed preview, retaining source details and new gaps", () => {
40
+ const projection = project(
41
+ tool(
42
+ "logs-denied",
43
+ "get_pod_logs",
44
+ { error: "pods/log is forbidden" },
45
+ { isError: true },
46
+ ),
47
+ tool("issues-cut", "issues", {}, { truncated: true }),
48
+ );
49
+ const groups = groupEvidenceCoverage(projection.limitations);
50
+ const line = `${groups[0].label}: ${groups[0].summary}`;
51
+ const renderStory = (summarizedLimits: string[], summary = "Assessment") =>
52
+ renderToStaticMarkup(
53
+ <InvestigationEvidencePane
54
+ projection={projection}
55
+ story={{ summary, report: "Analysis [[radar:evidence=0]]" }}
56
+ summarizedLimits={summarizedLimits}
57
+ collecting={false}
58
+ animateGroupIds={new Set()}
59
+ onViewSource={onViewSource}
60
+ onViewActivity={() => {}}
61
+ />,
62
+ );
63
+ const preview = (html: string) =>
64
+ html.match(
65
+ /<button[^>]*aria-controls="investigation-evidence-coverage"[\s\S]*?<\/button>/,
66
+ )?.[0] ?? "";
67
+ const firstSummary = renderToStaticMarkup(<>{groups[0].summary}</>);
68
+ expect(preview(renderStory([]))).toContain(firstSummary);
69
+ const html = renderStory([line]);
70
+ expect(preview(html)).not.toContain(firstSummary);
71
+ expect(preview(html)).toContain(groups[1].summary);
72
+ expect(html).toContain('aria-label="View result for Container logs"');
73
+ expect(html).toContain("Evidence coverage update:");
74
+ const allSummarized = renderStory(
75
+ groups.map((g) => `${g.label}: ${g.summary}`),
76
+ );
77
+ expect(preview(allSummarized)).not.toContain("line-clamp-2");
78
+ expect(preview(allSummarized)).toContain("Evidence coverage is incomplete");
79
+ for (const summary of ["", " "]) {
80
+ expect(preview(renderStory([line], summary))).toContain(firstSummary);
81
+ }
82
+ });
83
+ });
37
84
  const target = {
38
85
  kind: "Deployment",
39
86
  group: "apps",
@@ -71,6 +71,7 @@ export function InvestigationEvidencePane({
71
71
  afterEvidence,
72
72
  recordNotes,
73
73
  storyShell = false,
74
+ summarizedLimits = [],
74
75
  revealRequest,
75
76
  onRevealReady,
76
77
  }: {
@@ -85,7 +86,11 @@ export function InvestigationEvidencePane({
85
86
  * `evidence` array. Absent on backends and runs without the story contract,
86
87
  * which keep the previous layout.
87
88
  */
88
- story?: { report: string; evidence?: DiagnosisEvidenceItem[] };
89
+ story?: {
90
+ summary?: string;
91
+ report: string;
92
+ evidence?: DiagnosisEvidenceItem[];
93
+ };
89
94
  /** Open the story fully instead of the bounded preview — when the layout has room for it. */
90
95
  collecting: boolean;
91
96
  animateGroupIds: ReadonlySet<string>;
@@ -105,6 +110,8 @@ export function InvestigationEvidencePane({
105
110
  recordNotes?: ReactNode;
106
111
  /** No verdict yet, but the story shape is coming: keep its cards in place. */
107
112
  storyShell?: boolean;
113
+ /** Coverage lines already visible in the current assessment's Still open list. */
114
+ summarizedLimits?: readonly string[];
108
115
  /** Explicit Activity → Findings navigation, including repeat clicks. */
109
116
  revealRequest?: { sourceId: string; requestId: number };
110
117
  onRevealReady?: (sourceId: string) => void;
@@ -279,6 +286,12 @@ export function InvestigationEvidencePane({
279
286
  const limitationSummary = coverageGroups
280
287
  .map((group) => `${group.label}: ${group.summary}`)
281
288
  .join(" · ");
289
+ const additionalLimitations = coverageGroups
290
+ .map((group) => `${group.label}: ${group.summary}`)
291
+ .filter(
292
+ (line) => !story?.summary?.trim() || !summarizedLimits.includes(line),
293
+ )
294
+ .join(" · ");
282
295
  // Resolves a story placement to the agent item it names, or says why it
283
296
  // cannot: an index Radar does not know, an item the server could not bind,
284
297
  // or a bound item that matches no single observation. Only the last two
@@ -657,7 +670,7 @@ export function InvestigationEvidencePane({
657
670
  <CoverageStrip
658
671
  groups={coverageGroups}
659
672
  visibleGroupIds={new Set(partition.collectionByGroup.keys())}
660
- summary={limitationSummary}
673
+ summary={additionalLimitations}
661
674
  onViewSource={onViewSource}
662
675
  open={coverageOpen}
663
676
  onOpenChange={setCoverageOpen}
@@ -318,7 +318,7 @@ export function CoverageStrip({
318
318
  ? "Change history is limited"
319
319
  : "Evidence coverage is incomplete"}
320
320
  </span>
321
- {!historyOnly && !open ? (
321
+ {!historyOnly && !open && summary ? (
322
322
  <span className="line-clamp-2 text-xs text-theme-text-tertiary [overflow-wrap:anywhere]">
323
323
  {summary}
324
324
  </span>
@@ -788,7 +788,8 @@ export function InvestigationView({
788
788
  assessment: Turn,
789
789
  ): AssessmentExplanation | undefined => {
790
790
  const sequence = assessment.resultSequence;
791
- if (!sequence || !assessment.diagnosis?.rootCause) return undefined;
791
+ if (!sequence || !investigationIsAssessmentTurn(assessment))
792
+ return undefined;
792
793
  const saved = investigationExplanation(turns, sequence);
793
794
  if ((readOnly || !explanationEnabled) && saved.status === "idle")
794
795
  return undefined;
@@ -2361,12 +2362,14 @@ export function InvestigationView({
2361
2362
  story={
2362
2363
  storyShape && currentAssessment?.diagnosis
2363
2364
  ? {
2365
+ summary: currentAssessment.diagnosis.summary,
2364
2366
  report: currentAssessment.diagnosis.report,
2365
2367
  evidence: currentAssessment.diagnosis.evidence,
2366
2368
  }
2367
2369
  : undefined
2368
2370
  }
2369
2371
  storyShell={storyShell}
2372
+ summarizedLimits={storyShape ? assessmentLimits : undefined}
2370
2373
  collecting={
2371
2374
  explanationRequest?.status !== "running" &&
2372
2375
  (requestPending ||
@@ -21,6 +21,7 @@ import {
21
21
  } from "./investigationCase";
22
22
  import { AssessmentSources, ResultCard, assessmentSourceRows } from "./parts";
23
23
  import { namedInventoryRows } from "./investigationEvidence/bodies/inventory";
24
+ import { LogsBody } from "./investigationEvidence/bodies/streams";
24
25
  import type { Diagnosis, DiagnosisEvidenceItem } from "../../api/diagnose";
25
26
 
26
27
  const onViewSource = vi.fn();
@@ -1214,6 +1215,61 @@ describe("agent case placement (D-1, D-1b)", () => {
1214
1215
  ]);
1215
1216
  });
1216
1217
 
1218
+ it.each([
1219
+ ["resolved", "app", "app", "card"],
1220
+ ["wrong container", "proxy", "app", "source"],
1221
+ ["unknown container", undefined, "app", "source"],
1222
+ ] as const)(
1223
+ "places a named log claim only against its %s stream",
1224
+ (_name, container, claimedContainer, placement) => {
1225
+ const ref = evidenceRef("a", "b");
1226
+ const projection = project(
1227
+ tool(
1228
+ "logs",
1229
+ "get_pod_logs",
1230
+ {
1231
+ container,
1232
+ lines: ["ERROR missing configuration"],
1233
+ totalLines: 1,
1234
+ matchedLines: 1,
1235
+ fallback: false,
1236
+ },
1237
+ {
1238
+ summary: JSON.stringify({ namespace: "shop", name: "api-abc" }),
1239
+ evidenceRef: ref,
1240
+ },
1241
+ ),
1242
+ );
1243
+ const resolved = resolveInvestigationCase(
1244
+ projection,
1245
+ {
1246
+ evidence: [
1247
+ linked(ref, "cause", "The application is missing configuration.", {
1248
+ kind: "Pod",
1249
+ namespace: "shop",
1250
+ name: "api-abc",
1251
+ container: claimedContainer,
1252
+ observation: "logs",
1253
+ }),
1254
+ ],
1255
+ },
1256
+ 0,
1257
+ );
1258
+ expect(resolved.items[0].placement).toBe(placement);
1259
+ if (placement === "card") {
1260
+ expect(resolved.items[0].groupId).toBe(projection.groups[0].id);
1261
+ }
1262
+ if (container === undefined) {
1263
+ const data = projection.groups[0].latest.data;
1264
+ expect(data.type).toBe("logs");
1265
+ if (data.type !== "logs") throw new Error("expected log evidence");
1266
+ const html = renderToStaticMarkup(<LogsBody data={data} />);
1267
+ expect(html).toContain("container unknown");
1268
+ expect(html).not.toContain("default container");
1269
+ }
1270
+ },
1271
+ );
1272
+
1217
1273
  it("binds to the exact earlier read and renders on its revision row, not the card head", () => {
1218
1274
  const first = evidenceRef("a", "c");
1219
1275
  const second = evidenceRef("a", "d");
@@ -13,6 +13,66 @@ import {
13
13
  workloadLogsPayload,
14
14
  } from "../evidenceFixtures";
15
15
 
16
+ describe("pod logs container identity", () => {
17
+ const payload = {
18
+ lines: ["ERROR missing configuration"],
19
+ totalLines: 1,
20
+ matchedLines: 1,
21
+ fallback: false,
22
+ };
23
+
24
+ it.each([
25
+ ["resolved identity", { ...payload, container: "app" }, {}, "app"],
26
+ [
27
+ "producer identity wins",
28
+ { ...payload, container: "app" },
29
+ { container: "proxy" },
30
+ "app",
31
+ ],
32
+ ["saved explicit request", payload, { container: "app" }, "app"],
33
+ ["saved unspecified request", payload, {}, undefined],
34
+ ])("uses %s", (_name, result, args, expected) => {
35
+ const projection = project([
36
+ tool("logs", "get_pod_logs", result, {
37
+ summary: JSON.stringify({ namespace: "shop", name: "api", ...args }),
38
+ }),
39
+ ]);
40
+ const observation = groupsOf(projection.groups, "logs")[0].latest;
41
+ expect(observation.data).toMatchObject({
42
+ type: "logs",
43
+ container: expected,
44
+ });
45
+ expect(observation.title).toContain(expected ?? "container unknown");
46
+ });
47
+
48
+ it("does not merge separate unknown streams or current and previous instances", () => {
49
+ const projection = project([
50
+ tool("unknown-1", "get_pod_logs", payload),
51
+ tool("unknown-2", "get_pod_logs", payload),
52
+ tool("current", "get_pod_logs", { ...payload, container: "app" }),
53
+ tool(
54
+ "previous",
55
+ "get_pod_logs",
56
+ { ...payload, container: "app" },
57
+ {
58
+ summary: JSON.stringify({
59
+ namespace: "shop",
60
+ name: "api",
61
+ previous: true,
62
+ }),
63
+ },
64
+ ),
65
+ ]);
66
+ const groups = groupsOf(projection.groups, "logs");
67
+ expect(groups).toHaveLength(4);
68
+ expect(new Set(groups.map((group) => group.identity)).size).toBe(4);
69
+ expect(groups.slice(2).map((group) => group.identity)).toEqual([
70
+ "logs:current:api:app",
71
+ "logs:previous:api:app",
72
+ ]);
73
+ });
74
+ });
75
+
16
76
  // The get_workload_logs response shape, with the stream rows fetchPodLogs
17
77
  // emits per pod and container.
18
78
 
@@ -39,9 +39,13 @@ export function adaptPodLogs(
39
39
  const warnings = stringArray(value?.warnings) ?? [];
40
40
  const args = record(source.args ? parseJSON(source.args) : undefined);
41
41
  const pod = nonEmptyString(args?.name) ? args.name : "Pod";
42
- const container = nonEmptyString(args?.container)
43
- ? args.container
44
- : "default container";
42
+ // Saved results may omit container identity while their arguments explicitly
43
+ // name the requested container. Otherwise its identity is unknown.
44
+ const container = nonEmptyString(value?.container)
45
+ ? value.container
46
+ : nonEmptyString(args?.container)
47
+ ? args.container
48
+ : undefined;
45
49
  addLogs(
46
50
  builder,
47
51
  source,
@@ -33,7 +33,7 @@ export function LogsBody({
33
33
  {condensed ? null : (
34
34
  <>
35
35
  <Badge tone="structural" size="sm">
36
- {data.pod} / {data.container}
36
+ {data.pod} / {data.container ?? "container unknown"}
37
37
  </Badge>
38
38
  <Badge severity="neutral" size="sm">
39
39
  {data.previous ? "previous instance" : "current instance"}
@@ -912,19 +912,20 @@ export function changesSubjectFromArgs(
912
912
  export function addLogs(
913
913
  builder: ProjectionBuilder,
914
914
  source: InvestigationEvidenceSource,
915
- value: DiagnosisPodLogEntry,
915
+ value: Omit<DiagnosisPodLogEntry, "container"> & { container?: string },
916
916
  previous: boolean,
917
917
  warnings: string[] = [],
918
918
  relevance: InvestigationEvidenceRelevance = "broader",
919
919
  namespace?: string,
920
920
  ): void {
921
921
  const lines = (value.logs?.lines ?? []).map((line) => stripAnsi(line));
922
+ const containerLabel = value.container ?? "container unknown";
922
923
  const normalizedWarnings = warnings.map((warning) => stripAnsi(warning));
923
924
  const normalizedError = value.error ? stripAnsi(value.error) : undefined;
924
925
  if (lines.length === 0) {
925
926
  builder.limit(
926
927
  source,
927
- `${value.pod} / ${value.container}`,
928
+ `${value.pod} / ${containerLabel}`,
928
929
  value.error ||
929
930
  "No log lines were available. This does not mean the container is healthy.",
930
931
  value.error ? "error" : "unknown",
@@ -941,7 +942,9 @@ export function addLogs(
941
942
  ),
942
943
  );
943
944
  const selectedEvidence = value.logs?.fallback !== true && diagnosticSignal;
944
- const identity = `logs:${previous ? "previous" : "current"}:${value.pod}:${value.container}`;
945
+ // Unknown streams from separate reads need not belong to the same container.
946
+ const streamIdentity = value.container ?? `unknown:${source.id}`;
947
+ const identity = `logs:${previous ? "previous" : "current"}:${value.pod}:${streamIdentity}`;
945
948
  builder.observe(identity, "logs", source, {
946
949
  // FilterLogs' raw-tail fallback is useful provenance, but the producer did
947
950
  // not select it as diagnostic signal. Keep it in Context; only filtered
@@ -952,7 +955,7 @@ export function addLogs(
952
955
  ),
953
956
  relevance,
954
957
  tone: selectedEvidence || normalizedError ? "warning" : "neutral",
955
- title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${value.container}`,
958
+ title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${containerLabel}`,
956
959
  summary:
957
960
  lines.length > 0
958
961
  ? `${lines.length} selected line${lines.length === 1 ? "" : "s"}`
@@ -971,7 +974,7 @@ export function addLogs(
971
974
  if (normalizedError) {
972
975
  builder.limit(
973
976
  source,
974
- `${value.pod} / ${value.container}`,
977
+ `${value.pod} / ${containerLabel}`,
975
978
  normalizedError,
976
979
  "error",
977
980
  );
@@ -407,7 +407,7 @@ export type InvestigationEvidenceData =
407
407
  | {
408
408
  type: "logs";
409
409
  pod: string;
410
- container: string;
410
+ container?: string;
411
411
  /** Namespace the producing call actually read; absent when unstated. */
412
412
  namespace?: string;
413
413
  previous: boolean;
@@ -31,6 +31,82 @@ import { ThemeProvider } from "../../context/ThemeContext";
31
31
 
32
32
  const noop = vi.fn();
33
33
 
34
+ describe("explanations without a root cause", () => {
35
+ it("keeps inconclusive explanation, sources and action in one control row", () => {
36
+ const html = renderToStaticMarkup(
37
+ <ResultCard
38
+ diagnosis={
39
+ {
40
+ inconclusive: true,
41
+ summary: "Not enough evidence",
42
+ report: "Logs unavailable",
43
+ rootCause: "",
44
+ remediation: [],
45
+ } as Diagnosis
46
+ }
47
+ explanation={{ status: "idle", onGenerate: noop }}
48
+ assessmentSources={<span>Saved sources</span>}
49
+ assessmentAction={<button>Next steps</button>}
50
+ />,
51
+ );
52
+ expect(html.match(/data-assessment-actions/g)).toHaveLength(1);
53
+ const row = html.slice(
54
+ html.indexOf("data-assessment-actions"),
55
+ html.indexOf("data-assessment-actions") + 6000,
56
+ );
57
+ expect(row).toContain("Assessment details");
58
+ expect(row).toContain("Explain simply");
59
+ expect(row).toContain("Next steps");
60
+ });
61
+ it.each(["healthy", "inconclusive"] as const)(
62
+ "offers Explain for a %s assessment",
63
+ (state) => {
64
+ const diagnosis = {
65
+ [state]: true,
66
+ summary: "Saved assessment",
67
+ report: "Saved analysis",
68
+ rootCause: "",
69
+ remediation: [],
70
+ } as Diagnosis;
71
+ const html = renderToStaticMarkup(
72
+ <ResultCard
73
+ diagnosis={diagnosis}
74
+ explanation={{ status: "idle", onGenerate: noop }}
75
+ section="conclusion"
76
+ />,
77
+ );
78
+ expect(html.match(/Explain simply/g)).toHaveLength(1);
79
+ expect(html).not.toContain('disabled=""');
80
+ const running = renderToStaticMarkup(
81
+ <ResultCard
82
+ diagnosis={diagnosis}
83
+ explanation={{ status: "running" }}
84
+ section="conclusion"
85
+ />,
86
+ );
87
+ expect(running).toContain("Explaining this assessment");
88
+ expect(
89
+ renderToStaticMarkup(
90
+ <ResultCard
91
+ diagnosis={diagnosis}
92
+ explanation={{ status: "idle", onGenerate: noop }}
93
+ section="actions"
94
+ />,
95
+ ),
96
+ ).not.toContain("Explain simply");
97
+ expect(
98
+ renderToStaticMarkup(
99
+ <ResultCard
100
+ diagnosis={diagnosis}
101
+ explanation={{ status: "idle", onGenerate: noop }}
102
+ followup
103
+ />,
104
+ ),
105
+ ).not.toContain("Explain simply");
106
+ },
107
+ );
108
+ });
109
+
34
110
  it("keeps earlier remediation copyable without suggesting it is executable", () => {
35
111
  const html = renderToStaticMarkup(
36
112
  <ResultCard
@@ -95,10 +171,73 @@ describe("remediation commands", () => {
95
171
  compactActions
96
172
  />,
97
173
  );
98
- expect(html).toContain("Copy command 1 of step 1");
99
- expect(html).toContain("Copy command 2 of step 1");
174
+ expect(html.match(/aria-label="Copy command from step 1"/g)).toHaveLength(
175
+ 2,
176
+ );
100
177
  expect(html).not.toContain("Copy remediation step");
178
+ expect(
179
+ html.indexOf("kubectl rollout undo deployment/api -n dev</code>"),
180
+ ).toBeLessThan(html.indexOf("Copy command from step 1"));
181
+ expect(html.indexOf("Copy command from step 1")).toBeLessThan(
182
+ html.indexOf("kubectl -n dev get pods</code>"),
183
+ );
101
184
  });
185
+
186
+ it.each(["bash", ""])(
187
+ "places each copy button inside its own %s fenced command box",
188
+ (language) => {
189
+ const html = renderToStaticMarkup(
190
+ <ResultCard
191
+ diagnosis={
192
+ {
193
+ rootCause: "Image pull failure",
194
+ report: "Assessment",
195
+ remediation: [
196
+ `Roll back:\n\`\`\`${language}\nkubectl rollout undo deployment/api -n dev\n\`\`\`\nThen watch:\n\`\`\`${language}\nkubectl rollout status deployment/api -n dev\n\`\`\``,
197
+ ],
198
+ recommendedIndex: 1,
199
+ } as Diagnosis
200
+ }
201
+ section="actions"
202
+ compactActions
203
+ onApply={noop}
204
+ />,
205
+ );
206
+ const blocks = [...html.matchAll(/<pre\b[^>]*>([\s\S]*?)<\/pre>/g)].map(
207
+ (match) => match[1],
208
+ );
209
+ expect(blocks).toHaveLength(2);
210
+ expect(blocks[0]).toContain("kubectl rollout undo");
211
+ expect(blocks[0]).toContain("Copy command from step 1");
212
+ expect(blocks[1]).toContain("kubectl rollout status");
213
+ expect(blocks[1]).toContain("Copy command from step 1");
214
+ expect(html.match(/aria-label="Copy command/g)).toHaveLength(2);
215
+ expect(html.indexOf("Apply…")).toBeLessThan(html.indexOf("<pre"));
216
+ },
217
+ );
218
+ });
219
+
220
+ it("keeps repeated and normalized commands copyable at their rendered locations", () => {
221
+ const step =
222
+ "Run `kubectl get pods -n dev`, then run this: ```bash kubectl get pods -n dev ```";
223
+ expect(remediationCommands(step)).toEqual([
224
+ "kubectl get pods -n dev",
225
+ "kubectl get pods -n dev",
226
+ ]);
227
+ const html = renderToStaticMarkup(
228
+ <ResultCard
229
+ diagnosis={
230
+ { rootCause: "Check", report: "", remediation: [step] } as Diagnosis
231
+ }
232
+ section="actions"
233
+ compactActions
234
+ />,
235
+ );
236
+ expect(html.match(/aria-label="Copy command from step 1"/g)).toHaveLength(2);
237
+ expect(html).toMatch(
238
+ /<pre\b[^>]*>[\s\S]*kubectl get pods -n dev[\s\S]*Copy command from step 1[\s\S]*<\/pre>/,
239
+ );
240
+ expect(html).not.toContain("bash kubectl");
102
241
  });
103
242
 
104
243
  describe("explanation placement", () => {