@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
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
createContext,
|
|
3
3
|
useCallback,
|
|
4
4
|
useContext,
|
|
5
|
+
useEffect,
|
|
5
6
|
useId,
|
|
6
7
|
useLayoutEffect,
|
|
7
8
|
useRef,
|
|
@@ -9,22 +10,18 @@ import {
|
|
|
9
10
|
type ReactNode,
|
|
10
11
|
} from "react";
|
|
11
12
|
import { clsx } from "clsx";
|
|
13
|
+
import {
|
|
14
|
+
EVIDENCE_KIND_TRAITS,
|
|
15
|
+
evidenceKindIsFocused,
|
|
16
|
+
} from "./investigationEvidenceKinds";
|
|
12
17
|
import {
|
|
13
18
|
Activity,
|
|
14
19
|
AlertTriangle,
|
|
15
|
-
Boxes,
|
|
16
|
-
Bug,
|
|
17
|
-
CheckCircle2,
|
|
18
20
|
CircleAlert,
|
|
19
|
-
Clock3,
|
|
20
21
|
FileClock,
|
|
21
22
|
FileSearch,
|
|
22
23
|
Info,
|
|
23
|
-
ListTree,
|
|
24
|
-
Network,
|
|
25
|
-
ScrollText,
|
|
26
24
|
SearchCheck,
|
|
27
|
-
ShieldAlert,
|
|
28
25
|
SquareArrowOutUpRight,
|
|
29
26
|
} from "lucide-react";
|
|
30
27
|
import {
|
|
@@ -37,12 +34,27 @@ import {
|
|
|
37
34
|
defaultConditionTone,
|
|
38
35
|
displayKind,
|
|
39
36
|
formatRelativeAgeTime,
|
|
37
|
+
HPADiagnosisSummary,
|
|
40
38
|
mapHealthToTone,
|
|
41
39
|
ResourceLink,
|
|
42
40
|
stripAnsi,
|
|
43
41
|
} from "@skyhook-io/k8s-ui";
|
|
42
|
+
import {
|
|
43
|
+
AreaChart,
|
|
44
|
+
SERIES_COLORS,
|
|
45
|
+
SeriesLegend,
|
|
46
|
+
formatMetricValue,
|
|
47
|
+
seriesDisplayLabels,
|
|
48
|
+
seriesFill,
|
|
49
|
+
type TimeSeries,
|
|
50
|
+
} from "@skyhook-io/k8s-ui/components/charts";
|
|
44
51
|
import { apiVersionToGroup } from "../../utils/navigation";
|
|
45
52
|
import { parseLogLine } from "../../utils/log-format";
|
|
53
|
+
import {
|
|
54
|
+
metricsChangeCoverage,
|
|
55
|
+
metricsDomain,
|
|
56
|
+
type MetricsChangeCoverage,
|
|
57
|
+
} from "./investigationMetrics";
|
|
46
58
|
import {
|
|
47
59
|
evidenceDisplaySnapshot,
|
|
48
60
|
groupEvidenceCoverage,
|
|
@@ -52,6 +64,7 @@ import {
|
|
|
52
64
|
import {
|
|
53
65
|
investigationEvidenceSubjectRef,
|
|
54
66
|
investigationEvidenceSourceDomId,
|
|
67
|
+
investigationSourceArgs,
|
|
55
68
|
type InvestigationEvidenceData,
|
|
56
69
|
type InvestigationEvidenceGroup,
|
|
57
70
|
type InvestigationEvidenceObservation,
|
|
@@ -60,12 +73,23 @@ import {
|
|
|
60
73
|
type InvestigationEvidenceSource,
|
|
61
74
|
type InvestigationEvidenceTier,
|
|
62
75
|
} from "./investigationEvidence";
|
|
63
|
-
import type {
|
|
76
|
+
import type {
|
|
77
|
+
DiagnosisResourceContext,
|
|
78
|
+
DiagnosisResourceRef,
|
|
79
|
+
DiagnosisScalerRef,
|
|
80
|
+
} from "./diagnoseEvidenceTypes";
|
|
64
81
|
import { InvestigationResourceEvidence } from "./InvestigationResourceEvidence";
|
|
65
82
|
import { investigationResourceEvidenceHasDetails } from "./investigationResourceEvidenceModel";
|
|
66
83
|
import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
|
|
67
84
|
import { evidenceSourceExcerpt } from "./investigationSourceFocus";
|
|
68
85
|
import { Tooltip } from "../ui/Tooltip";
|
|
86
|
+
import { AgentClaimNote, AgentRoleChip } from "./AgentCase";
|
|
87
|
+
import {
|
|
88
|
+
investigationCaseObservationKey,
|
|
89
|
+
type InvestigationCaseItem,
|
|
90
|
+
type InvestigationCaseResolution,
|
|
91
|
+
} from "./investigationCase";
|
|
92
|
+
import type { DiagnosisEvidenceRole } from "../../api/diagnose";
|
|
69
93
|
|
|
70
94
|
import {
|
|
71
95
|
investigationDisclosureSettleDelay,
|
|
@@ -79,19 +103,71 @@ export {
|
|
|
79
103
|
} from "./useDisclosureReveal";
|
|
80
104
|
export const VISIBLE_LOG_EVIDENCE_LINES = 12;
|
|
81
105
|
|
|
106
|
+
/** The Timeline scope a changes card can open: one resource name in one namespace. */
|
|
107
|
+
export interface InvestigationTimelineScope {
|
|
108
|
+
namespace?: string;
|
|
109
|
+
name: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
const EvidenceNavigationContext = createContext<{
|
|
83
113
|
onOpenResource?: (ref: DiagnosisResourceRef) => void;
|
|
114
|
+
onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
|
|
84
115
|
revealSourceId?: string;
|
|
85
116
|
revealRequestId?: number;
|
|
86
117
|
expandedGroupIds?: ReadonlySet<string>;
|
|
87
118
|
onGroupOpenChange?: (id: string, open: boolean) => void;
|
|
88
119
|
citedOrderByGroup?: ReadonlyMap<string, number>;
|
|
120
|
+
/** Change markers for each metrics observation, keyed by its source id. */
|
|
121
|
+
metricsMarkersBySource?: ReadonlyMap<string, MetricsChangeCoverage>;
|
|
122
|
+
/** Card- and revision-placed agent items, keyed by group id. */
|
|
123
|
+
caseByGroup?: ReadonlyMap<string, InvestigationCaseItem[]>;
|
|
124
|
+
/** The hypothesis each `rules_out` item excludes, keyed by item. */
|
|
125
|
+
excludedByItem?: ReadonlyMap<string, string>;
|
|
89
126
|
}>({});
|
|
90
127
|
|
|
128
|
+
/** Item identity for the excluded-hypothesis lookup; index alone repeats across turns. */
|
|
129
|
+
export function investigationCaseItemKey(item: InvestigationCaseItem): string {
|
|
130
|
+
return `${item.source.id}\u0000${item.index}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const EVIDENCE_ROLE_RANK: Readonly<Record<DiagnosisEvidenceRole, number>> = {
|
|
134
|
+
cause: 0,
|
|
135
|
+
symptom: 1,
|
|
136
|
+
// A ruled-out card keeps Radar's place in the list; the role only labels it.
|
|
137
|
+
rules_out: 2,
|
|
138
|
+
context: 3,
|
|
139
|
+
benign: 4,
|
|
140
|
+
demoted: 5,
|
|
141
|
+
};
|
|
142
|
+
const UNLABELLED_RANK = 2;
|
|
143
|
+
|
|
144
|
+
function sourceNamesOneResource(source: InvestigationEvidenceSource): boolean {
|
|
145
|
+
const args = investigationSourceArgs(source);
|
|
146
|
+
return (
|
|
147
|
+
typeof args?.kind === "string" &&
|
|
148
|
+
args.kind !== "" &&
|
|
149
|
+
typeof args.name === "string" &&
|
|
150
|
+
args.name !== ""
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function investigationCaseByGroup(
|
|
155
|
+
investigationCase?: InvestigationCaseResolution,
|
|
156
|
+
): Map<string, InvestigationCaseItem[]> {
|
|
157
|
+
const byGroup = new Map<string, InvestigationCaseItem[]>();
|
|
158
|
+
for (const item of investigationCase?.items ?? []) {
|
|
159
|
+
if (!item.groupId || item.placement === "source") continue;
|
|
160
|
+
const items = byGroup.get(item.groupId) ?? [];
|
|
161
|
+
items.push(item);
|
|
162
|
+
byGroup.set(item.groupId, items);
|
|
163
|
+
}
|
|
164
|
+
return byGroup;
|
|
165
|
+
}
|
|
166
|
+
|
|
91
167
|
function evidenceTypePrefersFullRow(
|
|
92
168
|
type: InvestigationEvidenceData["type"],
|
|
93
169
|
): boolean {
|
|
94
|
-
return type
|
|
170
|
+
return EVIDENCE_KIND_TRAITS[type].fullRow;
|
|
95
171
|
}
|
|
96
172
|
|
|
97
173
|
// Supporting evidence becomes a two-column grid when the pane is wide enough.
|
|
@@ -119,12 +195,31 @@ type EvidenceCollection = "main" | "workload" | "earlier";
|
|
|
119
195
|
export function partitionInvestigationEvidence(
|
|
120
196
|
groups: InvestigationEvidenceGroup[],
|
|
121
197
|
resolution?: InvestigationRootCauseEvidenceResolution,
|
|
198
|
+
investigationCase?: InvestigationCaseResolution,
|
|
199
|
+
/**
|
|
200
|
+
* What a later turn cited, with the source that cited it. A follow-up adds
|
|
201
|
+
* to what the displayed assessment selected and never takes a selection
|
|
202
|
+
* away. The source travels with it because promoting a broader card needs
|
|
203
|
+
* one: an id alone selects the group but leaves the broader gate below
|
|
204
|
+
* unable to find who cited it, and the card stays withheld.
|
|
205
|
+
*/
|
|
206
|
+
alsoSelected?: readonly {
|
|
207
|
+
groupId: string;
|
|
208
|
+
source: InvestigationEvidenceSource;
|
|
209
|
+
}[],
|
|
122
210
|
) {
|
|
123
|
-
|
|
124
|
-
|
|
211
|
+
// Selection: legacy root-cause links plus every placed agent item, of any
|
|
212
|
+
// role (placement promotes a group into main the way a citation does; the
|
|
213
|
+
// role does not matter here). Ordering below is the only place roles act,
|
|
214
|
+
// and nothing the agent sends can remove a group from a collection.
|
|
215
|
+
const caseByGroup = investigationCaseByGroup(investigationCase);
|
|
216
|
+
const selected = new Set([
|
|
217
|
+
...(resolution?.status === "linked"
|
|
125
218
|
? resolution.links.map((link) => link.originalGroupId)
|
|
126
|
-
: [],
|
|
127
|
-
|
|
219
|
+
: []),
|
|
220
|
+
...caseByGroup.keys(),
|
|
221
|
+
...(alsoSelected ?? []).map((entry) => entry.groupId),
|
|
222
|
+
]);
|
|
128
223
|
const collections: Record<EvidenceCollection, InvestigationEvidenceGroup[]> =
|
|
129
224
|
{
|
|
130
225
|
main: [],
|
|
@@ -134,30 +229,51 @@ export function partitionInvestigationEvidence(
|
|
|
134
229
|
const collectionByGroup = new Map<string, EvidenceCollection>();
|
|
135
230
|
const adverse = (group: InvestigationEvidenceGroup) =>
|
|
136
231
|
["error", "alert", "warning"].includes(group.latest.tone);
|
|
232
|
+
// Broader cards are facts about something other than the target. They are
|
|
233
|
+
// withheld unless cited, and the pane says how many were withheld so a
|
|
234
|
+
// reader knows the agent looked at things it did not build its case on.
|
|
235
|
+
let hiddenBroader = 0;
|
|
236
|
+
// Broader metrics are facts about something other than the target. They are
|
|
237
|
+
// withheld unless cited, and the pane says how many were withheld so a
|
|
238
|
+
// reader knows the agent ran queries it did not build its case on.
|
|
239
|
+
let hiddenMetrics = 0;
|
|
137
240
|
for (const group of groups) {
|
|
138
241
|
const broader = group.latest.relevance === "broader";
|
|
139
242
|
// Citations select tool results, not individual rows in a broad search.
|
|
140
243
|
// Only a focused, unambiguous fact can be promoted by a source citation.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
244
|
+
// An agent item names this observation when its subject does, or when the
|
|
245
|
+
// call it cites was itself scoped to one resource; a subject-less item on
|
|
246
|
+
// a broad query only proves the query returned one row and is gated like
|
|
247
|
+
// a citation of its source.
|
|
248
|
+
const namedByAgent = (caseByGroup.get(group.id) ?? []).some(
|
|
249
|
+
(item) => item.subject || sourceNamesOneResource(item.source),
|
|
250
|
+
);
|
|
251
|
+
if (broader && !namedByAgent) {
|
|
252
|
+
const citingSource =
|
|
253
|
+
resolution?.links.find((item) => item.originalGroupId === group.id)
|
|
254
|
+
?.source ??
|
|
255
|
+
caseByGroup.get(group.id)?.[0]?.source ??
|
|
256
|
+
alsoSelected?.find((entry) => entry.groupId === group.id)?.source;
|
|
257
|
+
const focused = evidenceKindIsFocused(group.latest.data.type);
|
|
258
|
+
const sourceGroups = citingSource
|
|
149
259
|
? groups.filter(
|
|
150
260
|
(candidate) =>
|
|
151
|
-
|
|
152
|
-
candidate.latest.data.type,
|
|
153
|
-
) &&
|
|
261
|
+
evidenceKindIsFocused(candidate.latest.data.type) &&
|
|
154
262
|
candidate.observations.some(
|
|
155
|
-
(observation) => observation.source.id ===
|
|
263
|
+
(observation) => observation.source.id === citingSource.id,
|
|
156
264
|
),
|
|
157
265
|
)
|
|
158
266
|
: [];
|
|
159
|
-
if (!selected.has(group.id) || !focused || sourceGroups.length !== 1)
|
|
267
|
+
if (!selected.has(group.id) || !focused || sourceGroups.length !== 1) {
|
|
268
|
+
// The two counts head separate lines in the pane, so they have to be
|
|
269
|
+
// disjoint: a withheld chart announced by both would read as two
|
|
270
|
+
// withheld results.
|
|
271
|
+
if (!group.historical) {
|
|
272
|
+
if (group.latest.data.type === "metrics") hiddenMetrics += 1;
|
|
273
|
+
else hiddenBroader += 1;
|
|
274
|
+
}
|
|
160
275
|
continue;
|
|
276
|
+
}
|
|
161
277
|
}
|
|
162
278
|
const main =
|
|
163
279
|
!group.historical &&
|
|
@@ -173,14 +289,51 @@ export function partitionInvestigationEvidence(
|
|
|
173
289
|
collections[collection].push(group);
|
|
174
290
|
collectionByGroup.set(group.id, collection);
|
|
175
291
|
}
|
|
176
|
-
|
|
177
|
-
|
|
292
|
+
// A group takes its strongest role (lowest rank); within one role the
|
|
293
|
+
// agent's own item order decides. Unlabelled and ruled-out groups keep
|
|
294
|
+
// Radar's order among themselves.
|
|
295
|
+
const roleOrder = (group: InvestigationEvidenceGroup) => {
|
|
296
|
+
let rank: number | undefined;
|
|
297
|
+
let itemIndex = Number.POSITIVE_INFINITY;
|
|
298
|
+
for (const item of caseByGroup.get(group.id) ?? []) {
|
|
299
|
+
const itemRank = EVIDENCE_ROLE_RANK[item.role];
|
|
300
|
+
if (rank === undefined || itemRank < rank) {
|
|
301
|
+
rank = itemRank;
|
|
302
|
+
itemIndex = item.index;
|
|
303
|
+
} else if (itemRank === rank) {
|
|
304
|
+
itemIndex = Math.min(itemIndex, item.index);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (rank === undefined || rank === UNLABELLED_RANK) {
|
|
308
|
+
return { rank: UNLABELLED_RANK, itemIndex: Number.POSITIVE_INFINITY };
|
|
309
|
+
}
|
|
310
|
+
return { rank, itemIndex };
|
|
311
|
+
};
|
|
312
|
+
collections.main.sort((left, right) => {
|
|
313
|
+
const leftRole = roleOrder(left);
|
|
314
|
+
const rightRole = roleOrder(right);
|
|
315
|
+
const agentOrder =
|
|
316
|
+
Number.isFinite(leftRole.itemIndex) ||
|
|
317
|
+
Number.isFinite(rightRole.itemIndex)
|
|
318
|
+
? leftRole.itemIndex - rightRole.itemIndex
|
|
319
|
+
: 0;
|
|
320
|
+
return (
|
|
321
|
+
leftRole.rank - rightRole.rank ||
|
|
322
|
+
agentOrder ||
|
|
178
323
|
Number(right.latest.tier === "key") -
|
|
179
324
|
Number(left.latest.tier === "key") ||
|
|
180
325
|
Number(adverse(right)) - Number(adverse(left)) ||
|
|
181
|
-
left.firstOrder - right.firstOrder
|
|
326
|
+
left.firstOrder - right.firstOrder
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
// A healthy workload's collection opens with several "nothing here"
|
|
330
|
+
// receipts; the cards that carry facts (its vitals above all) come first,
|
|
331
|
+
// and the receipts keep their order among themselves.
|
|
332
|
+
collections.workload.sort(
|
|
333
|
+
(left, right) =>
|
|
334
|
+
Number(left.kind === "receipt") - Number(right.kind === "receipt"),
|
|
182
335
|
);
|
|
183
|
-
return { ...collections, collectionByGroup };
|
|
336
|
+
return { ...collections, collectionByGroup, hiddenBroader, hiddenMetrics };
|
|
184
337
|
}
|
|
185
338
|
|
|
186
339
|
export function investigationEvidenceRevealCollection(
|
|
@@ -205,11 +358,14 @@ export function investigationEvidenceRevealCollection(
|
|
|
205
358
|
export function InvestigationEvidencePane({
|
|
206
359
|
projection,
|
|
207
360
|
rootCauseEvidence,
|
|
361
|
+
alsoSelectedGroupIds,
|
|
362
|
+
investigationCase,
|
|
208
363
|
collecting,
|
|
209
364
|
animateGroupIds,
|
|
210
365
|
onViewSource,
|
|
211
366
|
onViewActivity,
|
|
212
367
|
onOpenResource,
|
|
368
|
+
onOpenTimeline,
|
|
213
369
|
afterEvidence,
|
|
214
370
|
revealRequest,
|
|
215
371
|
onRevealReady,
|
|
@@ -217,6 +373,13 @@ export function InvestigationEvidencePane({
|
|
|
217
373
|
projection: InvestigationEvidenceProjection;
|
|
218
374
|
/** Server-validated links for the current root cause; absent without one. */
|
|
219
375
|
rootCauseEvidence?: InvestigationRootCauseEvidenceResolution;
|
|
376
|
+
/** What a later turn cited; adds to the assessment's selection. */
|
|
377
|
+
alsoSelectedGroupIds?: readonly {
|
|
378
|
+
groupId: string;
|
|
379
|
+
source: InvestigationEvidenceSource;
|
|
380
|
+
}[];
|
|
381
|
+
/** The current assessment's agent case, resolved against this projection. */
|
|
382
|
+
investigationCase?: InvestigationCaseResolution;
|
|
220
383
|
collecting: boolean;
|
|
221
384
|
animateGroupIds: ReadonlySet<string>;
|
|
222
385
|
onViewSource: (
|
|
@@ -227,6 +390,8 @@ export function InvestigationEvidencePane({
|
|
|
227
390
|
onViewActivity: () => void;
|
|
228
391
|
/** Opens an evidence subject in Radar when the producer identified it exactly. */
|
|
229
392
|
onOpenResource?: (ref: DiagnosisResourceRef) => void;
|
|
393
|
+
/** Opens Radar's Timeline filtered to the resource a changes card is about. */
|
|
394
|
+
onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
|
|
230
395
|
/** Actions follow the complete evidence section, including its disclosures. */
|
|
231
396
|
afterEvidence?: ReactNode;
|
|
232
397
|
/** Explicit Activity → Findings navigation, including repeat clicks. */
|
|
@@ -253,9 +418,57 @@ export function InvestigationEvidencePane({
|
|
|
253
418
|
const partition = partitionInvestigationEvidence(
|
|
254
419
|
projection.groups,
|
|
255
420
|
rootCauseEvidence,
|
|
421
|
+
investigationCase,
|
|
422
|
+
alsoSelectedGroupIds,
|
|
256
423
|
);
|
|
257
424
|
const hasCurrentEvidence =
|
|
258
425
|
partition.main.length + partition.workload.length > 0;
|
|
426
|
+
// A "Ruled out" link is an in-pane navigation to the placed observation. It
|
|
427
|
+
// reuses the Activity → Findings reveal path (open the card, open history
|
|
428
|
+
// when the observation is a superseded revision) and yields to a newer
|
|
429
|
+
// request from the parent.
|
|
430
|
+
const [caseReveal, setCaseReveal] = useState<
|
|
431
|
+
{ sourceId: string; requestId: number } | undefined
|
|
432
|
+
>(undefined);
|
|
433
|
+
// A ruled-out reveal belongs to the case that produced it; a new parent
|
|
434
|
+
// request or a new assessment's case supersedes it.
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
setCaseReveal(undefined);
|
|
437
|
+
}, [revealRequest?.requestId, investigationCase]);
|
|
438
|
+
const collectionByGroup = partition.collectionByGroup;
|
|
439
|
+
// A placed item can still sit on a withheld broader card; a link to it
|
|
440
|
+
// would have nowhere to go.
|
|
441
|
+
const visibleRuledOut = (investigationCase?.ruledOut ?? []).filter(
|
|
442
|
+
(entry) => entry.item.groupId && collectionByGroup.has(entry.item.groupId),
|
|
443
|
+
);
|
|
444
|
+
const revealCaseItem = useCallback(
|
|
445
|
+
(item: InvestigationCaseItem) => {
|
|
446
|
+
const { groupId, observation } = item;
|
|
447
|
+
if (!groupId || !observation) return;
|
|
448
|
+
// A placed group is normally in main, but a historical one lives in the
|
|
449
|
+
// nested "Previous observations" collection; open the way to it first.
|
|
450
|
+
const collection = collectionByGroup.get(groupId);
|
|
451
|
+
let settle = 0;
|
|
452
|
+
if (collection === "workload" || collection === "earlier") {
|
|
453
|
+
setWorkloadOpen(true);
|
|
454
|
+
settle = investigationDisclosureSettleDelay(prefersReducedMotion());
|
|
455
|
+
}
|
|
456
|
+
if (collection === "earlier") setEarlierOpen(true);
|
|
457
|
+
onGroupOpenChange(groupId, true);
|
|
458
|
+
setCaseReveal({ sourceId: observation.source.id, requestId: Date.now() });
|
|
459
|
+
window.setTimeout(() => {
|
|
460
|
+
window.requestAnimationFrame(() => {
|
|
461
|
+
const element = document.getElementById(groupId);
|
|
462
|
+
element?.scrollIntoView({
|
|
463
|
+
block: "start",
|
|
464
|
+
behavior: prefersReducedMotion() ? "auto" : "smooth",
|
|
465
|
+
});
|
|
466
|
+
element?.focus({ preventScroll: true });
|
|
467
|
+
});
|
|
468
|
+
}, settle);
|
|
469
|
+
},
|
|
470
|
+
[onGroupOpenChange, collectionByGroup],
|
|
471
|
+
);
|
|
259
472
|
const revealCollection = revealRequest
|
|
260
473
|
? investigationEvidenceRevealCollection(
|
|
261
474
|
projection,
|
|
@@ -376,6 +589,13 @@ export function InvestigationEvidencePane({
|
|
|
376
589
|
/>
|
|
377
590
|
) : null}
|
|
378
591
|
|
|
592
|
+
{/* What the agent considered and rejected belongs with the conclusion
|
|
593
|
+
it argues for, not after the evidence list among the withheld
|
|
594
|
+
counts, where it read as bookkeeping. */}
|
|
595
|
+
{visibleRuledOut.length > 0 ? (
|
|
596
|
+
<RuledOutBlock entries={visibleRuledOut} onReveal={revealCaseItem} />
|
|
597
|
+
) : null}
|
|
598
|
+
|
|
379
599
|
<div className="grid items-start gap-2.5">
|
|
380
600
|
{partition.main.map((group) => (
|
|
381
601
|
<EvidenceCard
|
|
@@ -388,6 +608,28 @@ export function InvestigationEvidencePane({
|
|
|
388
608
|
))}
|
|
389
609
|
</div>
|
|
390
610
|
|
|
611
|
+
{partition.hiddenBroader > 0 ? (
|
|
612
|
+
<p
|
|
613
|
+
className="text-xs text-theme-text-tertiary"
|
|
614
|
+
data-testid="investigation-hidden-evidence"
|
|
615
|
+
>
|
|
616
|
+
{partition.hiddenBroader === 1
|
|
617
|
+
? "1 result about another resource is not shown; it appears here when the assessment cites it."
|
|
618
|
+
: `${partition.hiddenBroader} results about other resources are not shown; they appear here when the assessment cites them.`}
|
|
619
|
+
</p>
|
|
620
|
+
) : null}
|
|
621
|
+
|
|
622
|
+
{partition.hiddenMetrics > 0 ? (
|
|
623
|
+
<p
|
|
624
|
+
className="text-xs text-theme-text-tertiary"
|
|
625
|
+
data-testid="investigation-hidden-metrics"
|
|
626
|
+
>
|
|
627
|
+
{partition.hiddenMetrics === 1
|
|
628
|
+
? "1 broader metric result is not shown; it appears here when the assessment cites it."
|
|
629
|
+
: `${partition.hiddenMetrics} broader metric results are not shown; they appear here when the assessment cites them.`}
|
|
630
|
+
</p>
|
|
631
|
+
) : null}
|
|
632
|
+
|
|
391
633
|
{!hasCurrentEvidence ? (
|
|
392
634
|
<EmptyCollection
|
|
393
635
|
collecting={collecting}
|
|
@@ -426,10 +668,32 @@ export function InvestigationEvidencePane({
|
|
|
426
668
|
<EvidenceNavigationContext.Provider
|
|
427
669
|
value={{
|
|
428
670
|
onOpenResource,
|
|
671
|
+
onOpenTimeline,
|
|
429
672
|
expandedGroupIds,
|
|
430
673
|
onGroupOpenChange,
|
|
431
|
-
revealSourceId: revealRequest?.sourceId,
|
|
432
|
-
revealRequestId: revealRequest?.requestId,
|
|
674
|
+
revealSourceId: caseReveal?.sourceId ?? revealRequest?.sourceId,
|
|
675
|
+
revealRequestId: caseReveal?.requestId ?? revealRequest?.requestId,
|
|
676
|
+
caseByGroup: investigationCaseByGroup(investigationCase),
|
|
677
|
+
excludedByItem: new Map(
|
|
678
|
+
(investigationCase?.ruledOut ?? []).map((entry) => [
|
|
679
|
+
investigationCaseItemKey(entry.item),
|
|
680
|
+
entry.hypothesis,
|
|
681
|
+
]),
|
|
682
|
+
),
|
|
683
|
+
metricsMarkersBySource: new Map(
|
|
684
|
+
projection.groups.flatMap((group) =>
|
|
685
|
+
group.observations.flatMap((observation) =>
|
|
686
|
+
observation.data.type === "metrics"
|
|
687
|
+
? [
|
|
688
|
+
[
|
|
689
|
+
observation.source.id,
|
|
690
|
+
metricsChangeCoverage(projection.groups, observation),
|
|
691
|
+
] as const,
|
|
692
|
+
]
|
|
693
|
+
: [],
|
|
694
|
+
),
|
|
695
|
+
),
|
|
696
|
+
),
|
|
433
697
|
citedOrderByGroup: new Map(
|
|
434
698
|
rootCauseEvidence?.links.flatMap((link) =>
|
|
435
699
|
link.originalGroupId
|
|
@@ -445,6 +709,63 @@ export function InvestigationEvidencePane({
|
|
|
445
709
|
);
|
|
446
710
|
}
|
|
447
711
|
|
|
712
|
+
/**
|
|
713
|
+
* Hypotheses the agent dropped, each pointing at the Radar observation whose
|
|
714
|
+
* result contradicted it. Entries whose item could not be placed on an
|
|
715
|
+
* observation are filtered out by the resolver and never rendered here.
|
|
716
|
+
*/
|
|
717
|
+
function RuledOutBlock({
|
|
718
|
+
entries,
|
|
719
|
+
onReveal,
|
|
720
|
+
}: {
|
|
721
|
+
entries: InvestigationCaseResolution["ruledOut"];
|
|
722
|
+
onReveal: (item: InvestigationCaseItem) => void;
|
|
723
|
+
}) {
|
|
724
|
+
const headingId = useId();
|
|
725
|
+
return (
|
|
726
|
+
<section
|
|
727
|
+
aria-labelledby={headingId}
|
|
728
|
+
data-testid="investigation-ruled-out"
|
|
729
|
+
className="rounded-lg border border-theme-border/80 bg-theme-base/20 px-3 py-2.5"
|
|
730
|
+
>
|
|
731
|
+
<h3
|
|
732
|
+
id={headingId}
|
|
733
|
+
className="flex items-center gap-2 text-xs font-semibold text-theme-text-secondary"
|
|
734
|
+
>
|
|
735
|
+
Ruled out
|
|
736
|
+
<span className="text-[10px] font-semibold uppercase tracking-wide text-accent-text">
|
|
737
|
+
Agent
|
|
738
|
+
</span>
|
|
739
|
+
</h3>
|
|
740
|
+
<ul className="mt-1.5 space-y-1.5">
|
|
741
|
+
{entries.map((entry, position) => {
|
|
742
|
+
const observation = entry.item.observation!;
|
|
743
|
+
return (
|
|
744
|
+
<li
|
|
745
|
+
key={position}
|
|
746
|
+
className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs"
|
|
747
|
+
>
|
|
748
|
+
<span className="min-w-0 text-theme-text-secondary [overflow-wrap:anywhere]">
|
|
749
|
+
{entry.hypothesis}
|
|
750
|
+
</span>
|
|
751
|
+
<button
|
|
752
|
+
type="button"
|
|
753
|
+
onClick={() => onReveal(entry.item)}
|
|
754
|
+
className="shrink-0 rounded text-accent-text hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
755
|
+
>
|
|
756
|
+
See {observation.title}
|
|
757
|
+
{entry.item.placement === "revision"
|
|
758
|
+
? " (earlier observation)"
|
|
759
|
+
: ""}
|
|
760
|
+
</button>
|
|
761
|
+
</li>
|
|
762
|
+
);
|
|
763
|
+
})}
|
|
764
|
+
</ul>
|
|
765
|
+
</section>
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
448
769
|
function AssessmentEvidenceQualification({
|
|
449
770
|
resolution,
|
|
450
771
|
onViewActivity,
|
|
@@ -633,6 +954,7 @@ function CoverageStrip({
|
|
|
633
954
|
}) {
|
|
634
955
|
const hasError = groups.some((group) => group.hasError);
|
|
635
956
|
const historyOnly = groups.every((group) => group.historyOnly);
|
|
957
|
+
const quiet = historyOnly;
|
|
636
958
|
const { elementRef, revealAfterToggle } =
|
|
637
959
|
useDisclosureReveal<HTMLDivElement>();
|
|
638
960
|
const regionId = "investigation-evidence-coverage";
|
|
@@ -652,7 +974,7 @@ function CoverageStrip({
|
|
|
652
974
|
}}
|
|
653
975
|
className="flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left hover:bg-theme-hover/50"
|
|
654
976
|
>
|
|
655
|
-
{
|
|
977
|
+
{quiet ? (
|
|
656
978
|
<Info
|
|
657
979
|
className="h-4 w-4 shrink-0 text-theme-text-tertiary"
|
|
658
980
|
aria-hidden
|
|
@@ -669,7 +991,7 @@ function CoverageStrip({
|
|
|
669
991
|
<span
|
|
670
992
|
className={clsx(
|
|
671
993
|
"block text-xs",
|
|
672
|
-
|
|
994
|
+
quiet
|
|
673
995
|
? "font-medium text-theme-text-secondary"
|
|
674
996
|
: "font-semibold text-theme-text-primary",
|
|
675
997
|
)}
|
|
@@ -735,6 +1057,65 @@ function CoverageGroupRow({
|
|
|
735
1057
|
const regionId = useId();
|
|
736
1058
|
const { elementRef, revealAfterToggle } =
|
|
737
1059
|
useDisclosureReveal<HTMLDivElement>();
|
|
1060
|
+
// One limitation whose message is the whole summary would repeat itself as
|
|
1061
|
+
// its own detail row; show its source link on the summary row instead.
|
|
1062
|
+
const single =
|
|
1063
|
+
group.limitations.length === 1 &&
|
|
1064
|
+
group.limitations[0].message === group.summary
|
|
1065
|
+
? group.limitations[0]
|
|
1066
|
+
: undefined;
|
|
1067
|
+
const anchors = sourceIds.map((id) => (
|
|
1068
|
+
<span
|
|
1069
|
+
key={id}
|
|
1070
|
+
id={investigationEvidenceSourceDomId(id)}
|
|
1071
|
+
className="sr-only scroll-mt-14"
|
|
1072
|
+
aria-hidden
|
|
1073
|
+
/>
|
|
1074
|
+
));
|
|
1075
|
+
if (single) {
|
|
1076
|
+
return (
|
|
1077
|
+
<div
|
|
1078
|
+
ref={elementRef}
|
|
1079
|
+
data-evidence-source-container
|
|
1080
|
+
tabIndex={-1}
|
|
1081
|
+
aria-label={`Evidence limitation for ${group.label}: ${group.summary}`}
|
|
1082
|
+
className="flex items-center gap-2 px-3 py-2.5 text-xs outline-none focus:ring-2 focus:ring-accent/40"
|
|
1083
|
+
>
|
|
1084
|
+
{anchors}
|
|
1085
|
+
{group.hasError ? (
|
|
1086
|
+
<CircleAlert
|
|
1087
|
+
className="h-3.5 w-3.5 shrink-0 text-red-400"
|
|
1088
|
+
aria-hidden
|
|
1089
|
+
/>
|
|
1090
|
+
) : single.kind === "truncated" ? (
|
|
1091
|
+
<AlertTriangle
|
|
1092
|
+
className="h-3.5 w-3.5 shrink-0 text-amber-500"
|
|
1093
|
+
aria-hidden
|
|
1094
|
+
/>
|
|
1095
|
+
) : (
|
|
1096
|
+
<Info
|
|
1097
|
+
className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
|
|
1098
|
+
aria-hidden
|
|
1099
|
+
/>
|
|
1100
|
+
)}
|
|
1101
|
+
<p className="min-w-0 flex-1 leading-relaxed text-theme-text-secondary">
|
|
1102
|
+
<span className="font-medium text-theme-text-primary">
|
|
1103
|
+
{group.label}:
|
|
1104
|
+
</span>{" "}
|
|
1105
|
+
{group.summary}
|
|
1106
|
+
</p>
|
|
1107
|
+
{single.sources.at(-1) ? (
|
|
1108
|
+
<SourceButton
|
|
1109
|
+
ariaLabel={`View source for ${group.label}`}
|
|
1110
|
+
buttonLabel={
|
|
1111
|
+
single.sources.length > 1 ? "View latest in Activity" : undefined
|
|
1112
|
+
}
|
|
1113
|
+
onClick={() => onViewSource(single.sources.at(-1)!.id)}
|
|
1114
|
+
/>
|
|
1115
|
+
) : null}
|
|
1116
|
+
</div>
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
738
1119
|
return (
|
|
739
1120
|
<div
|
|
740
1121
|
ref={elementRef}
|
|
@@ -743,14 +1124,7 @@ function CoverageGroupRow({
|
|
|
743
1124
|
aria-label={`Evidence limitation for ${group.label}: ${group.summary}`}
|
|
744
1125
|
className="outline-none focus:ring-2 focus:ring-accent/40"
|
|
745
1126
|
>
|
|
746
|
-
{
|
|
747
|
-
<span
|
|
748
|
-
key={id}
|
|
749
|
-
id={investigationEvidenceSourceDomId(id)}
|
|
750
|
-
className="sr-only scroll-mt-14"
|
|
751
|
-
aria-hidden
|
|
752
|
-
/>
|
|
753
|
-
))}
|
|
1127
|
+
{anchors}
|
|
754
1128
|
<button
|
|
755
1129
|
type="button"
|
|
756
1130
|
aria-expanded={open}
|
|
@@ -833,14 +1207,25 @@ function CoverageGroupRow({
|
|
|
833
1207
|
function previousDifferentObservations(
|
|
834
1208
|
group: InvestigationEvidenceGroup,
|
|
835
1209
|
citedOrder?: number,
|
|
1210
|
+
boundOrders: ReadonlySet<number> = new Set(),
|
|
836
1211
|
) {
|
|
837
1212
|
const seen = new Set([evidenceDisplaySnapshot(group.latest)]);
|
|
1213
|
+
const pinned = (observation: InvestigationEvidenceObservation) =>
|
|
1214
|
+
observation.source.order === citedOrder ||
|
|
1215
|
+
boundOrders.has(observation.source.order);
|
|
838
1216
|
return [...group.observations].reverse().filter((observation) => {
|
|
1217
|
+
if (observation === group.latest) return false;
|
|
1218
|
+
// An observation bound to an agent item always keeps its own row: display
|
|
1219
|
+
// equivalence is a folding rule for unpinned rechecks, never a reason to
|
|
1220
|
+
// move a claim onto different content.
|
|
1221
|
+
if (boundOrders.has(observation.source.order)) return true;
|
|
1222
|
+
// A pinned observation wins over an unpinned twin with the same display
|
|
1223
|
+
// content.
|
|
839
1224
|
if (
|
|
840
|
-
observation
|
|
1225
|
+
!pinned(observation) &&
|
|
841
1226
|
group.observations.some(
|
|
842
1227
|
(other) =>
|
|
843
|
-
other
|
|
1228
|
+
pinned(other) &&
|
|
844
1229
|
evidenceDisplaySnapshot(other) ===
|
|
845
1230
|
evidenceDisplaySnapshot(observation),
|
|
846
1231
|
)
|
|
@@ -856,6 +1241,8 @@ function previousDifferentObservations(
|
|
|
856
1241
|
export function investigationEvidenceShouldRevealHistory(
|
|
857
1242
|
group: InvestigationEvidenceGroup,
|
|
858
1243
|
sourceId?: string,
|
|
1244
|
+
/** Sources whose observation carries an agent item and so always has a row. */
|
|
1245
|
+
boundSourceIds: ReadonlySet<string> = new Set(),
|
|
859
1246
|
): boolean {
|
|
860
1247
|
return (
|
|
861
1248
|
Boolean(sourceId) &&
|
|
@@ -863,8 +1250,9 @@ export function investigationEvidenceShouldRevealHistory(
|
|
|
863
1250
|
group.observations.some(
|
|
864
1251
|
(observation) =>
|
|
865
1252
|
observation.source.id === sourceId &&
|
|
866
|
-
|
|
867
|
-
evidenceDisplaySnapshot(
|
|
1253
|
+
(boundSourceIds.has(observation.source.id) ||
|
|
1254
|
+
evidenceDisplaySnapshot(observation) !==
|
|
1255
|
+
evidenceDisplaySnapshot(group.latest)),
|
|
868
1256
|
)
|
|
869
1257
|
);
|
|
870
1258
|
}
|
|
@@ -891,11 +1279,15 @@ function EvidenceCard({
|
|
|
891
1279
|
}) {
|
|
892
1280
|
const {
|
|
893
1281
|
onOpenResource,
|
|
1282
|
+
onOpenTimeline,
|
|
894
1283
|
revealSourceId,
|
|
895
1284
|
revealRequestId,
|
|
896
1285
|
citedOrderByGroup,
|
|
897
1286
|
expandedGroupIds,
|
|
898
1287
|
onGroupOpenChange,
|
|
1288
|
+
metricsMarkersBySource,
|
|
1289
|
+
caseByGroup,
|
|
1290
|
+
excludedByItem,
|
|
899
1291
|
} = useContext(EvidenceNavigationContext);
|
|
900
1292
|
const open = expandedGroupIds?.has(group.id) ?? false;
|
|
901
1293
|
const setOpen = useCallback(
|
|
@@ -909,7 +1301,17 @@ function EvidenceCard({
|
|
|
909
1301
|
? parseLogLine(observation.summary).content
|
|
910
1302
|
: observation.summary;
|
|
911
1303
|
const citedOrder = citedOrderByGroup?.get(group.id);
|
|
912
|
-
const
|
|
1304
|
+
const caseItems = caseByGroup?.get(group.id) ?? [];
|
|
1305
|
+
const cardItems = caseItems.filter((item) => item.placement === "card");
|
|
1306
|
+
const revisionItems = caseItems.filter(
|
|
1307
|
+
(item) => item.placement === "revision",
|
|
1308
|
+
);
|
|
1309
|
+
const agentCause = cardItems.some((item) => item.role === "cause");
|
|
1310
|
+
const previousObservations = previousDifferentObservations(
|
|
1311
|
+
group,
|
|
1312
|
+
citedOrder,
|
|
1313
|
+
new Set(revisionItems.map((item) => item.observation!.source.order)),
|
|
1314
|
+
);
|
|
913
1315
|
const meaningfulHistory = previousObservations.length > 0;
|
|
914
1316
|
const citedObservation = group.observations.find(
|
|
915
1317
|
(item) => item.source.order === citedOrder,
|
|
@@ -927,6 +1329,7 @@ function EvidenceCard({
|
|
|
927
1329
|
const revealHistory = investigationEvidenceShouldRevealHistory(
|
|
928
1330
|
group,
|
|
929
1331
|
revealSourceId,
|
|
1332
|
+
new Set(revisionItems.map((item) => item.observation!.source.id)),
|
|
930
1333
|
);
|
|
931
1334
|
useLayoutEffect(() => {
|
|
932
1335
|
const destination = revealSourceId
|
|
@@ -1007,7 +1410,14 @@ function EvidenceCard({
|
|
|
1007
1410
|
aria-label={`${observation.title} evidence`}
|
|
1008
1411
|
className={clsx(
|
|
1009
1412
|
"@container/card scroll-mt-14 overflow-hidden outline-none focus:ring-2 focus:ring-accent/50 data-[source-related]:ring-2 data-[source-related]:ring-accent/35",
|
|
1010
|
-
"rounded-lg border
|
|
1413
|
+
"rounded-lg border",
|
|
1414
|
+
// The card the agent calls the cause sorts first and is often the
|
|
1415
|
+
// calmest thing on screen: a cause is frequently a Secret or a
|
|
1416
|
+
// ConfigMap that Radar has no reason to colour, while every card
|
|
1417
|
+
// echoing the failure below it carries red. Lifting the surface marks
|
|
1418
|
+
// it without borrowing a severity hue, and without touching the left
|
|
1419
|
+
// rule (Radar's severity) or the ring (the source you are viewing).
|
|
1420
|
+
agentCause ? "bg-theme-elevated shadow-theme-sm" : "bg-theme-surface",
|
|
1011
1421
|
toneBorder(observation.tone, observation.tier, prominence),
|
|
1012
1422
|
wide && "@min-[760px]/evidence:col-span-2",
|
|
1013
1423
|
animateArrival && "animate-transcript-enter",
|
|
@@ -1072,8 +1482,32 @@ function EvidenceCard({
|
|
|
1072
1482
|
onOpenResource={onOpenResource}
|
|
1073
1483
|
compact
|
|
1074
1484
|
/>
|
|
1485
|
+
{observation.data.type === "changes" && observation.data.subject ? (
|
|
1486
|
+
<OpenTimelineButton
|
|
1487
|
+
scope={observation.data.subject}
|
|
1488
|
+
onOpenTimeline={onOpenTimeline}
|
|
1489
|
+
/>
|
|
1490
|
+
) : null}
|
|
1075
1491
|
</div>
|
|
1076
1492
|
</div>
|
|
1493
|
+
{cardItems.some((item) => item.claim || item.role) ? (
|
|
1494
|
+
<div
|
|
1495
|
+
className={clsx(
|
|
1496
|
+
"space-y-1.5",
|
|
1497
|
+
prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2",
|
|
1498
|
+
)}
|
|
1499
|
+
>
|
|
1500
|
+
{cardItems.map((item) => (
|
|
1501
|
+
<AgentClaimNote
|
|
1502
|
+
key={item.index}
|
|
1503
|
+
claim={item.claim}
|
|
1504
|
+
role={item.role}
|
|
1505
|
+
excludes={excludedByItem?.get(investigationCaseItemKey(item))}
|
|
1506
|
+
className="pt-1.5"
|
|
1507
|
+
/>
|
|
1508
|
+
))}
|
|
1509
|
+
</div>
|
|
1510
|
+
) : null}
|
|
1077
1511
|
{canExpand ? (
|
|
1078
1512
|
<div id={bodyId}>
|
|
1079
1513
|
<Collapse open={open}>
|
|
@@ -1087,12 +1521,16 @@ function EvidenceCard({
|
|
|
1087
1521
|
<EvidenceBody
|
|
1088
1522
|
data={observation.data}
|
|
1089
1523
|
cardSummary={observation.summary}
|
|
1524
|
+
changeCoverage={metricsMarkersBySource?.get(
|
|
1525
|
+
observation.source.id,
|
|
1526
|
+
)}
|
|
1090
1527
|
/>
|
|
1091
1528
|
) : null}
|
|
1092
1529
|
{meaningfulHistory ? (
|
|
1093
1530
|
<RevisionHistory
|
|
1094
1531
|
observations={previousObservations}
|
|
1095
1532
|
citedOrder={citedOrder}
|
|
1533
|
+
caseItems={revisionItems}
|
|
1096
1534
|
reveal={revealHistory}
|
|
1097
1535
|
revealRequestId={revealRequestId}
|
|
1098
1536
|
onViewSource={onViewSource}
|
|
@@ -1146,6 +1584,36 @@ function OpenResourceButton({
|
|
|
1146
1584
|
);
|
|
1147
1585
|
}
|
|
1148
1586
|
|
|
1587
|
+
function OpenTimelineButton({
|
|
1588
|
+
scope,
|
|
1589
|
+
onOpenTimeline,
|
|
1590
|
+
}: {
|
|
1591
|
+
scope: InvestigationTimelineScope;
|
|
1592
|
+
onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
|
|
1593
|
+
}) {
|
|
1594
|
+
if (!onOpenTimeline) return null;
|
|
1595
|
+
const identity = `${scope.namespace ? `${scope.namespace}/` : ""}${scope.name}`;
|
|
1596
|
+
const label = `Open the Timeline for ${identity}`;
|
|
1597
|
+
return (
|
|
1598
|
+
<Tooltip
|
|
1599
|
+
content={label}
|
|
1600
|
+
delay={350}
|
|
1601
|
+
position="left"
|
|
1602
|
+
wrapperClassName="flex shrink-0"
|
|
1603
|
+
>
|
|
1604
|
+
<button
|
|
1605
|
+
type="button"
|
|
1606
|
+
aria-label={label}
|
|
1607
|
+
onClick={() => onOpenTimeline(scope)}
|
|
1608
|
+
className="flex h-7 shrink-0 items-center justify-center gap-1 rounded px-2 text-xs font-medium text-theme-text-tertiary transition-colors hover:bg-theme-hover hover:text-accent-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
1609
|
+
>
|
|
1610
|
+
<FileClock className="h-3.5 w-3.5" aria-hidden />
|
|
1611
|
+
<span className="hidden @min-[540px]/card:inline">Timeline</span>
|
|
1612
|
+
</button>
|
|
1613
|
+
</Tooltip>
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1149
1617
|
function uniquePrimarySources(
|
|
1150
1618
|
group: InvestigationEvidenceGroup,
|
|
1151
1619
|
): InvestigationEvidenceSource[] {
|
|
@@ -1164,9 +1632,12 @@ function uniquePrimarySources(
|
|
|
1164
1632
|
function EvidenceBody({
|
|
1165
1633
|
data,
|
|
1166
1634
|
cardSummary,
|
|
1635
|
+
changeCoverage,
|
|
1167
1636
|
}: {
|
|
1168
1637
|
data: InvestigationEvidenceData;
|
|
1169
1638
|
cardSummary?: string;
|
|
1639
|
+
/** Change markers for a metrics chart; derived by the pane, never by data. */
|
|
1640
|
+
changeCoverage?: MetricsChangeCoverage;
|
|
1170
1641
|
}) {
|
|
1171
1642
|
switch (data.type) {
|
|
1172
1643
|
case "issue":
|
|
@@ -1194,9 +1665,20 @@ function EvidenceBody({
|
|
|
1194
1665
|
case "inventory":
|
|
1195
1666
|
return <InventoryBody data={data} />;
|
|
1196
1667
|
case "receipt":
|
|
1197
|
-
|
|
1668
|
+
// The title and the scope above it are the answer. A body appears only
|
|
1669
|
+
// when it adds the reason or the limit, so an absent one renders nothing
|
|
1670
|
+
// rather than an empty line.
|
|
1671
|
+
return data.message ? (
|
|
1198
1672
|
<p className="text-xs text-theme-text-secondary">{data.message}</p>
|
|
1199
|
-
);
|
|
1673
|
+
) : null;
|
|
1674
|
+
case "alerts":
|
|
1675
|
+
return <AlertsBody data={data} />;
|
|
1676
|
+
case "helm":
|
|
1677
|
+
return <HelmBody data={data} />;
|
|
1678
|
+
case "permissions":
|
|
1679
|
+
return <PermissionsBody data={data} />;
|
|
1680
|
+
case "metrics":
|
|
1681
|
+
return <MetricsBody data={data} changeCoverage={changeCoverage} />;
|
|
1200
1682
|
}
|
|
1201
1683
|
}
|
|
1202
1684
|
|
|
@@ -1211,10 +1693,12 @@ function evidenceHasDetails(
|
|
|
1211
1693
|
const message = data.issue.message?.trim();
|
|
1212
1694
|
return Boolean(
|
|
1213
1695
|
(cause && cause !== summary) ||
|
|
1214
|
-
(message && message !== summary && message !== cause)
|
|
1696
|
+
(message && message !== summary && message !== cause) ||
|
|
1697
|
+
data.pods?.length,
|
|
1215
1698
|
);
|
|
1216
1699
|
}
|
|
1217
1700
|
case "startup":
|
|
1701
|
+
return (data.pods?.length ?? 0) > 1;
|
|
1218
1702
|
case "receipt":
|
|
1219
1703
|
return false;
|
|
1220
1704
|
case "resource": {
|
|
@@ -1223,6 +1707,7 @@ function evidenceHasDetails(
|
|
|
1223
1707
|
investigationResourceEvidenceHasDetails(data.resource) ||
|
|
1224
1708
|
replicas?.desired !== undefined ||
|
|
1225
1709
|
data.resourceContext?.statusSummary?.conditions?.length ||
|
|
1710
|
+
diagnosedScalers(data.resourceContext).length ||
|
|
1226
1711
|
data.gitOpsDiagnosis ||
|
|
1227
1712
|
data.warnings.length,
|
|
1228
1713
|
);
|
|
@@ -1271,6 +1756,18 @@ function IssueBody({
|
|
|
1271
1756
|
{issue.namespace ? `${issue.namespace}/` : ""}
|
|
1272
1757
|
{issue.name}
|
|
1273
1758
|
</Badge>
|
|
1759
|
+
{data.pods && data.pods.length > 0 ? (
|
|
1760
|
+
<>
|
|
1761
|
+
<Badge tone="structural" size="sm">
|
|
1762
|
+
{data.pods.length === 1 ? "1 Pod" : `${data.pods.length} Pods`}
|
|
1763
|
+
</Badge>
|
|
1764
|
+
{data.pods.map((pod) => (
|
|
1765
|
+
<Badge key={pod} tone="structural" size="sm">
|
|
1766
|
+
{pod}
|
|
1767
|
+
</Badge>
|
|
1768
|
+
))}
|
|
1769
|
+
</>
|
|
1770
|
+
) : null}
|
|
1274
1771
|
</div>
|
|
1275
1772
|
{showCause ? (
|
|
1276
1773
|
<p className="text-sm font-medium leading-relaxed text-theme-text-primary">
|
|
@@ -1288,15 +1785,18 @@ function IssueBody({
|
|
|
1288
1785
|
|
|
1289
1786
|
function StartupBody({ data }: { data: EvidenceDataOf<"startup"> }) {
|
|
1290
1787
|
const blocker = data.blocker;
|
|
1788
|
+
const pods = data.pods && data.pods.length > 1 ? data.pods : [blocker.name];
|
|
1291
1789
|
return (
|
|
1292
1790
|
<div className="space-y-2">
|
|
1293
1791
|
<div className="flex flex-wrap gap-1.5">
|
|
1294
1792
|
<Badge tone="structural" size="sm">
|
|
1295
|
-
{blocker.kind}
|
|
1296
|
-
</Badge>
|
|
1297
|
-
<Badge tone="structural" size="sm">
|
|
1298
|
-
{blocker.name}
|
|
1793
|
+
{pods.length > 1 ? `${pods.length} ${blocker.kind}s` : blocker.kind}
|
|
1299
1794
|
</Badge>
|
|
1795
|
+
{pods.map((pod) => (
|
|
1796
|
+
<Badge key={pod} tone="structural" size="sm">
|
|
1797
|
+
{pod}
|
|
1798
|
+
</Badge>
|
|
1799
|
+
))}
|
|
1300
1800
|
<Badge severity={severityBadge(blocker.severity)} size="sm">
|
|
1301
1801
|
{blocker.severity}
|
|
1302
1802
|
</Badge>
|
|
@@ -1343,6 +1843,7 @@ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
|
|
|
1343
1843
|
const ready = replicas ? (replicas.ready ?? 0) : undefined;
|
|
1344
1844
|
const shortfall =
|
|
1345
1845
|
desired !== undefined && ready !== undefined && ready < desired;
|
|
1846
|
+
const scalers = diagnosedScalers(data.resourceContext);
|
|
1346
1847
|
return (
|
|
1347
1848
|
<div className="space-y-3">
|
|
1348
1849
|
<InvestigationResourceEvidence resource={data.resource} />
|
|
@@ -1398,6 +1899,12 @@ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
|
|
|
1398
1899
|
</div>
|
|
1399
1900
|
</div>
|
|
1400
1901
|
) : null}
|
|
1902
|
+
{scalers.length > 0 ? (
|
|
1903
|
+
<ScaledBySection
|
|
1904
|
+
scalers={scalers}
|
|
1905
|
+
allScalers={data.resourceContext?.scaledBy ?? []}
|
|
1906
|
+
/>
|
|
1907
|
+
) : null}
|
|
1401
1908
|
{data.warnings.length > 0 ? (
|
|
1402
1909
|
<ul className="space-y-1 text-xs text-theme-text-secondary">
|
|
1403
1910
|
{data.warnings.map((warning) => (
|
|
@@ -1412,6 +1919,75 @@ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
|
|
|
1412
1919
|
);
|
|
1413
1920
|
}
|
|
1414
1921
|
|
|
1922
|
+
type DiagnosedScaler = DiagnosisScalerRef &
|
|
1923
|
+
Required<Pick<DiagnosisScalerRef, "hpaSummary">>;
|
|
1924
|
+
|
|
1925
|
+
function diagnosedScalers(
|
|
1926
|
+
context: DiagnosisResourceContext | undefined,
|
|
1927
|
+
): DiagnosedScaler[] {
|
|
1928
|
+
return (context?.scaledBy ?? []).filter(
|
|
1929
|
+
(scaler): scaler is DiagnosedScaler => scaler.hpaSummary !== undefined,
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
function ScaledBySection({
|
|
1934
|
+
scalers,
|
|
1935
|
+
allScalers,
|
|
1936
|
+
}: {
|
|
1937
|
+
scalers: DiagnosedScaler[];
|
|
1938
|
+
allScalers: DiagnosisScalerRef[];
|
|
1939
|
+
}) {
|
|
1940
|
+
const { onOpenResource } = useContext(EvidenceNavigationContext);
|
|
1941
|
+
// The ScaledObject is only a link when Radar listed it as a scaler too; a
|
|
1942
|
+
// name read off the HPA's owner reference alone is not a resource we hold.
|
|
1943
|
+
const listedScaler = (ref: DiagnosisResourceRef) =>
|
|
1944
|
+
allScalers.some(
|
|
1945
|
+
(scaler) =>
|
|
1946
|
+
scaler.kind === ref.kind &&
|
|
1947
|
+
scaler.name === ref.name &&
|
|
1948
|
+
(scaler.namespace ?? "") === (ref.namespace ?? ""),
|
|
1949
|
+
);
|
|
1950
|
+
return (
|
|
1951
|
+
<div>
|
|
1952
|
+
<div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
1953
|
+
Scaled by
|
|
1954
|
+
</div>
|
|
1955
|
+
<div className="space-y-2">
|
|
1956
|
+
{scalers.map((scaler) => (
|
|
1957
|
+
<HPADiagnosisSummary
|
|
1958
|
+
key={`${scaler.namespace ?? ""}/${scaler.name}`}
|
|
1959
|
+
diagnosis={scaler.hpaSummary}
|
|
1960
|
+
variant="inline"
|
|
1961
|
+
header={
|
|
1962
|
+
<>
|
|
1963
|
+
<span className="font-medium text-theme-text-primary">
|
|
1964
|
+
{displayKind(scaler.kind)} {scaler.name}
|
|
1965
|
+
</span>
|
|
1966
|
+
{scaler.managedBy ? (
|
|
1967
|
+
<span className="text-theme-text-tertiary">
|
|
1968
|
+
managed by KEDA {displayKind(scaler.managedBy.kind)}{" "}
|
|
1969
|
+
<ResourceLink
|
|
1970
|
+
name={scaler.managedBy.name}
|
|
1971
|
+
kind={scaler.managedBy.kind}
|
|
1972
|
+
namespace={scaler.managedBy.namespace ?? ""}
|
|
1973
|
+
group={scaler.managedBy.group}
|
|
1974
|
+
onNavigate={
|
|
1975
|
+
onOpenResource && listedScaler(scaler.managedBy)
|
|
1976
|
+
? (ref) => onOpenResource(ref)
|
|
1977
|
+
: undefined
|
|
1978
|
+
}
|
|
1979
|
+
/>
|
|
1980
|
+
</span>
|
|
1981
|
+
) : null}
|
|
1982
|
+
</>
|
|
1983
|
+
}
|
|
1984
|
+
/>
|
|
1985
|
+
))}
|
|
1986
|
+
</div>
|
|
1987
|
+
</div>
|
|
1988
|
+
);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1415
1991
|
function GitOpsStatusBody({
|
|
1416
1992
|
status,
|
|
1417
1993
|
}: {
|
|
@@ -1585,7 +2161,11 @@ function EventsBody({ data }: { data: EvidenceDataOf<"events"> }) {
|
|
|
1585
2161
|
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
|
|
1586
2162
|
<span className="text-xs font-semibold text-theme-text-primary">
|
|
1587
2163
|
{event.reason}
|
|
1588
|
-
{event.count > 1 ?
|
|
2164
|
+
{event.count > 1 ? (
|
|
2165
|
+
<span className="ml-1.5 font-mono text-[11px] font-normal text-theme-text-tertiary">
|
|
2166
|
+
×{event.count}
|
|
2167
|
+
</span>
|
|
2168
|
+
) : null}
|
|
1589
2169
|
</span>
|
|
1590
2170
|
<Tooltip
|
|
1591
2171
|
content={new Date(event.lastTimestamp).toLocaleString()}
|
|
@@ -1958,6 +2538,222 @@ function TopologyStat({ label, value }: { label: string; value: number }) {
|
|
|
1958
2538
|
);
|
|
1959
2539
|
}
|
|
1960
2540
|
|
|
2541
|
+
const METRICS_AXIS_LABEL_MAX_CHARS = 72;
|
|
2542
|
+
|
|
2543
|
+
function finiteSamples(series: TimeSeries): TimeSeries["dataPoints"] {
|
|
2544
|
+
return series.dataPoints.filter((point) => typeof point.value === "number");
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
function MetricsValueTable({
|
|
2548
|
+
series,
|
|
2549
|
+
labels,
|
|
2550
|
+
unit,
|
|
2551
|
+
withTime,
|
|
2552
|
+
}: {
|
|
2553
|
+
series: TimeSeries[];
|
|
2554
|
+
/** Display name per series, derived from the complete result. */
|
|
2555
|
+
labels: string[];
|
|
2556
|
+
unit: string;
|
|
2557
|
+
withTime: boolean;
|
|
2558
|
+
}) {
|
|
2559
|
+
return (
|
|
2560
|
+
<table className="w-full text-xs">
|
|
2561
|
+
<tbody>
|
|
2562
|
+
{series.map((item, index) => {
|
|
2563
|
+
const sample = finiteSamples(item).at(-1);
|
|
2564
|
+
return (
|
|
2565
|
+
<tr
|
|
2566
|
+
key={`${labels[index]}-${index}`}
|
|
2567
|
+
className="border-b border-theme-border/60 last:border-b-0"
|
|
2568
|
+
>
|
|
2569
|
+
<td className="py-1 pr-3 font-mono text-theme-text-secondary [overflow-wrap:anywhere]">
|
|
2570
|
+
{labels[index]}
|
|
2571
|
+
</td>
|
|
2572
|
+
{withTime ? (
|
|
2573
|
+
<td className="py-1 pr-3 text-right text-theme-text-tertiary tabular-nums">
|
|
2574
|
+
{sample
|
|
2575
|
+
? new Date(sample.timestamp * 1000).toLocaleTimeString()
|
|
2576
|
+
: ""}
|
|
2577
|
+
</td>
|
|
2578
|
+
) : null}
|
|
2579
|
+
<td className="py-1 text-right font-mono tabular-nums text-theme-text-primary">
|
|
2580
|
+
{sample && typeof sample.value === "number"
|
|
2581
|
+
? formatMetricValue(sample.value, unit)
|
|
2582
|
+
: "no value"}
|
|
2583
|
+
</td>
|
|
2584
|
+
</tr>
|
|
2585
|
+
);
|
|
2586
|
+
})}
|
|
2587
|
+
</tbody>
|
|
2588
|
+
</table>
|
|
2589
|
+
);
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
function MetricsBody({
|
|
2593
|
+
data,
|
|
2594
|
+
changeCoverage,
|
|
2595
|
+
}: {
|
|
2596
|
+
data: EvidenceDataOf<"metrics">;
|
|
2597
|
+
changeCoverage?: MetricsChangeCoverage;
|
|
2598
|
+
}) {
|
|
2599
|
+
const annotations = changeCoverage?.markers;
|
|
2600
|
+
const unit = data.unit ?? "";
|
|
2601
|
+
const axisLabel = data.label ?? data.query;
|
|
2602
|
+
const axisTruncated = axisLabel.length > METRICS_AXIS_LABEL_MAX_CHARS;
|
|
2603
|
+
const axisText = axisTruncated
|
|
2604
|
+
? `${axisLabel.slice(0, METRICS_AXIS_LABEL_MAX_CHARS - 1)}…`
|
|
2605
|
+
: axisLabel;
|
|
2606
|
+
const domain = metricsDomain(data);
|
|
2607
|
+
const windowText = domain
|
|
2608
|
+
? `${new Date(domain.start * 1000).toLocaleString()} to ${new Date(domain.end * 1000).toLocaleString()}`
|
|
2609
|
+
: undefined;
|
|
2610
|
+
if (data.series.length === 0) {
|
|
2611
|
+
return (
|
|
2612
|
+
<div className="space-y-2">
|
|
2613
|
+
<p className="text-xs text-theme-text-secondary">
|
|
2614
|
+
No series matched this query
|
|
2615
|
+
{data.mode === "range" ? " in the window" : ""}.
|
|
2616
|
+
</p>
|
|
2617
|
+
<pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
|
|
2618
|
+
{data.query}
|
|
2619
|
+
</pre>
|
|
2620
|
+
{data.note ? (
|
|
2621
|
+
<p className="text-xs text-theme-text-tertiary">{data.note}</p>
|
|
2622
|
+
) : null}
|
|
2623
|
+
</div>
|
|
2624
|
+
);
|
|
2625
|
+
}
|
|
2626
|
+
if (data.mode === "instant") {
|
|
2627
|
+
return (
|
|
2628
|
+
<div className="space-y-2">
|
|
2629
|
+
<MetricsValueTable
|
|
2630
|
+
series={data.series}
|
|
2631
|
+
labels={seriesDisplayLabels(data.series)}
|
|
2632
|
+
unit={unit}
|
|
2633
|
+
withTime={false}
|
|
2634
|
+
/>
|
|
2635
|
+
<pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
|
|
2636
|
+
{data.query}
|
|
2637
|
+
</pre>
|
|
2638
|
+
{data.note ? (
|
|
2639
|
+
<p className="text-xs text-theme-text-tertiary">{data.note}</p>
|
|
2640
|
+
) : null}
|
|
2641
|
+
</div>
|
|
2642
|
+
);
|
|
2643
|
+
}
|
|
2644
|
+
// Names come from the whole result so two series that differ only by a
|
|
2645
|
+
// label the chart would hide stay distinguishable wherever they are listed.
|
|
2646
|
+
const labels = seriesDisplayLabels(data.series);
|
|
2647
|
+
const indexed = data.series.map((item, index) => ({
|
|
2648
|
+
item,
|
|
2649
|
+
label: labels[index],
|
|
2650
|
+
finite: finiteSamples(item).length,
|
|
2651
|
+
}));
|
|
2652
|
+
// A series with one finite sample has no line to draw, and one with none
|
|
2653
|
+
// (Prometheus serializes NaN and infinities as gaps) has nothing to show.
|
|
2654
|
+
// Listing both keeps what was captured readable and states what was not.
|
|
2655
|
+
const charted = indexed.filter((entry) => entry.finite >= 2);
|
|
2656
|
+
const single = indexed.filter((entry) => entry.finite === 1);
|
|
2657
|
+
const empty = indexed.filter((entry) => entry.finite === 0);
|
|
2658
|
+
return (
|
|
2659
|
+
<div className="space-y-2">
|
|
2660
|
+
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs">
|
|
2661
|
+
<Tooltip
|
|
2662
|
+
content={axisLabel}
|
|
2663
|
+
delay={200}
|
|
2664
|
+
position="top"
|
|
2665
|
+
disabled={!axisTruncated}
|
|
2666
|
+
>
|
|
2667
|
+
<span
|
|
2668
|
+
className="min-w-0 truncate font-mono text-theme-text-secondary"
|
|
2669
|
+
data-testid="investigation-metrics-axis-label"
|
|
2670
|
+
>
|
|
2671
|
+
{axisText}
|
|
2672
|
+
</span>
|
|
2673
|
+
</Tooltip>
|
|
2674
|
+
{unit ? (
|
|
2675
|
+
<span className="text-theme-text-tertiary">({unit})</span>
|
|
2676
|
+
) : null}
|
|
2677
|
+
{windowText ? (
|
|
2678
|
+
<span className="ml-auto text-theme-text-tertiary">{windowText}</span>
|
|
2679
|
+
) : null}
|
|
2680
|
+
</div>
|
|
2681
|
+
{charted.length > 0 ? (
|
|
2682
|
+
<div className="space-y-1.5 rounded-md border border-theme-border/70 bg-theme-base/30 p-2">
|
|
2683
|
+
<AreaChart
|
|
2684
|
+
series={charted.map((entry) => entry.item)}
|
|
2685
|
+
seriesLabels={charted.map((entry) => entry.label)}
|
|
2686
|
+
color={SERIES_COLORS[0]}
|
|
2687
|
+
fillColor={seriesFill(0, SERIES_COLORS[0])}
|
|
2688
|
+
unit={unit}
|
|
2689
|
+
annotations={annotations}
|
|
2690
|
+
domain={domain}
|
|
2691
|
+
layout="auto"
|
|
2692
|
+
/>
|
|
2693
|
+
{charted.length > 1 ? (
|
|
2694
|
+
<SeriesLegend
|
|
2695
|
+
series={charted.map((entry) => entry.item)}
|
|
2696
|
+
seriesLabels={charted.map((entry) => entry.label)}
|
|
2697
|
+
color={SERIES_COLORS[0]}
|
|
2698
|
+
/>
|
|
2699
|
+
) : null}
|
|
2700
|
+
</div>
|
|
2701
|
+
) : null}
|
|
2702
|
+
{single.length > 0 ? (
|
|
2703
|
+
<div
|
|
2704
|
+
className="space-y-1"
|
|
2705
|
+
data-testid="investigation-metrics-sparse-series"
|
|
2706
|
+
>
|
|
2707
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
2708
|
+
{single.length === 1
|
|
2709
|
+
? "1 series has a single sample in this window, listed with its time:"
|
|
2710
|
+
: `${single.length} series have a single sample in this window, listed with their times:`}
|
|
2711
|
+
</p>
|
|
2712
|
+
<MetricsValueTable
|
|
2713
|
+
series={single.map((entry) => entry.item)}
|
|
2714
|
+
labels={single.map((entry) => entry.label)}
|
|
2715
|
+
unit={unit}
|
|
2716
|
+
withTime
|
|
2717
|
+
/>
|
|
2718
|
+
</div>
|
|
2719
|
+
) : null}
|
|
2720
|
+
{empty.length > 0 ? (
|
|
2721
|
+
<p
|
|
2722
|
+
className="text-xs text-theme-text-tertiary [overflow-wrap:anywhere]"
|
|
2723
|
+
data-testid="investigation-metrics-empty-series"
|
|
2724
|
+
>
|
|
2725
|
+
{empty.length === 1
|
|
2726
|
+
? "1 series had no usable samples in this window: "
|
|
2727
|
+
: `${empty.length} series had no usable samples in this window: `}
|
|
2728
|
+
<span className="font-mono">
|
|
2729
|
+
{empty.map((entry) => entry.label).join("; ")}
|
|
2730
|
+
</span>
|
|
2731
|
+
</p>
|
|
2732
|
+
) : null}
|
|
2733
|
+
{changeCoverage?.checked && charted.length > 0 ? (
|
|
2734
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
2735
|
+
{annotations?.length === 1
|
|
2736
|
+
? "1 change recorded in this window is marked on the chart."
|
|
2737
|
+
: annotations?.length
|
|
2738
|
+
? `${annotations.length} changes recorded in this window are marked on the chart.`
|
|
2739
|
+
: // Only that the changes Radar read miss this window — not that
|
|
2740
|
+
// the window was fully covered. The change lookup has its own
|
|
2741
|
+
// window, which need not span the chart's.
|
|
2742
|
+
"None of the changes Radar read fall in this window."}
|
|
2743
|
+
</p>
|
|
2744
|
+
) : null}
|
|
2745
|
+
{axisTruncated ? (
|
|
2746
|
+
<pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
|
|
2747
|
+
{data.query}
|
|
2748
|
+
</pre>
|
|
2749
|
+
) : null}
|
|
2750
|
+
{data.note ? (
|
|
2751
|
+
<p className="text-xs text-theme-text-tertiary">{data.note}</p>
|
|
2752
|
+
) : null}
|
|
2753
|
+
</div>
|
|
2754
|
+
);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
1961
2757
|
function InventoryBody({ data }: { data: EvidenceDataOf<"inventory"> }) {
|
|
1962
2758
|
return (
|
|
1963
2759
|
<div className="max-h-72 overflow-y-auto rounded-md border border-theme-border">
|
|
@@ -2003,15 +2799,456 @@ function InventoryBody({ data }: { data: EvidenceDataOf<"inventory"> }) {
|
|
|
2003
2799
|
);
|
|
2004
2800
|
}
|
|
2005
2801
|
|
|
2802
|
+
const VISIBLE_ALERT_LABELS = 6;
|
|
2803
|
+
|
|
2804
|
+
function alertStateSeverity(state: string | undefined) {
|
|
2805
|
+
switch ((state ?? "").toLowerCase()) {
|
|
2806
|
+
case "firing":
|
|
2807
|
+
return "error" as const;
|
|
2808
|
+
case "pending":
|
|
2809
|
+
return "warning" as const;
|
|
2810
|
+
case "inactive":
|
|
2811
|
+
return "success" as const;
|
|
2812
|
+
default:
|
|
2813
|
+
return "neutral" as const;
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
function AlertsBody({ data }: { data: EvidenceDataOf<"alerts"> }) {
|
|
2818
|
+
const { rule, instances, annotations } = data;
|
|
2819
|
+
const health = rule.health?.toLowerCase();
|
|
2820
|
+
// Target instances lead; the rest stay visible in producer order.
|
|
2821
|
+
const ordered = [...instances].sort(
|
|
2822
|
+
(left, right) => Number(right.namesTarget) - Number(left.namesTarget),
|
|
2823
|
+
);
|
|
2824
|
+
const annotationEntries = Object.entries(annotations);
|
|
2825
|
+
return (
|
|
2826
|
+
<div className="space-y-2.5">
|
|
2827
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
2828
|
+
{rule.state ? (
|
|
2829
|
+
<Badge severity={alertStateSeverity(rule.state)} size="sm">
|
|
2830
|
+
{rule.state}
|
|
2831
|
+
</Badge>
|
|
2832
|
+
) : null}
|
|
2833
|
+
{rule.labels.severity ? (
|
|
2834
|
+
<Badge severity={severityBadge(rule.labels.severity)} size="sm">
|
|
2835
|
+
severity {rule.labels.severity}
|
|
2836
|
+
</Badge>
|
|
2837
|
+
) : null}
|
|
2838
|
+
<Badge tone="structural" size="sm">
|
|
2839
|
+
{rule.group}
|
|
2840
|
+
</Badge>
|
|
2841
|
+
{health && health !== "ok" ? (
|
|
2842
|
+
<Badge severity={health === "err" ? "error" : "neutral"} size="sm">
|
|
2843
|
+
health {health}
|
|
2844
|
+
</Badge>
|
|
2845
|
+
) : null}
|
|
2846
|
+
</div>
|
|
2847
|
+
{ordered.length > 0 ? (
|
|
2848
|
+
<ol className="max-h-64 space-y-1.5 overflow-y-auto pr-1">
|
|
2849
|
+
{ordered.map((instance, index) => {
|
|
2850
|
+
const labels = Object.entries(instance.labels).filter(
|
|
2851
|
+
([key]) => key !== "alertname" && key !== "severity",
|
|
2852
|
+
);
|
|
2853
|
+
const hidden = labels.length - VISIBLE_ALERT_LABELS;
|
|
2854
|
+
return (
|
|
2855
|
+
<li
|
|
2856
|
+
key={`${instance.state}-${index}-${labels.map(([k, v]) => `${k}=${v}`).join(",")}`}
|
|
2857
|
+
className="rounded-md border border-theme-border/70 bg-theme-base/30 px-2.5 py-2"
|
|
2858
|
+
>
|
|
2859
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
2860
|
+
<Badge
|
|
2861
|
+
severity={alertStateSeverity(instance.state)}
|
|
2862
|
+
size="sm"
|
|
2863
|
+
>
|
|
2864
|
+
{instance.state}
|
|
2865
|
+
</Badge>
|
|
2866
|
+
{instance.namesTarget ? (
|
|
2867
|
+
<Badge severity="info" size="sm">
|
|
2868
|
+
names this resource
|
|
2869
|
+
</Badge>
|
|
2870
|
+
) : null}
|
|
2871
|
+
{instance.value ? (
|
|
2872
|
+
<span className="font-mono text-xs text-theme-text-secondary">
|
|
2873
|
+
value {instance.value}
|
|
2874
|
+
</span>
|
|
2875
|
+
) : null}
|
|
2876
|
+
{instance.activeAt ? (
|
|
2877
|
+
<Tooltip
|
|
2878
|
+
content={new Date(instance.activeAt).toLocaleString()}
|
|
2879
|
+
delay={150}
|
|
2880
|
+
position="left"
|
|
2881
|
+
wrapperClassName="ml-auto"
|
|
2882
|
+
>
|
|
2883
|
+
<time
|
|
2884
|
+
dateTime={instance.activeAt}
|
|
2885
|
+
className="text-xs text-theme-text-tertiary"
|
|
2886
|
+
>
|
|
2887
|
+
active {formatRelativeAgeTime(instance.activeAt)}
|
|
2888
|
+
</time>
|
|
2889
|
+
</Tooltip>
|
|
2890
|
+
) : null}
|
|
2891
|
+
</div>
|
|
2892
|
+
{labels.length > 0 ? (
|
|
2893
|
+
<div className="mt-1.5 flex flex-wrap gap-1 font-mono text-[11px] text-theme-text-secondary">
|
|
2894
|
+
{labels
|
|
2895
|
+
.slice(0, VISIBLE_ALERT_LABELS)
|
|
2896
|
+
.map(([key, value]) => (
|
|
2897
|
+
<span
|
|
2898
|
+
key={key}
|
|
2899
|
+
className="rounded bg-theme-base px-1.5 py-0.5 [overflow-wrap:anywhere]"
|
|
2900
|
+
>
|
|
2901
|
+
{key}={value}
|
|
2902
|
+
</span>
|
|
2903
|
+
))}
|
|
2904
|
+
{hidden > 0 ? (
|
|
2905
|
+
<span className="px-1 py-0.5 text-theme-text-tertiary">
|
|
2906
|
+
+{hidden} more
|
|
2907
|
+
</span>
|
|
2908
|
+
) : null}
|
|
2909
|
+
</div>
|
|
2910
|
+
) : null}
|
|
2911
|
+
</li>
|
|
2912
|
+
);
|
|
2913
|
+
})}
|
|
2914
|
+
</ol>
|
|
2915
|
+
) : (
|
|
2916
|
+
<p className="text-xs italic text-theme-text-tertiary">
|
|
2917
|
+
No active instances were reported for this rule.
|
|
2918
|
+
</p>
|
|
2919
|
+
)}
|
|
2920
|
+
{annotationEntries.length > 0 ? (
|
|
2921
|
+
<dl className="space-y-1 text-xs">
|
|
2922
|
+
{annotationEntries.map(([key, value]) => (
|
|
2923
|
+
<div key={key} className="flex min-w-0 gap-2">
|
|
2924
|
+
<dt className="shrink-0 text-theme-text-tertiary">{key}</dt>
|
|
2925
|
+
<dd className="min-w-0 leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
|
|
2926
|
+
{value}
|
|
2927
|
+
</dd>
|
|
2928
|
+
</div>
|
|
2929
|
+
))}
|
|
2930
|
+
</dl>
|
|
2931
|
+
) : null}
|
|
2932
|
+
{rule.query ? (
|
|
2933
|
+
<TerminalBlock label="Rule expression">{rule.query}</TerminalBlock>
|
|
2934
|
+
) : null}
|
|
2935
|
+
</div>
|
|
2936
|
+
);
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
function helmStatusSeverity(status: string) {
|
|
2940
|
+
const normalized = status.toLowerCase();
|
|
2941
|
+
if (normalized === "deployed") return "success" as const;
|
|
2942
|
+
if (normalized.includes("failed")) return "error" as const;
|
|
2943
|
+
if (normalized.startsWith("pending") || normalized === "uninstalling")
|
|
2944
|
+
return "info" as const;
|
|
2945
|
+
return "warning" as const;
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
function HelmBody({ data }: { data: EvidenceDataOf<"helm"> }) {
|
|
2949
|
+
const { onOpenResource } = useContext(EvidenceNavigationContext);
|
|
2950
|
+
const { release } = data;
|
|
2951
|
+
const operation = release.lastOperation;
|
|
2952
|
+
return (
|
|
2953
|
+
<div className="space-y-3">
|
|
2954
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
2955
|
+
<Badge severity={helmStatusSeverity(release.status)} size="sm">
|
|
2956
|
+
{release.status}
|
|
2957
|
+
</Badge>
|
|
2958
|
+
<Badge tone="structural" size="sm">
|
|
2959
|
+
{release.chart}
|
|
2960
|
+
{release.chartVersion ? ` ${release.chartVersion}` : ""}
|
|
2961
|
+
</Badge>
|
|
2962
|
+
<Badge tone="structural" size="sm">
|
|
2963
|
+
revision {release.revision}
|
|
2964
|
+
</Badge>
|
|
2965
|
+
{release.appVersion ? (
|
|
2966
|
+
<Badge tone="note" size="sm">
|
|
2967
|
+
app {release.appVersion}
|
|
2968
|
+
</Badge>
|
|
2969
|
+
) : null}
|
|
2970
|
+
{release.resourceHealth ? (
|
|
2971
|
+
<Badge
|
|
2972
|
+
severity={
|
|
2973
|
+
mapHealthToTone(release.resourceHealth) === "healthy"
|
|
2974
|
+
? "success"
|
|
2975
|
+
: mapHealthToTone(release.resourceHealth) === "unhealthy"
|
|
2976
|
+
? "error"
|
|
2977
|
+
: "warning"
|
|
2978
|
+
}
|
|
2979
|
+
size="sm"
|
|
2980
|
+
>
|
|
2981
|
+
resources {release.resourceHealth}
|
|
2982
|
+
</Badge>
|
|
2983
|
+
) : null}
|
|
2984
|
+
<Tooltip
|
|
2985
|
+
content={new Date(release.updated).toLocaleString()}
|
|
2986
|
+
delay={150}
|
|
2987
|
+
position="left"
|
|
2988
|
+
wrapperClassName="ml-auto"
|
|
2989
|
+
>
|
|
2990
|
+
<time
|
|
2991
|
+
dateTime={release.updated}
|
|
2992
|
+
className="text-xs text-theme-text-tertiary"
|
|
2993
|
+
>
|
|
2994
|
+
updated {formatRelativeAgeTime(release.updated)}
|
|
2995
|
+
</time>
|
|
2996
|
+
</Tooltip>
|
|
2997
|
+
</div>
|
|
2998
|
+
{release.healthIssue ? (
|
|
2999
|
+
<p className="text-xs leading-relaxed text-warning-text">
|
|
3000
|
+
{release.healthIssue}
|
|
3001
|
+
</p>
|
|
3002
|
+
) : null}
|
|
3003
|
+
{release.healthSummary &&
|
|
3004
|
+
release.healthSummary !== release.healthIssue ? (
|
|
3005
|
+
<p className="text-xs leading-relaxed text-theme-text-secondary">
|
|
3006
|
+
{release.healthSummary}
|
|
3007
|
+
</p>
|
|
3008
|
+
) : null}
|
|
3009
|
+
{operation ? (
|
|
3010
|
+
<div className="rounded-md border border-theme-border bg-theme-base/40 px-2.5 py-2">
|
|
3011
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
3012
|
+
<span className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
3013
|
+
Last operation
|
|
3014
|
+
</span>
|
|
3015
|
+
<Badge tone="note" size="sm">
|
|
3016
|
+
{operation.kind.replaceAll("_", " ")}
|
|
3017
|
+
</Badge>
|
|
3018
|
+
<Badge
|
|
3019
|
+
severity={
|
|
3020
|
+
operation.status === "completed"
|
|
3021
|
+
? "success"
|
|
3022
|
+
: operation.status === "failed"
|
|
3023
|
+
? "error"
|
|
3024
|
+
: "warning"
|
|
3025
|
+
}
|
|
3026
|
+
size="sm"
|
|
3027
|
+
>
|
|
3028
|
+
{operation.status.replaceAll("_", " ")}
|
|
3029
|
+
</Badge>
|
|
3030
|
+
</div>
|
|
3031
|
+
{operation.message ? (
|
|
3032
|
+
<p className="mt-1 text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
|
|
3033
|
+
{operation.message}
|
|
3034
|
+
</p>
|
|
3035
|
+
) : null}
|
|
3036
|
+
</div>
|
|
3037
|
+
) : null}
|
|
3038
|
+
{release.description ? (
|
|
3039
|
+
<p className="text-xs text-theme-text-tertiary [overflow-wrap:anywhere]">
|
|
3040
|
+
{release.description}
|
|
3041
|
+
</p>
|
|
3042
|
+
) : null}
|
|
3043
|
+
{release.storageNamespace &&
|
|
3044
|
+
release.storageNamespace !== release.namespace ? (
|
|
3045
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
3046
|
+
Release metadata stored in namespace {release.storageNamespace}
|
|
3047
|
+
</p>
|
|
3048
|
+
) : null}
|
|
3049
|
+
{release.managedByFluxHelmRelease ? (
|
|
3050
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
3051
|
+
Managed by Flux HelmRelease {release.managedByFluxHelmRelease}
|
|
3052
|
+
</p>
|
|
3053
|
+
) : null}
|
|
3054
|
+
{release.resources.length > 0 ? (
|
|
3055
|
+
<div className="max-h-52 overflow-y-auto rounded-md border border-theme-border">
|
|
3056
|
+
{release.resources.map((owned, index) => (
|
|
3057
|
+
<div
|
|
3058
|
+
key={`${owned.kind}-${owned.namespace}-${owned.name}`}
|
|
3059
|
+
className={clsx(
|
|
3060
|
+
"flex min-w-0 items-center gap-2 px-2.5 py-1.5",
|
|
3061
|
+
index > 0 && "border-t border-theme-border/60",
|
|
3062
|
+
)}
|
|
3063
|
+
>
|
|
3064
|
+
<StatusDot
|
|
3065
|
+
tone={mapHealthToTone(
|
|
3066
|
+
owned.issue ? "unhealthy" : (owned.status ?? ""),
|
|
3067
|
+
)}
|
|
3068
|
+
className="shrink-0"
|
|
3069
|
+
/>
|
|
3070
|
+
<Badge tone="structural" size="sm">
|
|
3071
|
+
{owned.kind}
|
|
3072
|
+
</Badge>
|
|
3073
|
+
<span className="min-w-0 flex-1">
|
|
3074
|
+
<span className="block truncate font-mono text-xs">
|
|
3075
|
+
<ResourceLink
|
|
3076
|
+
name={owned.name}
|
|
3077
|
+
kind={owned.kind}
|
|
3078
|
+
namespace={owned.namespace}
|
|
3079
|
+
group={
|
|
3080
|
+
owned.apiVersion
|
|
3081
|
+
? apiVersionToGroup(owned.apiVersion)
|
|
3082
|
+
: undefined
|
|
3083
|
+
}
|
|
3084
|
+
label={`${owned.namespace ? `${owned.namespace}/` : ""}${owned.name}`}
|
|
3085
|
+
onNavigate={
|
|
3086
|
+
owned.apiVersion && onOpenResource
|
|
3087
|
+
? (ref) => onOpenResource(ref)
|
|
3088
|
+
: undefined
|
|
3089
|
+
}
|
|
3090
|
+
/>
|
|
3091
|
+
</span>
|
|
3092
|
+
{owned.issue ? (
|
|
3093
|
+
<span className="block truncate text-xs text-warning-text">
|
|
3094
|
+
{owned.issue}
|
|
3095
|
+
</span>
|
|
3096
|
+
) : owned.summary || owned.message ? (
|
|
3097
|
+
<span className="block truncate text-xs text-theme-text-tertiary">
|
|
3098
|
+
{owned.summary || owned.message}
|
|
3099
|
+
</span>
|
|
3100
|
+
) : null}
|
|
3101
|
+
</span>
|
|
3102
|
+
{owned.ready || owned.status ? (
|
|
3103
|
+
<span className="ml-auto shrink-0 font-mono text-xs text-theme-text-tertiary">
|
|
3104
|
+
{owned.ready || owned.status}
|
|
3105
|
+
</span>
|
|
3106
|
+
) : null}
|
|
3107
|
+
</div>
|
|
3108
|
+
))}
|
|
3109
|
+
</div>
|
|
3110
|
+
) : null}
|
|
3111
|
+
</div>
|
|
3112
|
+
);
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
function PermissionsBody({ data }: { data: EvidenceDataOf<"permissions"> }) {
|
|
3116
|
+
const { subject, accessCheck } = data;
|
|
3117
|
+
const subjectBadges = (
|
|
3118
|
+
<>
|
|
3119
|
+
<Badge tone="structural" size="sm">
|
|
3120
|
+
{subject.kind}
|
|
3121
|
+
</Badge>
|
|
3122
|
+
<span className="font-mono text-xs text-theme-text-secondary">
|
|
3123
|
+
{subject.namespace ? `${subject.namespace}/` : ""}
|
|
3124
|
+
{subject.name}
|
|
3125
|
+
</span>
|
|
3126
|
+
</>
|
|
3127
|
+
);
|
|
3128
|
+
if (accessCheck) {
|
|
3129
|
+
const facts = [
|
|
3130
|
+
["Verb", accessCheck.verb],
|
|
3131
|
+
["Resource", accessCheck.resource],
|
|
3132
|
+
["Subresource", accessCheck.subresource],
|
|
3133
|
+
["API group", accessCheck.group || "core"],
|
|
3134
|
+
["Namespace", accessCheck.namespace || "cluster-wide"],
|
|
3135
|
+
["Name", accessCheck.resourceName],
|
|
3136
|
+
] as const;
|
|
3137
|
+
return (
|
|
3138
|
+
<div className="space-y-2.5">
|
|
3139
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
3140
|
+
{subjectBadges}
|
|
3141
|
+
<Badge
|
|
3142
|
+
severity={accessCheck.allowed ? "success" : "warning"}
|
|
3143
|
+
size="sm"
|
|
3144
|
+
>
|
|
3145
|
+
{accessCheck.allowed
|
|
3146
|
+
? "allowed"
|
|
3147
|
+
: accessCheck.denied
|
|
3148
|
+
? "denied"
|
|
3149
|
+
: "not allowed"}
|
|
3150
|
+
</Badge>
|
|
3151
|
+
</div>
|
|
3152
|
+
<dl className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
|
3153
|
+
{facts.map(([label, value]) => (
|
|
3154
|
+
<ResourceFact key={label} label={label} value={value} />
|
|
3155
|
+
))}
|
|
3156
|
+
</dl>
|
|
3157
|
+
{accessCheck.reason ? (
|
|
3158
|
+
<p className="text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
|
|
3159
|
+
{accessCheck.reason}
|
|
3160
|
+
</p>
|
|
3161
|
+
) : null}
|
|
3162
|
+
{accessCheck.evaluationError ? (
|
|
3163
|
+
<p className="text-xs leading-relaxed text-semantic-error">
|
|
3164
|
+
{accessCheck.evaluationError}
|
|
3165
|
+
</p>
|
|
3166
|
+
) : null}
|
|
3167
|
+
</div>
|
|
3168
|
+
);
|
|
3169
|
+
}
|
|
3170
|
+
const bindings = data.bindings ?? [];
|
|
3171
|
+
const usedByPods = data.usedByPods ?? [];
|
|
3172
|
+
return (
|
|
3173
|
+
<div className="space-y-2.5">
|
|
3174
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
3175
|
+
{subjectBadges}
|
|
3176
|
+
<Badge tone="note" size="sm">
|
|
3177
|
+
{data.flatRulesCount ?? 0}
|
|
3178
|
+
{data.truncated ? "+" : ""} effective rules
|
|
3179
|
+
</Badge>
|
|
3180
|
+
{data.truncated ? (
|
|
3181
|
+
<Badge severity="warning" size="sm">
|
|
3182
|
+
rule list truncated
|
|
3183
|
+
</Badge>
|
|
3184
|
+
) : null}
|
|
3185
|
+
</div>
|
|
3186
|
+
{bindings.length > 0 ? (
|
|
3187
|
+
<div className="max-h-52 overflow-y-auto rounded-md border border-theme-border">
|
|
3188
|
+
{bindings.map((binding, index) => (
|
|
3189
|
+
<div
|
|
3190
|
+
key={`${binding.bindingKind}-${binding.bindingNamespace ?? ""}-${binding.bindingName}`}
|
|
3191
|
+
className={clsx(
|
|
3192
|
+
"flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 px-2.5 py-1.5 text-xs",
|
|
3193
|
+
index > 0 && "border-t border-theme-border/60",
|
|
3194
|
+
)}
|
|
3195
|
+
>
|
|
3196
|
+
<Badge tone="structural" size="sm">
|
|
3197
|
+
{binding.bindingKind}
|
|
3198
|
+
</Badge>
|
|
3199
|
+
<span className="min-w-0 truncate font-mono text-theme-text-secondary">
|
|
3200
|
+
{binding.bindingNamespace ? `${binding.bindingNamespace}/` : ""}
|
|
3201
|
+
{binding.bindingName}
|
|
3202
|
+
</span>
|
|
3203
|
+
<span className="text-theme-text-tertiary">→</span>
|
|
3204
|
+
<Badge tone="structural" size="sm">
|
|
3205
|
+
{binding.roleKind}
|
|
3206
|
+
</Badge>
|
|
3207
|
+
<span className="min-w-0 truncate font-mono text-theme-text-primary">
|
|
3208
|
+
{binding.roleNamespace ? `${binding.roleNamespace}/` : ""}
|
|
3209
|
+
{binding.roleName}
|
|
3210
|
+
</span>
|
|
3211
|
+
<span className="ml-auto shrink-0 font-mono text-theme-text-tertiary">
|
|
3212
|
+
{binding.rulesCount} rule{binding.rulesCount === 1 ? "" : "s"}
|
|
3213
|
+
</span>
|
|
3214
|
+
{binding.inheritedFromGroup ? (
|
|
3215
|
+
<Badge tone="note" size="sm">
|
|
3216
|
+
via {binding.inheritedFromGroup}
|
|
3217
|
+
</Badge>
|
|
3218
|
+
) : null}
|
|
3219
|
+
</div>
|
|
3220
|
+
))}
|
|
3221
|
+
</div>
|
|
3222
|
+
) : (
|
|
3223
|
+
<p className="text-xs italic text-theme-text-tertiary">
|
|
3224
|
+
No RoleBinding or ClusterRoleBinding grants this subject anything.
|
|
3225
|
+
</p>
|
|
3226
|
+
)}
|
|
3227
|
+
{usedByPods.length > 0 ? (
|
|
3228
|
+
<p className="text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
|
|
3229
|
+
<span className="text-theme-text-tertiary">Used by pods: </span>
|
|
3230
|
+
<span className="font-mono">{usedByPods.join(", ")}</span>
|
|
3231
|
+
{data.podsTotal && data.podsTotal > usedByPods.length
|
|
3232
|
+
? ` and ${data.podsTotal - usedByPods.length} more`
|
|
3233
|
+
: ""}
|
|
3234
|
+
</p>
|
|
3235
|
+
) : null}
|
|
3236
|
+
</div>
|
|
3237
|
+
);
|
|
3238
|
+
}
|
|
3239
|
+
|
|
2006
3240
|
function RevisionHistory({
|
|
2007
3241
|
observations,
|
|
2008
3242
|
citedOrder,
|
|
3243
|
+
caseItems = [],
|
|
2009
3244
|
reveal,
|
|
2010
3245
|
revealRequestId,
|
|
2011
3246
|
onViewSource,
|
|
2012
3247
|
}: {
|
|
2013
3248
|
observations: InvestigationEvidenceObservation[];
|
|
2014
3249
|
citedOrder?: number;
|
|
3250
|
+
/** Agent items bound to a superseded observation of this group. */
|
|
3251
|
+
caseItems?: InvestigationCaseItem[];
|
|
2015
3252
|
reveal: boolean;
|
|
2016
3253
|
revealRequestId?: number;
|
|
2017
3254
|
onViewSource: (
|
|
@@ -2021,6 +3258,7 @@ function RevisionHistory({
|
|
|
2021
3258
|
}) {
|
|
2022
3259
|
const [open, setOpen] = useState(false);
|
|
2023
3260
|
const regionId = useId();
|
|
3261
|
+
const { metricsMarkersBySource } = useContext(EvidenceNavigationContext);
|
|
2024
3262
|
const { elementRef, revealAfterToggle } =
|
|
2025
3263
|
useDisclosureReveal<HTMLDivElement>();
|
|
2026
3264
|
useLayoutEffect(() => {
|
|
@@ -2044,41 +3282,73 @@ function RevisionHistory({
|
|
|
2044
3282
|
<div id={regionId} ref={elementRef}>
|
|
2045
3283
|
<Collapse open={open}>
|
|
2046
3284
|
<ol className="space-y-3 pt-2">
|
|
2047
|
-
{observations.map((observation) =>
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
3285
|
+
{observations.map((observation) => {
|
|
3286
|
+
const key = investigationCaseObservationKey(observation);
|
|
3287
|
+
const rowItems = caseItems.filter(
|
|
3288
|
+
(item) =>
|
|
3289
|
+
item.observation &&
|
|
3290
|
+
investigationCaseObservationKey(item.observation) === key,
|
|
3291
|
+
);
|
|
3292
|
+
const rowRoles = [...new Set(rowItems.map((item) => item.role))];
|
|
3293
|
+
return (
|
|
3294
|
+
<li
|
|
3295
|
+
key={`${observation.source.id}-${observation.revision}`}
|
|
3296
|
+
data-case-observation={rowItems.length > 0 ? key : undefined}
|
|
3297
|
+
className="flex min-w-0 items-start gap-2 text-xs"
|
|
3298
|
+
>
|
|
3299
|
+
<span className="mt-0.5 shrink-0 text-theme-text-tertiary">
|
|
3300
|
+
<span className="font-medium text-theme-text-secondary">
|
|
3301
|
+
{observation.source.order === citedOrder
|
|
3302
|
+
? "Used for assessment"
|
|
3303
|
+
: phaseLabel(observation.source.phase)}
|
|
3304
|
+
</span>
|
|
2057
3305
|
</span>
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
3306
|
+
<div className="min-w-0 flex-1 space-y-1 text-theme-text-secondary">
|
|
3307
|
+
{rowRoles.length > 0 ? (
|
|
3308
|
+
<p className="flex flex-wrap items-center gap-1.5">
|
|
3309
|
+
<span>{observation.summary || observation.title}</span>
|
|
3310
|
+
{rowRoles.map((role) => (
|
|
3311
|
+
<AgentRoleChip key={role} role={role} />
|
|
3312
|
+
))}
|
|
3313
|
+
</p>
|
|
3314
|
+
) : (
|
|
3315
|
+
<p>{observation.summary || observation.title}</p>
|
|
3316
|
+
)}
|
|
3317
|
+
{rowItems
|
|
3318
|
+
.filter((item) => item.claim)
|
|
3319
|
+
.map((item) => (
|
|
3320
|
+
<AgentClaimNote
|
|
3321
|
+
key={item.index}
|
|
3322
|
+
claim={item.claim}
|
|
3323
|
+
role={rowRoles.length > 1 ? item.role : undefined}
|
|
3324
|
+
className="pt-1"
|
|
3325
|
+
/>
|
|
3326
|
+
))}
|
|
3327
|
+
{evidenceHasDetails(
|
|
3328
|
+
observation.data,
|
|
3329
|
+
observation.summary,
|
|
3330
|
+
) && (
|
|
3331
|
+
<EvidenceBody
|
|
3332
|
+
data={observation.data}
|
|
3333
|
+
cardSummary={observation.summary}
|
|
3334
|
+
changeCoverage={metricsMarkersBySource?.get(
|
|
3335
|
+
observation.source.id,
|
|
3336
|
+
)}
|
|
3337
|
+
/>
|
|
3338
|
+
)}
|
|
3339
|
+
</div>
|
|
3340
|
+
<SourceButton
|
|
3341
|
+
ariaLabel={`View source for ${phaseLabel(observation.source.phase).toLowerCase()} observation of ${observation.title}`}
|
|
3342
|
+
onClick={() =>
|
|
3343
|
+
onViewSource(
|
|
3344
|
+
observation.source.id,
|
|
3345
|
+
evidenceSourceExcerpt(observation.data),
|
|
3346
|
+
)
|
|
3347
|
+
}
|
|
3348
|
+
/>
|
|
3349
|
+
</li>
|
|
3350
|
+
);
|
|
3351
|
+
})}
|
|
2082
3352
|
</ol>
|
|
2083
3353
|
</Collapse>
|
|
2084
3354
|
</div>
|
|
@@ -2087,11 +3357,9 @@ function RevisionHistory({
|
|
|
2087
3357
|
}
|
|
2088
3358
|
|
|
2089
3359
|
function EvidenceCaveat({ data }: { data: InvestigationEvidenceData }) {
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
"Events support the timeline; proximity alone does not establish cause.";
|
|
2094
|
-
} else if (data.type === "changes") {
|
|
3360
|
+
// Changes get a tooltip rather than a sentence because the wording depends
|
|
3361
|
+
// on whether the reported age was collected with the change.
|
|
3362
|
+
if (data.type === "changes") {
|
|
2095
3363
|
return (
|
|
2096
3364
|
<Tooltip
|
|
2097
3365
|
content={`A change alone does not establish the cause.${data.changeContext?.when ? " The reported age is as of collection." : ""}`}
|
|
@@ -2108,10 +3376,8 @@ function EvidenceCaveat({ data }: { data: InvestigationEvidenceData }) {
|
|
|
2108
3376
|
</button>
|
|
2109
3377
|
</Tooltip>
|
|
2110
3378
|
);
|
|
2111
|
-
} else if (data.type === "relationships" || data.type === "topology") {
|
|
2112
|
-
text =
|
|
2113
|
-
"This shows direct relationships Radar found, not an inferred blast radius.";
|
|
2114
3379
|
}
|
|
3380
|
+
const text = EVIDENCE_KIND_TRAITS[data.type].caveat;
|
|
2115
3381
|
if (!text) return null;
|
|
2116
3382
|
return (
|
|
2117
3383
|
<p className="flex items-start gap-1.5 text-xs leading-relaxed text-theme-text-tertiary">
|
|
@@ -2184,33 +3450,7 @@ function SourceButton({
|
|
|
2184
3450
|
}
|
|
2185
3451
|
|
|
2186
3452
|
function evidenceIcon(type: InvestigationEvidenceData["type"]) {
|
|
2187
|
-
|
|
2188
|
-
case "issue":
|
|
2189
|
-
return CircleAlert;
|
|
2190
|
-
case "startup":
|
|
2191
|
-
return ShieldAlert;
|
|
2192
|
-
case "crash":
|
|
2193
|
-
return Bug;
|
|
2194
|
-
case "resource":
|
|
2195
|
-
return Boxes;
|
|
2196
|
-
case "logs":
|
|
2197
|
-
return ScrollText;
|
|
2198
|
-
case "events":
|
|
2199
|
-
return Clock3;
|
|
2200
|
-
case "changes":
|
|
2201
|
-
return FileClock;
|
|
2202
|
-
case "dns":
|
|
2203
|
-
return Activity;
|
|
2204
|
-
case "network":
|
|
2205
|
-
return Network;
|
|
2206
|
-
case "relationships":
|
|
2207
|
-
case "topology":
|
|
2208
|
-
return Network;
|
|
2209
|
-
case "inventory":
|
|
2210
|
-
return ListTree;
|
|
2211
|
-
case "receipt":
|
|
2212
|
-
return CheckCircle2;
|
|
2213
|
-
}
|
|
3453
|
+
return EVIDENCE_KIND_TRAITS[type].icon;
|
|
2214
3454
|
}
|
|
2215
3455
|
|
|
2216
3456
|
function severityBadge(value: string) {
|
|
@@ -2244,6 +3484,13 @@ function toneBorder(
|
|
|
2244
3484
|
prominence: "primary" | "supporting" | "secondary",
|
|
2245
3485
|
): string {
|
|
2246
3486
|
if (prominence !== "primary") return "border-theme-border/70";
|
|
3487
|
+
// A supporting adverse card is what the healthy-conflict banner points at,
|
|
3488
|
+
// so it must be tellable from its neutral neighbours: a thin rule, lighter
|
|
3489
|
+
// than the Key tier's.
|
|
3490
|
+
if (tier === "supporting" && tone === "error")
|
|
3491
|
+
return "border-l-2 border-l-semantic-error border-theme-border";
|
|
3492
|
+
if (tier === "supporting" && (tone === "warning" || tone === "alert"))
|
|
3493
|
+
return "border-l-2 border-l-semantic-warning border-theme-border";
|
|
2247
3494
|
if (tier !== "key") return "border-theme-border";
|
|
2248
3495
|
if (tone === "error")
|
|
2249
3496
|
return "border-l-[3px] border-l-red-500 border-theme-border";
|