@skyhook-io/radar-app 1.14.3 → 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.
- package/package.json +1 -1
- package/src/api/client.ts +2 -0
- package/src/api/diagnose.ts +7 -1
- package/src/components/diagnose/AIMarkdown.tsx +8 -3
- package/src/components/diagnose/ActivityTurn.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +269 -238
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +47 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +15 -2
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +1 -1
- package/src/components/diagnose/InvestigationView.tsx +32 -24
- package/src/components/diagnose/investigationCase.test.tsx +83 -0
- package/src/components/diagnose/investigationCase.ts +3 -2
- package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +22 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
- package/src/components/diagnose/investigationEvidence/adapters/listings.ts +2 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +60 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.ts +7 -3
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +1 -1
- package/src/components/diagnose/investigationEvidence/observations.ts +10 -5
- package/src/components/diagnose/investigationEvidence/types.ts +1 -1
- package/src/components/diagnose/investigationState.test.ts +2 -2
- package/src/components/diagnose/investigationState.ts +2 -2
- package/src/components/diagnose/investigationStory.ts +1 -1
- package/src/components/diagnose/parts.test.tsx +153 -2
- package/src/components/home/mcpToolCatalog.ts +27 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +194 -63
- package/src/components/rightsizing/empty-state.test.tsx +67 -0
- package/src/components/rightsizing/model.test.ts +118 -1
- package/src/components/rightsizing/model.ts +36 -13
- package/src/components/rightsizing/notices.test.tsx +175 -0
- package/src/components/ui/Markdown.tsx +33 -6
- package/src/components/ui/Omnibar.tsx +82 -69
- package/src/components/workload/WorkloadView.tsx +7 -1
|
@@ -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?: {
|
|
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={
|
|
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>
|
|
@@ -176,19 +176,17 @@ export function InvestigationView({
|
|
|
176
176
|
onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
|
|
177
177
|
}) {
|
|
178
178
|
const { kind, namespace, name } = run;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
agents.find((agent) => agent.name === run.agent),
|
|
191
|
-
);
|
|
179
|
+
const { refreshRuns, openInvestigation, startError, dismissError, agents } =
|
|
180
|
+
useDiagnose();
|
|
181
|
+
// Capabilities are the declared ones of the agent that ran this run, not
|
|
182
|
+
// the picker's: a reopened run keeps the backend it was made with.
|
|
183
|
+
const runAgent = agents.find((agent) => agent.name === run.agent);
|
|
184
|
+
const explanationEnabled = supportsAssessmentExplanation(runAgent);
|
|
185
|
+
const canApply = runAgent?.apply === true;
|
|
186
|
+
// Read through a ref by the stream callback, which lives as long as the
|
|
187
|
+
// run and would otherwise keep the value from before the agents loaded.
|
|
188
|
+
const verifiesAfterApplyRef = useRef(false);
|
|
189
|
+
verifiesAfterApplyRef.current = runAgent?.verification === true;
|
|
192
190
|
// Investigate again means look again, so it asks for a new session explicitly and only
|
|
193
191
|
// carries the issue forward — being handed the previous answer is the one
|
|
194
192
|
// thing someone clicking this doesn't want.
|
|
@@ -536,10 +534,13 @@ export function InvestigationView({
|
|
|
536
534
|
});
|
|
537
535
|
pendingApplyStartedLiveRef.current = false;
|
|
538
536
|
if (effects.refreshClusterState) refreshClusterState();
|
|
539
|
-
//
|
|
540
|
-
// durable event is the automatic
|
|
541
|
-
// the controls through that
|
|
542
|
-
|
|
537
|
+
// On a backend that verifies, a successful apply is one compound
|
|
538
|
+
// server-owned job whose next durable event is the automatic
|
|
539
|
+
// read-only verification turn; hold the controls through that
|
|
540
|
+
// adjacent event so there is no idle flash. A backend that
|
|
541
|
+
// declares no verification sends no such turn, so nothing waits.
|
|
542
|
+
if (effects.verificationPending && verifiesAfterApplyRef.current)
|
|
543
|
+
setVerificationPending(true);
|
|
543
544
|
}
|
|
544
545
|
if (live || (isApply && applyStartedLive)) refreshRuns();
|
|
545
546
|
break;
|
|
@@ -787,7 +788,8 @@ export function InvestigationView({
|
|
|
787
788
|
assessment: Turn,
|
|
788
789
|
): AssessmentExplanation | undefined => {
|
|
789
790
|
const sequence = assessment.resultSequence;
|
|
790
|
-
if (!sequence || !assessment
|
|
791
|
+
if (!sequence || !investigationIsAssessmentTurn(assessment))
|
|
792
|
+
return undefined;
|
|
791
793
|
const saved = investigationExplanation(turns, sequence);
|
|
792
794
|
if ((readOnly || !explanationEnabled) && saved.status === "idle")
|
|
793
795
|
return undefined;
|
|
@@ -1299,13 +1301,15 @@ export function InvestigationView({
|
|
|
1299
1301
|
(rootCauseEvidenceResolution?.links.length ||
|
|
1300
1302
|
investigationCase?.items.length ||
|
|
1301
1303
|
currentAssessment.diagnosis.unlinkedEvidence ||
|
|
1302
|
-
currentAssessment.diagnosis.evidenceMalformed
|
|
1304
|
+
currentAssessment.diagnosis.evidenceMalformed ||
|
|
1305
|
+
currentAssessment.diagnosis.omittedEntries) ? (
|
|
1303
1306
|
<AssessmentSources
|
|
1304
1307
|
renderedGroupIds={visibleEvidenceGroupIds}
|
|
1305
1308
|
resolution={rootCauseEvidenceResolution}
|
|
1306
1309
|
investigationCase={investigationCase}
|
|
1307
1310
|
unlinkedEvidence={currentAssessment.diagnosis.unlinkedEvidence}
|
|
1308
1311
|
evidenceMalformed={currentAssessment.diagnosis.evidenceMalformed}
|
|
1312
|
+
omittedEntries={currentAssessment.diagnosis.omittedEntries}
|
|
1309
1313
|
onViewSource={viewActivitySource}
|
|
1310
1314
|
/>
|
|
1311
1315
|
) : undefined;
|
|
@@ -1313,7 +1317,8 @@ export function InvestigationView({
|
|
|
1313
1317
|
storyShape &&
|
|
1314
1318
|
(investigationCase?.items.some((item) => item.placement === "source") ||
|
|
1315
1319
|
currentAssessment?.diagnosis?.unlinkedEvidence ||
|
|
1316
|
-
currentAssessment?.diagnosis?.evidenceMalformed
|
|
1320
|
+
currentAssessment?.diagnosis?.evidenceMalformed ||
|
|
1321
|
+
currentAssessment?.diagnosis?.omittedEntries)
|
|
1317
1322
|
? assessmentSourcesNode
|
|
1318
1323
|
: undefined;
|
|
1319
1324
|
const assessmentLimits = useMemo(() => {
|
|
@@ -1345,8 +1350,7 @@ export function InvestigationView({
|
|
|
1345
1350
|
);
|
|
1346
1351
|
// While the first assessment is still running the pane keeps the story
|
|
1347
1352
|
// shape, so the page fills in rather than rearranging when the verdict lands.
|
|
1348
|
-
const storyShell =
|
|
1349
|
-
!hosted && !currentAssessment && lastTurn?.status === "running";
|
|
1353
|
+
const storyShell = !currentAssessment && lastTurn?.status === "running";
|
|
1350
1354
|
const currentAssessmentEvidenceConflict =
|
|
1351
1355
|
currentAssessment?.diagnosis?.healthy === true &&
|
|
1352
1356
|
investigationEvidenceConflictsWithHealthy(projection);
|
|
@@ -1660,7 +1664,7 @@ export function InvestigationView({
|
|
|
1660
1664
|
lastApplyAttemptIdx,
|
|
1661
1665
|
localApplyAttemptAssessmentIdx,
|
|
1662
1666
|
interactionsBlocked,
|
|
1663
|
-
|
|
1667
|
+
canApply,
|
|
1664
1668
|
hasNewerEvidence: hasEvidenceCollectedAfterAssessment,
|
|
1665
1669
|
})
|
|
1666
1670
|
? requestApply
|
|
@@ -1966,7 +1970,8 @@ export function InvestigationView({
|
|
|
1966
1970
|
return turnCase.items.length ||
|
|
1967
1971
|
turnResolution?.links.length ||
|
|
1968
1972
|
turn.diagnosis.unlinkedEvidence ||
|
|
1969
|
-
turn.diagnosis.evidenceMalformed
|
|
1973
|
+
turn.diagnosis.evidenceMalformed ||
|
|
1974
|
+
turn.diagnosis.omittedEntries ? (
|
|
1970
1975
|
<AssessmentSources
|
|
1971
1976
|
renderedGroupIds={visibleEvidenceGroupIds}
|
|
1972
1977
|
resolution={turnResolution}
|
|
@@ -1977,6 +1982,7 @@ export function InvestigationView({
|
|
|
1977
1982
|
evidenceMalformed={
|
|
1978
1983
|
turn.diagnosis.evidenceMalformed
|
|
1979
1984
|
}
|
|
1985
|
+
omittedEntries={turn.diagnosis.omittedEntries}
|
|
1980
1986
|
readOnly
|
|
1981
1987
|
onViewSource={viewActivitySource}
|
|
1982
1988
|
/>
|
|
@@ -2356,12 +2362,14 @@ export function InvestigationView({
|
|
|
2356
2362
|
story={
|
|
2357
2363
|
storyShape && currentAssessment?.diagnosis
|
|
2358
2364
|
? {
|
|
2365
|
+
summary: currentAssessment.diagnosis.summary,
|
|
2359
2366
|
report: currentAssessment.diagnosis.report,
|
|
2360
2367
|
evidence: currentAssessment.diagnosis.evidence,
|
|
2361
2368
|
}
|
|
2362
2369
|
: undefined
|
|
2363
2370
|
}
|
|
2364
2371
|
storyShell={storyShell}
|
|
2372
|
+
summarizedLimits={storyShape ? assessmentLimits : undefined}
|
|
2365
2373
|
collecting={
|
|
2366
2374
|
explanationRequest?.status !== "running" &&
|
|
2367
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();
|
|
@@ -1072,6 +1073,33 @@ describe("agent case placement (D-1, D-1b)", () => {
|
|
|
1072
1073
|
]);
|
|
1073
1074
|
});
|
|
1074
1075
|
|
|
1076
|
+
it.each(["issue", "issues"])(
|
|
1077
|
+
"pins a %s citation to the issue observation",
|
|
1078
|
+
(observation) => {
|
|
1079
|
+
const resolved = resolveInvestigationCase(
|
|
1080
|
+
projection,
|
|
1081
|
+
{
|
|
1082
|
+
evidence: [
|
|
1083
|
+
linked(ref, "symptom", "The issue is the symptom.", {
|
|
1084
|
+
kind: "Deployment",
|
|
1085
|
+
group: "apps",
|
|
1086
|
+
namespace: "shop",
|
|
1087
|
+
name: "api",
|
|
1088
|
+
observation,
|
|
1089
|
+
}),
|
|
1090
|
+
],
|
|
1091
|
+
},
|
|
1092
|
+
0,
|
|
1093
|
+
);
|
|
1094
|
+
expect(resolved.items[0].placement).toBe("card");
|
|
1095
|
+
expect(
|
|
1096
|
+
projection.groups.find(
|
|
1097
|
+
(group) => group.id === resolved.items[0].groupId,
|
|
1098
|
+
)?.kind,
|
|
1099
|
+
).toBe("issue");
|
|
1100
|
+
},
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1075
1103
|
it("pins a subject-bearing item to exactly the observation it names", () => {
|
|
1076
1104
|
const resolved = resolveInvestigationCase(
|
|
1077
1105
|
projection,
|
|
@@ -1187,6 +1215,61 @@ describe("agent case placement (D-1, D-1b)", () => {
|
|
|
1187
1215
|
]);
|
|
1188
1216
|
});
|
|
1189
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
|
+
|
|
1190
1273
|
it("binds to the exact earlier read and renders on its revision row, not the card head", () => {
|
|
1191
1274
|
const first = evidenceRef("a", "c");
|
|
1192
1275
|
const second = evidenceRef("a", "d");
|
|
@@ -54,7 +54,7 @@ export interface InvestigationCaseResolution {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* One half of a Go↔TS contract: `
|
|
57
|
+
* One half of a Go↔TS contract: `Roles` in pkg/investigation/verdict.go and the
|
|
58
58
|
* DiagnosisEvidenceRole union in api/diagnose.ts must list exactly these roles.
|
|
59
59
|
* A role the parser accepts but this set omits is bound server-side and then
|
|
60
60
|
* silently dropped here. Change all three together.
|
|
@@ -388,7 +388,8 @@ function observationMatchesSubject(
|
|
|
388
388
|
// "metrics" alone cannot name one; "metrics:<category>" picks the chart
|
|
389
389
|
// whose identity ends in that category. An agent-run query is one chart,
|
|
390
390
|
// so a qualifier on it carries no meaning.
|
|
391
|
-
const [
|
|
391
|
+
const [word, qualifier] = subject.observation.toLowerCase().split(":", 2);
|
|
392
|
+
const kind = word === "issues" ? "issue" : word;
|
|
392
393
|
if (
|
|
393
394
|
kind !== observation.data.type &&
|
|
394
395
|
!(kind === "resource" && RESOURCE_SHAPED.has(observation.data.type))
|
|
@@ -434,6 +434,28 @@ describe("posture scans that did not finish", () => {
|
|
|
434
434
|
});
|
|
435
435
|
|
|
436
436
|
describe("listing cards (helm releases, packages, search)", () => {
|
|
437
|
+
it.each([null, []])("accepts an empty Helm result: %j", (payload) => {
|
|
438
|
+
const projection = project([
|
|
439
|
+
tool("helm", "list_helm_releases", payload, {
|
|
440
|
+
summary: JSON.stringify({ namespace: "shop" }),
|
|
441
|
+
}),
|
|
442
|
+
]);
|
|
443
|
+
expect(projection.groups).toHaveLength(0);
|
|
444
|
+
expect(projection.limitations).toEqual([
|
|
445
|
+
expect.objectContaining({
|
|
446
|
+
source: "Resource inventory",
|
|
447
|
+
kind: "unknown",
|
|
448
|
+
}),
|
|
449
|
+
]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it.each([{}, "", 0])("rejects malformed Helm results: %j", (payload) => {
|
|
453
|
+
const projection = project([tool("helm", "list_helm_releases", payload)]);
|
|
454
|
+
expect(projection.limitations).toEqual([
|
|
455
|
+
expect.objectContaining({ source: "Helm releases", kind: "unknown" }),
|
|
456
|
+
]);
|
|
457
|
+
});
|
|
458
|
+
|
|
437
459
|
it("lists Helm releases with their status and chart", () => {
|
|
438
460
|
const projection = project([
|
|
439
461
|
tool(
|
|
@@ -264,7 +264,7 @@ describe("diagnose metrics evidence", () => {
|
|
|
264
264
|
expect(groups.map((group) => group.latest.title)).toEqual([
|
|
265
265
|
"CPU usage · Deployment shop/api",
|
|
266
266
|
"Memory working set · Deployment shop/api",
|
|
267
|
-
"Restarts · Deployment shop/api",
|
|
267
|
+
"Restarts in trailing 1h · Deployment shop/api",
|
|
268
268
|
]);
|
|
269
269
|
for (const group of groups) {
|
|
270
270
|
expect(group.latest.relevance).toBe("target");
|
|
@@ -289,6 +289,12 @@ describe("diagnose metrics evidence", () => {
|
|
|
289
289
|
expect(investigationEvidenceSubjectRef(data)).toEqual(data.subject);
|
|
290
290
|
}
|
|
291
291
|
const [cpu, , restarts] = groups;
|
|
292
|
+
expect(restarts.latest.title).toContain("Restarts in trailing 1h");
|
|
293
|
+
expect(restarts.latest.data).toMatchObject({
|
|
294
|
+
type: "metrics",
|
|
295
|
+
label: "Restarts in trailing 1h",
|
|
296
|
+
unit: "count",
|
|
297
|
+
});
|
|
292
298
|
expect(cpu.latest.summary).toBe("2 current pods · 60m window · 1m2s step");
|
|
293
299
|
const cpuData = cpu.latest.data;
|
|
294
300
|
if (cpuData.type !== "metrics") throw new Error("expected metrics");
|
|
@@ -562,7 +568,7 @@ describe("diagnose metrics evidence", () => {
|
|
|
562
568
|
]);
|
|
563
569
|
const groups = groupsOf(projection.groups, "metrics");
|
|
564
570
|
expect(groups.map((group) => group.latest.title)).toEqual([
|
|
565
|
-
"Restarts · Deployment shop/api",
|
|
571
|
+
"Restarts in trailing 1h · Deployment shop/api",
|
|
566
572
|
]);
|
|
567
573
|
expect(
|
|
568
574
|
projection.limitations.some(
|
|
@@ -821,7 +821,7 @@ export function adaptDiagnose(
|
|
|
821
821
|
const DIAGNOSE_METRICS_LABELS: Record<string, string> = {
|
|
822
822
|
cpu: "CPU usage",
|
|
823
823
|
memory: "Memory working set",
|
|
824
|
-
restarts: "Restarts",
|
|
824
|
+
restarts: "Restarts in trailing 1h",
|
|
825
825
|
};
|
|
826
826
|
/**
|
|
827
827
|
* The vitals `diagnose` captured inside the same call: one chart per category
|
|
@@ -13,6 +13,8 @@ export function adaptListHelmReleases(
|
|
|
13
13
|
source: InvestigationEvidenceSource,
|
|
14
14
|
payload: unknown,
|
|
15
15
|
): void {
|
|
16
|
+
// Retained runs may contain the Helm producer's nil-slice encoding.
|
|
17
|
+
if (payload === null) payload = [];
|
|
16
18
|
if (!Array.isArray(payload)) {
|
|
17
19
|
invalidPayload(builder, source);
|
|
18
20
|
return;
|
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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"}
|
|
@@ -193,6 +193,8 @@ const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
|
|
|
193
193
|
list_packages: "Installed packages",
|
|
194
194
|
search: "Search results",
|
|
195
195
|
discover_metrics: "Metric discovery",
|
|
196
|
+
get_cost: "Cost",
|
|
197
|
+
get_rightsizing: "Rightsizing",
|
|
196
198
|
};
|
|
197
199
|
export function investigationResultLabel(
|
|
198
200
|
source: InvestigationEvidenceSource,
|
|
@@ -910,19 +912,20 @@ export function changesSubjectFromArgs(
|
|
|
910
912
|
export function addLogs(
|
|
911
913
|
builder: ProjectionBuilder,
|
|
912
914
|
source: InvestigationEvidenceSource,
|
|
913
|
-
value: DiagnosisPodLogEntry,
|
|
915
|
+
value: Omit<DiagnosisPodLogEntry, "container"> & { container?: string },
|
|
914
916
|
previous: boolean,
|
|
915
917
|
warnings: string[] = [],
|
|
916
918
|
relevance: InvestigationEvidenceRelevance = "broader",
|
|
917
919
|
namespace?: string,
|
|
918
920
|
): void {
|
|
919
921
|
const lines = (value.logs?.lines ?? []).map((line) => stripAnsi(line));
|
|
922
|
+
const containerLabel = value.container ?? "container unknown";
|
|
920
923
|
const normalizedWarnings = warnings.map((warning) => stripAnsi(warning));
|
|
921
924
|
const normalizedError = value.error ? stripAnsi(value.error) : undefined;
|
|
922
925
|
if (lines.length === 0) {
|
|
923
926
|
builder.limit(
|
|
924
927
|
source,
|
|
925
|
-
`${value.pod} / ${
|
|
928
|
+
`${value.pod} / ${containerLabel}`,
|
|
926
929
|
value.error ||
|
|
927
930
|
"No log lines were available. This does not mean the container is healthy.",
|
|
928
931
|
value.error ? "error" : "unknown",
|
|
@@ -939,7 +942,9 @@ export function addLogs(
|
|
|
939
942
|
),
|
|
940
943
|
);
|
|
941
944
|
const selectedEvidence = value.logs?.fallback !== true && diagnosticSignal;
|
|
942
|
-
|
|
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}`;
|
|
943
948
|
builder.observe(identity, "logs", source, {
|
|
944
949
|
// FilterLogs' raw-tail fallback is useful provenance, but the producer did
|
|
945
950
|
// not select it as diagnostic signal. Keep it in Context; only filtered
|
|
@@ -950,7 +955,7 @@ export function addLogs(
|
|
|
950
955
|
),
|
|
951
956
|
relevance,
|
|
952
957
|
tone: selectedEvidence || normalizedError ? "warning" : "neutral",
|
|
953
|
-
title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${
|
|
958
|
+
title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${containerLabel}`,
|
|
954
959
|
summary:
|
|
955
960
|
lines.length > 0
|
|
956
961
|
? `${lines.length} selected line${lines.length === 1 ? "" : "s"}`
|
|
@@ -969,7 +974,7 @@ export function addLogs(
|
|
|
969
974
|
if (normalizedError) {
|
|
970
975
|
builder.limit(
|
|
971
976
|
source,
|
|
972
|
-
`${value.pod} / ${
|
|
977
|
+
`${value.pod} / ${containerLabel}`,
|
|
973
978
|
normalizedError,
|
|
974
979
|
"error",
|
|
975
980
|
);
|
|
@@ -407,7 +407,7 @@ export type InvestigationEvidenceData =
|
|
|
407
407
|
| {
|
|
408
408
|
type: "logs";
|
|
409
409
|
pod: string;
|
|
410
|
-
container
|
|
410
|
+
container?: string;
|
|
411
411
|
/** Namespace the producing call actually read; absent when unstated. */
|
|
412
412
|
namespace?: string;
|
|
413
413
|
previous: boolean;
|
|
@@ -611,14 +611,14 @@ describe("investigation action gating", () => {
|
|
|
611
611
|
lastApplyAttemptIdx: -1,
|
|
612
612
|
localApplyAttemptAssessmentIdx: -1,
|
|
613
613
|
interactionsBlocked: false,
|
|
614
|
-
|
|
614
|
+
canApply: true,
|
|
615
615
|
hasNewerEvidence: false,
|
|
616
616
|
};
|
|
617
617
|
expect(canOfferInvestigationApply(base)).toBe(true);
|
|
618
618
|
expect(
|
|
619
619
|
canOfferInvestigationApply({ ...base, hasNewerEvidence: true }),
|
|
620
620
|
).toBe(false);
|
|
621
|
-
expect(canOfferInvestigationApply({ ...base,
|
|
621
|
+
expect(canOfferInvestigationApply({ ...base, canApply: false })).toBe(false);
|
|
622
622
|
expect(
|
|
623
623
|
canOfferInvestigationApply({ ...base, interactionsBlocked: true }),
|
|
624
624
|
).toBe(false);
|
|
@@ -76,7 +76,7 @@ export function canOfferInvestigationApply(input: {
|
|
|
76
76
|
lastApplyAttemptIdx: number;
|
|
77
77
|
localApplyAttemptAssessmentIdx: number;
|
|
78
78
|
interactionsBlocked: boolean;
|
|
79
|
-
|
|
79
|
+
canApply: boolean;
|
|
80
80
|
hasNewerEvidence: boolean;
|
|
81
81
|
}): boolean {
|
|
82
82
|
return (
|
|
@@ -87,7 +87,7 @@ export function canOfferInvestigationApply(input: {
|
|
|
87
87
|
input.localApplyAttemptAssessmentIdx,
|
|
88
88
|
) &&
|
|
89
89
|
!input.interactionsBlocked &&
|
|
90
|
-
|
|
90
|
+
input.canApply &&
|
|
91
91
|
!input.hasNewerEvidence
|
|
92
92
|
);
|
|
93
93
|
}
|