@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
|
@@ -33,6 +33,8 @@ export interface InvestigationCaseItem {
|
|
|
33
33
|
index: number;
|
|
34
34
|
role: DiagnosisEvidenceRole;
|
|
35
35
|
claim: string;
|
|
36
|
+
/** What the agent says this result does not cover. */
|
|
37
|
+
gap?: string;
|
|
36
38
|
subject?: DiagnosisEvidenceSubject;
|
|
37
39
|
source: InvestigationEvidenceSource;
|
|
38
40
|
placement: InvestigationCasePlacement;
|
|
@@ -51,65 +53,6 @@ export interface InvestigationCaseResolution {
|
|
|
51
53
|
ruledOut: InvestigationCaseRuledOut[];
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
/**
|
|
55
|
-
* Notes the agent put on cards in earlier assessments stay on those cards
|
|
56
|
-
* when a later turn takes over the pane's case, unless the later turn
|
|
57
|
-
* addressed the same card; a follow-up about one chart must not strip the
|
|
58
|
-
* initial assessment's reading from everything else. Ordering and the
|
|
59
|
-
* ruled-out list follow the live turn alone, and source-placed notes stay
|
|
60
|
-
* listed under their own assessment. `earlier` is newest first.
|
|
61
|
-
*/
|
|
62
|
-
export function mergeInvestigationCases(
|
|
63
|
-
live: InvestigationCaseResolution | undefined,
|
|
64
|
-
earlier: readonly (InvestigationCaseResolution | undefined)[],
|
|
65
|
-
): InvestigationCaseResolution | undefined {
|
|
66
|
-
const liveItems = live?.items ?? [];
|
|
67
|
-
const covered = new Set(
|
|
68
|
-
liveItems.flatMap((item) => (item.groupId ? [item.groupId] : [])),
|
|
69
|
-
);
|
|
70
|
-
const carried: InvestigationCaseItem[] = [];
|
|
71
|
-
for (const resolution of earlier) {
|
|
72
|
-
// The newest assessment that spoke about a group wins it outright, and it
|
|
73
|
-
// wins with everything it said: a card note and a pinned revision note are
|
|
74
|
-
// two readings of one group, not rivals. Coverage is therefore taken after
|
|
75
|
-
// the whole resolution, not as each of its items is carried.
|
|
76
|
-
const takenHere = new Set<string>();
|
|
77
|
-
for (const item of resolution?.items ?? []) {
|
|
78
|
-
if (item.placement === "source" || !item.groupId) continue;
|
|
79
|
-
if (covered.has(item.groupId)) continue;
|
|
80
|
-
takenHere.add(item.groupId);
|
|
81
|
-
carried.push(item);
|
|
82
|
-
}
|
|
83
|
-
for (const groupId of takenHere) covered.add(groupId);
|
|
84
|
-
}
|
|
85
|
-
if (carried.length === 0) return live;
|
|
86
|
-
return { items: [...liveItems, ...carried], ruledOut: live?.ruledOut ?? [] };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* The subset of one assessment's items that survived into the case the pane
|
|
91
|
-
* actually renders. Matching is by value, not object identity: the merge is
|
|
92
|
-
* fed freshly resolved copies of every turn, so an identity test would report
|
|
93
|
-
* that an assessment's own note had vanished the moment any other turn was
|
|
94
|
-
* re-resolved.
|
|
95
|
-
*/
|
|
96
|
-
export function investigationCaseItemsStillRendered(
|
|
97
|
-
assessmentItems: readonly InvestigationCaseItem[] | undefined,
|
|
98
|
-
renderedItems: readonly InvestigationCaseItem[] | undefined,
|
|
99
|
-
): InvestigationCaseItem[] {
|
|
100
|
-
if (!assessmentItems?.length || !renderedItems?.length) return [];
|
|
101
|
-
const key = (item: InvestigationCaseItem) =>
|
|
102
|
-
[
|
|
103
|
-
item.index,
|
|
104
|
-
item.source.id,
|
|
105
|
-
item.placement,
|
|
106
|
-
item.groupId ?? "",
|
|
107
|
-
item.observation ? investigationCaseObservationKey(item.observation) : "",
|
|
108
|
-
].join("\u0000");
|
|
109
|
-
const rendered = new Set(renderedItems.map(key));
|
|
110
|
-
return assessmentItems.filter((item) => rendered.has(key(item)));
|
|
111
|
-
}
|
|
112
|
-
|
|
113
56
|
/**
|
|
114
57
|
* One half of a Go↔TS contract: `evidenceRoles` in internal/ai/parse.go and the
|
|
115
58
|
* DiagnosisEvidenceRole union in api/diagnose.ts must list exactly these roles.
|
|
@@ -134,10 +77,12 @@ export function investigationCaseObservationKey(
|
|
|
134
77
|
|
|
135
78
|
/**
|
|
136
79
|
* Resolves the agent's case for one assessment. Refs are re-validated against
|
|
137
|
-
* the assessment turn exactly like root-cause
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
80
|
+
* the run up to and including the assessment turn, exactly like root-cause
|
|
81
|
+
* refs — a revised assessment may rest on a result read in an earlier turn,
|
|
82
|
+
* and the observation it resolves to is that earlier read, never a later one.
|
|
83
|
+
* An item whose ref does not resolve is dropped on its own. Placement follows
|
|
84
|
+
* the observation the (ref, subject) pair names: exactly one match pins the
|
|
85
|
+
* claim to that observation, anything else falls back to the source row.
|
|
141
86
|
*/
|
|
142
87
|
export function resolveInvestigationCase(
|
|
143
88
|
projection: InvestigationEvidenceProjection,
|
|
@@ -149,17 +94,17 @@ export function resolveInvestigationCase(
|
|
|
149
94
|
): InvestigationCaseResolution {
|
|
150
95
|
const evidence = diagnosis?.evidence ?? [];
|
|
151
96
|
if (evidence.length === 0) return { items: [], ruledOut: [] };
|
|
97
|
+
const targetPods = new Set(projection.targetPods);
|
|
152
98
|
const byRef = new Map<string, InvestigationEvidenceSource[]>();
|
|
153
99
|
for (const source of projection.evidenceRefSources) {
|
|
154
|
-
if (source.turnIndex
|
|
155
|
-
continue;
|
|
100
|
+
if (source.turnIndex > assessmentTurnIndex || !source.evidenceRef) continue;
|
|
156
101
|
const matches = byRef.get(source.evidenceRef) ?? [];
|
|
157
102
|
matches.push(source);
|
|
158
103
|
byRef.set(source.evidenceRef, matches);
|
|
159
104
|
}
|
|
160
105
|
const citableSourceIds = new Set(
|
|
161
106
|
projection.citableSources
|
|
162
|
-
.filter((source) => source.turnIndex
|
|
107
|
+
.filter((source) => source.turnIndex <= assessmentTurnIndex)
|
|
163
108
|
.map((source) => source.id),
|
|
164
109
|
);
|
|
165
110
|
const items: InvestigationCaseItem[] = [];
|
|
@@ -188,6 +133,9 @@ export function resolveInvestigationCase(
|
|
|
188
133
|
index,
|
|
189
134
|
role: entry.role,
|
|
190
135
|
claim: typeof entry.claim === "string" ? entry.claim.trim() : "",
|
|
136
|
+
...(typeof entry.gap === "string" && entry.gap.trim()
|
|
137
|
+
? { gap: entry.gap.trim() }
|
|
138
|
+
: {}),
|
|
191
139
|
...(subject ? { subject } : {}),
|
|
192
140
|
source,
|
|
193
141
|
placement: "source",
|
|
@@ -200,7 +148,12 @@ export function resolveInvestigationCase(
|
|
|
200
148
|
(observation) =>
|
|
201
149
|
observation.source.id === source.id &&
|
|
202
150
|
(!subject ||
|
|
203
|
-
observationMatchesSubject(
|
|
151
|
+
observationMatchesSubject(
|
|
152
|
+
group,
|
|
153
|
+
observation,
|
|
154
|
+
subject,
|
|
155
|
+
targetPods,
|
|
156
|
+
)),
|
|
204
157
|
)
|
|
205
158
|
.map((observation) => ({ group, observation })),
|
|
206
159
|
);
|
|
@@ -241,11 +194,11 @@ export function resolveInvestigationCase(
|
|
|
241
194
|
function validCaseSubject(
|
|
242
195
|
value: DiagnosisEvidenceSubject | undefined,
|
|
243
196
|
): DiagnosisEvidenceSubject | undefined {
|
|
244
|
-
if (!value || !nonEmptyString(value.kind)
|
|
245
|
-
return undefined;
|
|
197
|
+
if (!value || !nonEmptyString(value.kind)) return undefined;
|
|
246
198
|
const optional = (field: unknown) =>
|
|
247
199
|
field === undefined || typeof field === "string";
|
|
248
200
|
if (
|
|
201
|
+
!optional(value.name) ||
|
|
249
202
|
!optional(value.group) ||
|
|
250
203
|
!optional(value.namespace) ||
|
|
251
204
|
!optional(value.container) ||
|
|
@@ -268,6 +221,8 @@ interface ObservationSubjectIdentity {
|
|
|
268
221
|
name: string;
|
|
269
222
|
container?: string;
|
|
270
223
|
stream?: "current" | "previous";
|
|
224
|
+
/** A listing names a kind (and scope) but no single object. */
|
|
225
|
+
listing?: boolean;
|
|
271
226
|
}
|
|
272
227
|
|
|
273
228
|
/**
|
|
@@ -319,17 +274,44 @@ function observationSubjectIdentity(
|
|
|
319
274
|
};
|
|
320
275
|
}
|
|
321
276
|
const args = investigationSourceArgs(observation.source);
|
|
322
|
-
|
|
323
|
-
|
|
277
|
+
// A listing whose call names no kind (list_namespaces) is still a listing
|
|
278
|
+
// of the one kind its rows carry.
|
|
279
|
+
if (data.type === "inventory" && !nonEmptyString(args?.kind)) {
|
|
280
|
+
const kinds = new Set(data.resources.map((resource) => resource.kind));
|
|
281
|
+
if (kinds.size !== 1) return undefined;
|
|
282
|
+
return {
|
|
283
|
+
kind: [...kinds][0],
|
|
284
|
+
namespace: nonEmptyString(args?.namespace) ? args.namespace : undefined,
|
|
285
|
+
name: "",
|
|
286
|
+
listing: true,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (!args || !nonEmptyString(args.kind)) return undefined;
|
|
324
290
|
return {
|
|
325
291
|
kind: args.kind,
|
|
326
292
|
group: nonEmptyString(args.group) ? args.group : undefined,
|
|
327
293
|
namespace: nonEmptyString(args.namespace) ? args.namespace : undefined,
|
|
328
|
-
name: args.name,
|
|
294
|
+
name: nonEmptyString(args.name) ? args.name : "",
|
|
295
|
+
...(nonEmptyString(args.name) ? {} : { listing: true }),
|
|
329
296
|
};
|
|
330
297
|
}
|
|
331
298
|
|
|
332
|
-
|
|
299
|
+
// No CRD lives in a built-in API group: the core group under its three
|
|
300
|
+
// spellings, the four legacy unsuffixed groups, or any group under k8s.io.
|
|
301
|
+
const LEGACY_BUILT_IN_GROUPS = new Set([
|
|
302
|
+
"",
|
|
303
|
+
"core",
|
|
304
|
+
"v1",
|
|
305
|
+
"apps",
|
|
306
|
+
"batch",
|
|
307
|
+
"autoscaling",
|
|
308
|
+
"policy",
|
|
309
|
+
]);
|
|
310
|
+
function isBuiltInGroup(group: string): boolean {
|
|
311
|
+
return LEGACY_BUILT_IN_GROUPS.has(group) || group.endsWith(".k8s.io");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function sameKind(left: string, right: string): boolean {
|
|
333
315
|
return pluralToKind(left).toLowerCase() === pluralToKind(right).toLowerCase();
|
|
334
316
|
}
|
|
335
317
|
|
|
@@ -342,6 +324,7 @@ function observationMatchesSubject(
|
|
|
342
324
|
group: InvestigationEvidenceGroup,
|
|
343
325
|
observation: InvestigationEvidenceObservation,
|
|
344
326
|
subject: DiagnosisEvidenceSubject,
|
|
327
|
+
targetPods: ReadonlySet<string>,
|
|
345
328
|
): boolean {
|
|
346
329
|
if (subject.observation !== undefined) {
|
|
347
330
|
// A diagnose bundle captures several vitals charts for one resource, so
|
|
@@ -349,7 +332,10 @@ function observationMatchesSubject(
|
|
|
349
332
|
// whose identity ends in that category. An agent-run query is one chart,
|
|
350
333
|
// so a qualifier on it carries no meaning.
|
|
351
334
|
const [kind, qualifier] = subject.observation.toLowerCase().split(":", 2);
|
|
352
|
-
|
|
335
|
+
// A listing is resources too: "resource" names an inventory card as well.
|
|
336
|
+
const inventoryAsResource =
|
|
337
|
+
kind === "resource" && observation.data.type === "inventory";
|
|
338
|
+
if (kind !== observation.data.type && !inventoryAsResource) return false;
|
|
353
339
|
if (
|
|
354
340
|
qualifier !== undefined &&
|
|
355
341
|
observation.data.type === "metrics" &&
|
|
@@ -359,6 +345,10 @@ function observationMatchesSubject(
|
|
|
359
345
|
return false;
|
|
360
346
|
}
|
|
361
347
|
}
|
|
348
|
+
// One rules result holds many rules and none states a resource; the rule's
|
|
349
|
+
// name is the handle that tells them apart.
|
|
350
|
+
if (observation.data.type === "alerts" && subject.name !== undefined)
|
|
351
|
+
return observation.data.rule.name === subject.name;
|
|
362
352
|
const identities = observationIdentities(observation);
|
|
363
353
|
// An observation that states no resource of its own (an agent-run query
|
|
364
354
|
// whose selectors do not name the target exactly, a topology summary) can
|
|
@@ -366,7 +356,7 @@ function observationMatchesSubject(
|
|
|
366
356
|
// the rest.
|
|
367
357
|
if (identities.length === 0) return subject.observation !== undefined;
|
|
368
358
|
return identities.some((identity) =>
|
|
369
|
-
identityMatchesSubject(identity, observation, subject),
|
|
359
|
+
identityMatchesSubject(identity, observation, subject, targetPods),
|
|
370
360
|
);
|
|
371
361
|
}
|
|
372
362
|
|
|
@@ -397,17 +387,72 @@ function observationIdentities(
|
|
|
397
387
|
return identities;
|
|
398
388
|
}
|
|
399
389
|
|
|
390
|
+
// Pod names are DNS subdomains (letters, digits, hyphens, dots), so a name
|
|
391
|
+
// is a whole word between characters that cannot be part of one; "api"
|
|
392
|
+
// inside "api-other" names another Pod.
|
|
393
|
+
function namesPod(text: string, name: string): boolean {
|
|
394
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
395
|
+
return new RegExp(`(^|[^A-Za-z0-9.-])${escaped}(?![A-Za-z0-9.-])`).test(text);
|
|
396
|
+
}
|
|
397
|
+
|
|
400
398
|
function identityMatchesSubject(
|
|
401
399
|
identity: ObservationSubjectIdentity,
|
|
402
400
|
observation: InvestigationEvidenceObservation,
|
|
403
401
|
subject: DiagnosisEvidenceSubject,
|
|
402
|
+
targetPods: ReadonlySet<string>,
|
|
404
403
|
): boolean {
|
|
405
|
-
|
|
404
|
+
// "The events of namespace X" names a namespace-scoped read whose
|
|
405
|
+
// observation inherits the resource its call was asked about; the agent
|
|
406
|
+
// naming the namespace instead is not wrong, so a stated evidence kind plus
|
|
407
|
+
// a matching namespace is enough for that shape.
|
|
408
|
+
if (
|
|
409
|
+
subject.observation !== undefined &&
|
|
410
|
+
sameKind(subject.kind, "Namespace") &&
|
|
411
|
+
identity.namespace !== undefined &&
|
|
412
|
+
identity.namespace === subject.name
|
|
413
|
+
)
|
|
414
|
+
return true;
|
|
415
|
+
// A workload read collects its pods' events, so "the events of Pod X" names
|
|
416
|
+
// the events observation a diagnose bundle or workload call inherited from
|
|
417
|
+
// the workload; the ref already fixes the call, uniqueness fixes the card.
|
|
418
|
+
if (
|
|
419
|
+
subject.observation !== undefined &&
|
|
420
|
+
observation.data.type === "events" &&
|
|
421
|
+
sameKind(subject.kind, "Pod") &&
|
|
422
|
+
!sameKind(identity.kind, "Pod") &&
|
|
423
|
+
(identity.namespace === undefined ||
|
|
424
|
+
subject.namespace === undefined ||
|
|
425
|
+
identity.namespace === subject.namespace) &&
|
|
426
|
+
// The Pod has to be the target's own on events the target's read produced,
|
|
427
|
+
// or named whole by an event: a Pod nothing here establishes is not this card.
|
|
428
|
+
(subject.name === undefined ||
|
|
429
|
+
(targetPods.has(subject.name) && observation.relevance !== "broader") ||
|
|
430
|
+
observation.data.events.some((event) =>
|
|
431
|
+
namesPod(event.message, subject.name!),
|
|
432
|
+
))
|
|
433
|
+
)
|
|
434
|
+
return true;
|
|
435
|
+
if (!sameKind(identity.kind, subject.kind)) return false;
|
|
436
|
+
// A subject with no name (a listing cited for what it does not contain) is
|
|
437
|
+
// a wildcard that uniqueness still gates. A listing has no name of its own; the agent naming the entry it means
|
|
438
|
+
// ("ConfigMap kube-root-ca.crt" in the ConfigMaps of a namespace) still
|
|
439
|
+
// points at that listing, whether or not the listing holds the entry: an
|
|
440
|
+
// absent entry is often the point, and the card says which it is.
|
|
441
|
+
if (
|
|
442
|
+
!identity.listing &&
|
|
443
|
+
subject.name !== undefined &&
|
|
444
|
+
identity.name !== subject.name
|
|
445
|
+
)
|
|
406
446
|
return false;
|
|
447
|
+
// A built-in group on a core kind ("apps" on a Pod) is the agent
|
|
448
|
+
// misremembering the API, not naming a different object: no CRD lives in a
|
|
449
|
+
// built-in group, so nothing else could be meant. A vendor group on a core
|
|
450
|
+
// kind (a Knative Service) still names a different object.
|
|
407
451
|
if (
|
|
408
452
|
subject.group !== undefined &&
|
|
409
453
|
identity.group !== undefined &&
|
|
410
|
-
subject.group.toLowerCase() !== identity.group.toLowerCase()
|
|
454
|
+
subject.group.toLowerCase() !== identity.group.toLowerCase() &&
|
|
455
|
+
!(identity.group === "" && isBuiltInGroup(subject.group.toLowerCase()))
|
|
411
456
|
) {
|
|
412
457
|
return false;
|
|
413
458
|
}
|
|
@@ -27,6 +27,33 @@ describe("resource cards scaled by an HPA", () => {
|
|
|
27
27
|
},
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
it("states a workload's readiness from the resource when the read carried no context", () => {
|
|
31
|
+
const result = project([
|
|
32
|
+
tool(
|
|
33
|
+
"res",
|
|
34
|
+
"get_resource",
|
|
35
|
+
{
|
|
36
|
+
resource: {
|
|
37
|
+
apiVersion: "apps/v1",
|
|
38
|
+
kind: "Deployment",
|
|
39
|
+
metadata: { namespace: "shop", name: "api" },
|
|
40
|
+
spec: { replicas: 1 },
|
|
41
|
+
status: { replicas: 1, readyReplicas: 0 },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
summary: JSON.stringify({
|
|
46
|
+
kind: "deployment",
|
|
47
|
+
namespace: "shop",
|
|
48
|
+
name: "api",
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
),
|
|
52
|
+
]);
|
|
53
|
+
const card = groupsOf(result.groups, "resource")[0].latest;
|
|
54
|
+
expect(card.summary).toBe("0/1 replicas ready");
|
|
55
|
+
});
|
|
56
|
+
|
|
30
57
|
it("lifts a healthy workload whose autoscaler cannot act", () => {
|
|
31
58
|
const result = project([scaled("metrics_unavailable")]);
|
|
32
59
|
const card = groupsOf(result.groups, "resource")[0].latest;
|
|
@@ -198,6 +198,34 @@ export function adaptGetResource(
|
|
|
198
198
|
);
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
|
+
// A namespace listing is an inventory like any other; its entries carry no
|
|
202
|
+
// kind of their own, so the card gets one.
|
|
203
|
+
export function adaptListNamespaces(
|
|
204
|
+
builder: ProjectionBuilder,
|
|
205
|
+
source: InvestigationEvidenceSource,
|
|
206
|
+
payload: unknown,
|
|
207
|
+
): void {
|
|
208
|
+
if (!Array.isArray(payload)) {
|
|
209
|
+
invalidPayload(builder, source);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
adaptListResources(
|
|
213
|
+
builder,
|
|
214
|
+
source,
|
|
215
|
+
payload.map((entry) => {
|
|
216
|
+
const item = record(entry);
|
|
217
|
+
return item
|
|
218
|
+
? {
|
|
219
|
+
kind: "Namespace",
|
|
220
|
+
name: item.name,
|
|
221
|
+
status: item.status,
|
|
222
|
+
terminating: item.status === "Terminating",
|
|
223
|
+
}
|
|
224
|
+
: entry;
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
201
229
|
export function adaptListResources(
|
|
202
230
|
builder: ProjectionBuilder,
|
|
203
231
|
source: InvestigationEvidenceSource,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
apiVersionToGroup,
|
|
3
|
+
knownKindForPluralWithGroup,
|
|
4
|
+
} from "../../../utils/navigation";
|
|
2
5
|
import { adaptChanges } from "./adapters/changes";
|
|
3
6
|
import { adaptDiagnose } from "./adapters/diagnose";
|
|
4
7
|
import { adaptEvents } from "./adapters/events";
|
|
@@ -10,7 +13,11 @@ import {
|
|
|
10
13
|
adaptPrometheusRules,
|
|
11
14
|
adaptQueryPrometheus,
|
|
12
15
|
} from "./adapters/prometheus";
|
|
13
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
adaptGetResource,
|
|
18
|
+
adaptListResources,
|
|
19
|
+
adaptListNamespaces,
|
|
20
|
+
} from "./adapters/resource";
|
|
14
21
|
import { adaptNeighborhood, adaptTopology } from "./adapters/topology";
|
|
15
22
|
import {
|
|
16
23
|
investigationEvidenceRefRe,
|
|
@@ -58,6 +65,7 @@ const ADAPTERS: Record<
|
|
|
58
65
|
issues: adaptIssues,
|
|
59
66
|
get_resource: adaptGetResource,
|
|
60
67
|
list_resources: adaptListResources,
|
|
68
|
+
list_namespaces: adaptListNamespaces,
|
|
61
69
|
get_events: adaptEvents,
|
|
62
70
|
get_pod_logs: adaptPodLogs,
|
|
63
71
|
get_changes: adaptChanges,
|
|
@@ -408,6 +416,7 @@ export function projectInvestigationEvidence(
|
|
|
408
416
|
sources: builder.sources,
|
|
409
417
|
evidenceRefSources,
|
|
410
418
|
citableSources,
|
|
419
|
+
targetPods: [...builder.establishedTargetPods],
|
|
411
420
|
coverage: {
|
|
412
421
|
attempted: builder.sources.length,
|
|
413
422
|
projected: builder.projectedSources.size,
|
|
@@ -471,7 +471,7 @@ describe("root-cause evidence resolution", () => {
|
|
|
471
471
|
).toBeUndefined();
|
|
472
472
|
});
|
|
473
473
|
|
|
474
|
-
it("fails closed for malformed, unmatched,
|
|
474
|
+
it("fails closed for malformed, unmatched, later-turn, failed, or partial refs", () => {
|
|
475
475
|
const currentRef = evidenceRef("a", "b");
|
|
476
476
|
const priorRef = evidenceRef("c", "d");
|
|
477
477
|
const failedRef = evidenceRef("a", "e");
|
|
@@ -493,9 +493,27 @@ describe("root-cause evidence resolution", () => {
|
|
|
493
493
|
],
|
|
494
494
|
);
|
|
495
495
|
|
|
496
|
+
// A result read in an earlier turn of the same run is citable — a revised
|
|
497
|
+
// assessment may rest on it — and resolves to that earlier read.
|
|
498
|
+
expect(
|
|
499
|
+
resolveInvestigationRootCauseEvidence(
|
|
500
|
+
projection,
|
|
501
|
+
{ status: "linked", refs: [priorRef] },
|
|
502
|
+
1,
|
|
503
|
+
),
|
|
504
|
+
).toMatchObject({ status: "linked", links: [{ source: { stepId: "old" } }] });
|
|
505
|
+
// A result read AFTER the assessment turn is not: the assessment could not
|
|
506
|
+
// have seen it.
|
|
507
|
+
expect(
|
|
508
|
+
resolveInvestigationRootCauseEvidence(
|
|
509
|
+
projection,
|
|
510
|
+
{ status: "linked", refs: [currentRef] },
|
|
511
|
+
0,
|
|
512
|
+
),
|
|
513
|
+
).toEqual({ status: "invalid", links: [] });
|
|
514
|
+
|
|
496
515
|
for (const evidence of [
|
|
497
516
|
{ status: "linked" as const, refs: [evidenceRef("a", "z")] },
|
|
498
|
-
{ status: "linked" as const, refs: [priorRef] },
|
|
499
517
|
{ status: "linked" as const, refs: [failedRef] },
|
|
500
518
|
{ status: "linked" as const, refs: [partialRef] },
|
|
501
519
|
{ status: "linked" as const, refs: [currentRef, currentRef] },
|
|
@@ -531,7 +549,7 @@ describe("root-cause evidence resolution", () => {
|
|
|
531
549
|
).toEqual({ status: "invalid", links: [] });
|
|
532
550
|
});
|
|
533
551
|
|
|
534
|
-
it("counts
|
|
552
|
+
it("counts duplicate refs across every turn up to the assessment", () => {
|
|
535
553
|
const ref = evidenceRef("a", "b");
|
|
536
554
|
const currentDuplicate = project(
|
|
537
555
|
[tool("prior", "get_resource", deployment, { evidenceRef: ref })],
|
|
@@ -562,16 +580,16 @@ describe("root-cause evidence resolution", () => {
|
|
|
562
580
|
],
|
|
563
581
|
[tool("current-only", "get_resource", deployment, { evidenceRef: ref })],
|
|
564
582
|
);
|
|
583
|
+
// A server-issued ref names one payload in one scope; the same ref in two
|
|
584
|
+
// turns can only be a replay, so run-wide uniqueness is the rule — the
|
|
585
|
+
// same rule the server binds by.
|
|
565
586
|
expect(
|
|
566
587
|
resolveInvestigationRootCauseEvidence(
|
|
567
588
|
priorDuplicateOnly,
|
|
568
589
|
{ status: "linked", refs: [ref] },
|
|
569
590
|
1,
|
|
570
591
|
),
|
|
571
|
-
).
|
|
572
|
-
status: "linked",
|
|
573
|
-
links: [{ source: { stepId: "current-only" } }],
|
|
574
|
-
});
|
|
592
|
+
).toEqual({ status: "invalid", links: [] });
|
|
575
593
|
});
|
|
576
594
|
|
|
577
595
|
it("rejects a replay that claims refs from different investigation scopes are linked", () => {
|
|
@@ -189,7 +189,7 @@ export function resolveInvestigationRootCauseEvidence(
|
|
|
189
189
|
|
|
190
190
|
const byRef = new Map<string, InvestigationEvidenceSource[]>();
|
|
191
191
|
for (const source of projection.evidenceRefSources) {
|
|
192
|
-
if (source.turnIndex
|
|
192
|
+
if (source.turnIndex > assessmentTurnIndex) continue;
|
|
193
193
|
if (!source.evidenceRef) continue;
|
|
194
194
|
const matches = byRef.get(source.evidenceRef) ?? [];
|
|
195
195
|
matches.push(source);
|
|
@@ -197,14 +197,14 @@ export function resolveInvestigationRootCauseEvidence(
|
|
|
197
197
|
}
|
|
198
198
|
const citableSourceIds = new Set(
|
|
199
199
|
projection.citableSources
|
|
200
|
-
.filter((source) => source.turnIndex
|
|
200
|
+
.filter((source) => source.turnIndex <= assessmentTurnIndex)
|
|
201
201
|
.map((source) => source.id),
|
|
202
202
|
);
|
|
203
203
|
const links: InvestigationRootCauseEvidenceLink[] = [];
|
|
204
204
|
for (const ref of refs) {
|
|
205
205
|
const matches = byRef.get(ref);
|
|
206
|
-
// Match the server's fail-closed binding: every
|
|
207
|
-
// counts before success/completeness eligibility is considered.
|
|
206
|
+
// Match the server's fail-closed binding: every occurrence up to this
|
|
207
|
+
// turn counts before success/completeness eligibility is considered.
|
|
208
208
|
if (matches?.length !== 1 || !citableSourceIds.has(matches[0].id)) {
|
|
209
209
|
return { status: "invalid", links: [] };
|
|
210
210
|
}
|
|
@@ -174,6 +174,7 @@ const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
|
|
|
174
174
|
issues: "Issue scan",
|
|
175
175
|
get_resource: "Resource details",
|
|
176
176
|
list_resources: "Resource inventory",
|
|
177
|
+
list_namespaces: "Namespace inventory",
|
|
177
178
|
get_events: "Kubernetes events",
|
|
178
179
|
get_pod_logs: "Container logs",
|
|
179
180
|
get_changes: "Recent changes",
|
|
@@ -585,11 +586,34 @@ function resourceObservationSummary(
|
|
|
585
586
|
if (context?.statusSummary?.phase) return context.statusSummary.phase;
|
|
586
587
|
if (context?.issueSummary?.topReason) return context.issueSummary.topReason;
|
|
587
588
|
return (
|
|
589
|
+
workloadReadinessFromResource(resource) ||
|
|
588
590
|
investigationResourceEvidenceSummary(resource) ||
|
|
589
591
|
warnings[0] ||
|
|
590
592
|
resource.metadata.namespace
|
|
591
593
|
);
|
|
592
594
|
}
|
|
595
|
+
|
|
596
|
+
// A workload read that carried no context still states its own readiness;
|
|
597
|
+
// a row that names only its namespace says nothing.
|
|
598
|
+
function workloadReadinessFromResource(
|
|
599
|
+
resource: InvestigationKubernetesResource,
|
|
600
|
+
): string | undefined {
|
|
601
|
+
const spec = record(resource.spec);
|
|
602
|
+
const status = record(resource.status);
|
|
603
|
+
const count = (value: unknown): number | undefined =>
|
|
604
|
+
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
605
|
+
if (resource.kind === "Deployment" || resource.kind === "StatefulSet") {
|
|
606
|
+
const desired = count(spec?.replicas) ?? (spec ? 1 : undefined);
|
|
607
|
+
if (desired === undefined) return undefined;
|
|
608
|
+
return `${count(status?.readyReplicas) ?? 0}/${desired} replicas ready`;
|
|
609
|
+
}
|
|
610
|
+
if (resource.kind === "DaemonSet") {
|
|
611
|
+
const desired = count(status?.desiredNumberScheduled);
|
|
612
|
+
if (desired === undefined) return undefined;
|
|
613
|
+
return `${count(status?.numberReady) ?? 0}/${desired} pods ready`;
|
|
614
|
+
}
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
593
617
|
// A scaler that cannot act (no metrics, cannot read the target) or is pinned
|
|
594
618
|
// at its ceiling is a captured Radar fact about the workload, so it lifts the
|
|
595
619
|
// card the way an adverse condition does. Ordinary scale-ups and scale-downs
|
|
@@ -510,9 +510,7 @@ describe("strict evidence adapters", () => {
|
|
|
510
510
|
expect(groupsOf(result.groups, "events")[0].latest.relevance).toBe(
|
|
511
511
|
"target",
|
|
512
512
|
);
|
|
513
|
-
expect(groupsOf(result.groups, "issue")[0].latest.relevance).toBe(
|
|
514
|
-
"target",
|
|
515
|
-
);
|
|
513
|
+
expect(groupsOf(result.groups, "issue")[0].latest.relevance).toBe("target");
|
|
516
514
|
});
|
|
517
515
|
|
|
518
516
|
it("keeps colliding non-core plurals on their own API group", () => {
|
|
@@ -2701,3 +2699,38 @@ describe("live-run follow-ups", () => {
|
|
|
2701
2699
|
expect(changes[1].latest.title).toBe("Recent changes · last 24h");
|
|
2702
2700
|
});
|
|
2703
2701
|
});
|
|
2702
|
+
|
|
2703
|
+
describe("namespace listings", () => {
|
|
2704
|
+
it("renders a list_namespaces result as an inventory card of Namespaces", () => {
|
|
2705
|
+
const projection = projectInvestigationEvidence(
|
|
2706
|
+
[
|
|
2707
|
+
{
|
|
2708
|
+
timeline: [
|
|
2709
|
+
tool(
|
|
2710
|
+
"namespaces",
|
|
2711
|
+
"list_namespaces",
|
|
2712
|
+
[
|
|
2713
|
+
{
|
|
2714
|
+
name: "capi-system",
|
|
2715
|
+
status: "Terminating",
|
|
2716
|
+
labels: { a: "b" },
|
|
2717
|
+
},
|
|
2718
|
+
{ name: "shop", status: "Active" },
|
|
2719
|
+
],
|
|
2720
|
+
{ summary: "{}" },
|
|
2721
|
+
),
|
|
2722
|
+
],
|
|
2723
|
+
},
|
|
2724
|
+
],
|
|
2725
|
+
{ kind: "Deployment", group: "apps", namespace: "shop", name: "api" },
|
|
2726
|
+
);
|
|
2727
|
+
const card = projection.groups.find((group) => group.kind === "inventory");
|
|
2728
|
+
expect(card?.latest.title).toBe("Namespaces");
|
|
2729
|
+
expect(card?.latest.summary).toBe("2 returned");
|
|
2730
|
+
expect(
|
|
2731
|
+
card?.latest.data.type === "inventory"
|
|
2732
|
+
? card.latest.data.resources.map((r) => r.name)
|
|
2733
|
+
: [],
|
|
2734
|
+
).toEqual(["capi-system", "shop"]);
|
|
2735
|
+
});
|
|
2736
|
+
});
|
|
@@ -500,6 +500,8 @@ export interface InvestigationEvidenceProjection {
|
|
|
500
500
|
/** Complete confirmed-success sources eligible for server-authored links. */
|
|
501
501
|
citableSources: InvestigationEvidenceSource[];
|
|
502
502
|
coverage: InvestigationEvidenceCoverage;
|
|
503
|
+
/** Pods a producer established as the target's own, by name. */
|
|
504
|
+
targetPods: string[];
|
|
503
505
|
}
|
|
504
506
|
export interface InvestigationRootCauseEvidenceLink {
|
|
505
507
|
source: InvestigationEvidenceSource;
|
|
@@ -428,6 +428,8 @@ describe("coverage presentation", () => {
|
|
|
428
428
|
limitation("Container logs", "Connection timed out", "error"),
|
|
429
429
|
]);
|
|
430
430
|
expect(groups[0].hasError).toBe(true);
|
|
431
|
+
expect(groups[0].bookkeepingOnly).toBe(false);
|
|
432
|
+
expect(groups[1].bookkeepingOnly).toBe(true);
|
|
431
433
|
expect(groups[0].summary).toContain("Forbidden");
|
|
432
434
|
expect(groups[0].summary).toContain("Connection timed out");
|
|
433
435
|
expect(groups[0].limitations).toHaveLength(3);
|
|
@@ -105,6 +105,8 @@ export interface EvidenceCoverageGroup {
|
|
|
105
105
|
limitations: InvestigationEvidenceLimitation[];
|
|
106
106
|
hasError: boolean;
|
|
107
107
|
historyOnly: boolean;
|
|
108
|
+
/** Every entry is a sampling cap or a history bound: bookkeeping for the record, not an open question. */
|
|
109
|
+
bookkeepingOnly: boolean;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
/** Group the presentation, not the underlying limitations or health qualification. */
|
|
@@ -158,6 +160,7 @@ export function groupEvidenceCoverage(
|
|
|
158
160
|
limitations: entries,
|
|
159
161
|
hasError: errors.length > 0,
|
|
160
162
|
historyOnly,
|
|
163
|
+
bookkeepingOnly: ordinaryLimits,
|
|
161
164
|
};
|
|
162
165
|
})
|
|
163
166
|
.sort(
|