@skyhook-io/radar-app 1.13.1 → 1.13.3
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 +19 -1
- package/src/RadarApp.tsx +2 -2
- package/src/api/diagnose.test.ts +268 -0
- package/src/api/diagnose.ts +74 -9
- package/src/components/diagnose/AISettings.tsx +1 -1
- package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
- package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
- package/src/components/diagnose/DiagnoseContext.tsx +12 -17
- package/src/components/diagnose/DiagnoseSurface.test.tsx +211 -16
- package/src/components/diagnose/DiagnoseSurface.tsx +464 -133
- package/src/components/diagnose/Home.test.tsx +293 -0
- package/src/components/diagnose/Home.tsx +289 -119
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
- package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
- package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
- package/src/components/diagnose/InvestigationView.test.ts +17 -0
- package/src/components/diagnose/InvestigationView.tsx +1913 -395
- package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
- package/src/components/diagnose/agentCatalog.ts +1 -1
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
- package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
- package/src/components/diagnose/investigationEvidence.ts +3492 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
- package/src/components/diagnose/investigationExplanation.test.ts +92 -0
- package/src/components/diagnose/investigationExplanation.ts +29 -0
- package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
- package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
- package/src/components/diagnose/investigationSourceFocus.ts +98 -0
- package/src/components/diagnose/investigationState.test.ts +695 -0
- package/src/components/diagnose/investigationState.ts +451 -0
- package/src/components/diagnose/parts.test.tsx +864 -3
- package/src/components/diagnose/parts.tsx +1337 -541
- package/src/components/diagnose/target.test.ts +39 -0
- package/src/components/diagnose/target.ts +36 -0
- package/src/components/diagnose/useDisclosureReveal.ts +117 -0
- package/src/components/home/MCPSetupDialog.tsx +2 -2
- package/src/components/home/mcpToolCatalog.test.ts +22 -0
- package/src/components/home/mcpToolCatalog.ts +3 -2
- package/src/components/issues/IssuesPane.tsx +5 -1
- package/src/components/settings/SettingsDialog.tsx +11 -13
- package/src/components/workload/WorkloadView.tsx +1 -1
- package/src/context/DiagnoseCustomization.tsx +11 -8
- package/src/index.css +63 -79
- package/src/index.ts +1 -1
|
@@ -0,0 +1,2253 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useId,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { clsx } from "clsx";
|
|
12
|
+
import {
|
|
13
|
+
Activity,
|
|
14
|
+
AlertTriangle,
|
|
15
|
+
Boxes,
|
|
16
|
+
Bug,
|
|
17
|
+
CheckCircle2,
|
|
18
|
+
CircleAlert,
|
|
19
|
+
Clock3,
|
|
20
|
+
FileClock,
|
|
21
|
+
FileSearch,
|
|
22
|
+
Info,
|
|
23
|
+
ListTree,
|
|
24
|
+
Network,
|
|
25
|
+
ScrollText,
|
|
26
|
+
SearchCheck,
|
|
27
|
+
ShieldAlert,
|
|
28
|
+
SquareArrowOutUpRight,
|
|
29
|
+
} from "lucide-react";
|
|
30
|
+
import {
|
|
31
|
+
Badge,
|
|
32
|
+
Collapse,
|
|
33
|
+
CollapseChevron,
|
|
34
|
+
DiffViewer,
|
|
35
|
+
StatusDot,
|
|
36
|
+
TerminalBlock,
|
|
37
|
+
defaultConditionTone,
|
|
38
|
+
displayKind,
|
|
39
|
+
formatRelativeAgeTime,
|
|
40
|
+
mapHealthToTone,
|
|
41
|
+
ResourceLink,
|
|
42
|
+
stripAnsi,
|
|
43
|
+
} from "@skyhook-io/k8s-ui";
|
|
44
|
+
import { apiVersionToGroup } from "../../utils/navigation";
|
|
45
|
+
import { parseLogLine } from "../../utils/log-format";
|
|
46
|
+
import {
|
|
47
|
+
evidenceDisplaySnapshot,
|
|
48
|
+
groupEvidenceCoverage,
|
|
49
|
+
type EvidenceCoverageGroup,
|
|
50
|
+
} from "./investigationEvidencePresentation";
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
investigationEvidenceSubjectRef,
|
|
54
|
+
investigationEvidenceSourceDomId,
|
|
55
|
+
type InvestigationEvidenceData,
|
|
56
|
+
type InvestigationEvidenceGroup,
|
|
57
|
+
type InvestigationEvidenceObservation,
|
|
58
|
+
type InvestigationEvidenceProjection,
|
|
59
|
+
type InvestigationRootCauseEvidenceResolution,
|
|
60
|
+
type InvestigationEvidenceSource,
|
|
61
|
+
type InvestigationEvidenceTier,
|
|
62
|
+
} from "./investigationEvidence";
|
|
63
|
+
import type { DiagnosisResourceRef } from "./diagnoseEvidenceTypes";
|
|
64
|
+
import { InvestigationResourceEvidence } from "./InvestigationResourceEvidence";
|
|
65
|
+
import { investigationResourceEvidenceHasDetails } from "./investigationResourceEvidenceModel";
|
|
66
|
+
import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
|
|
67
|
+
import { evidenceSourceExcerpt } from "./investigationSourceFocus";
|
|
68
|
+
import { Tooltip } from "../ui/Tooltip";
|
|
69
|
+
|
|
70
|
+
import {
|
|
71
|
+
investigationDisclosureSettleDelay,
|
|
72
|
+
prefersReducedMotion,
|
|
73
|
+
useDisclosureReveal,
|
|
74
|
+
} from "./useDisclosureReveal";
|
|
75
|
+
export {
|
|
76
|
+
INVESTIGATION_DISCLOSURE_SETTLE_MS,
|
|
77
|
+
investigationDisclosureSettleDelay,
|
|
78
|
+
investigationDisclosureScrollTop,
|
|
79
|
+
} from "./useDisclosureReveal";
|
|
80
|
+
export const VISIBLE_LOG_EVIDENCE_LINES = 12;
|
|
81
|
+
|
|
82
|
+
const EvidenceNavigationContext = createContext<{
|
|
83
|
+
onOpenResource?: (ref: DiagnosisResourceRef) => void;
|
|
84
|
+
revealSourceId?: string;
|
|
85
|
+
revealRequestId?: number;
|
|
86
|
+
expandedGroupIds?: ReadonlySet<string>;
|
|
87
|
+
onGroupOpenChange?: (id: string, open: boolean) => void;
|
|
88
|
+
citedOrderByGroup?: ReadonlyMap<string, number>;
|
|
89
|
+
}>({});
|
|
90
|
+
|
|
91
|
+
function evidenceTypePrefersFullRow(
|
|
92
|
+
type: InvestigationEvidenceData["type"],
|
|
93
|
+
): boolean {
|
|
94
|
+
return type === "logs" || type === "events";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Supporting evidence becomes a two-column grid when the pane is wide enough.
|
|
98
|
+
// Keep compact cards paired only with an adjacent compact card. Otherwise a
|
|
99
|
+
// full-row card between them strands a conspicuous empty half-row (and makes the
|
|
100
|
+
// visual order look accidental), as does an odd card at the end of a run.
|
|
101
|
+
export function investigationEvidenceFullRowFlags(
|
|
102
|
+
types: readonly InvestigationEvidenceData["type"][],
|
|
103
|
+
): boolean[] {
|
|
104
|
+
const fullRow = types.map(evidenceTypePrefersFullRow);
|
|
105
|
+
let compactRunStart = 0;
|
|
106
|
+
|
|
107
|
+
for (let index = 0; index <= types.length; index += 1) {
|
|
108
|
+
if (index < types.length && !fullRow[index]) continue;
|
|
109
|
+
const compactRunLength = index - compactRunStart;
|
|
110
|
+
if (compactRunLength % 2 === 1) fullRow[index - 1] = true;
|
|
111
|
+
compactRunStart = index + 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return fullRow;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type EvidenceCollection = "main" | "workload" | "earlier";
|
|
118
|
+
|
|
119
|
+
export function partitionInvestigationEvidence(
|
|
120
|
+
groups: InvestigationEvidenceGroup[],
|
|
121
|
+
resolution?: InvestigationRootCauseEvidenceResolution,
|
|
122
|
+
) {
|
|
123
|
+
const selected = new Set(
|
|
124
|
+
resolution?.status === "linked"
|
|
125
|
+
? resolution.links.map((link) => link.originalGroupId)
|
|
126
|
+
: [],
|
|
127
|
+
);
|
|
128
|
+
const collections: Record<EvidenceCollection, InvestigationEvidenceGroup[]> =
|
|
129
|
+
{
|
|
130
|
+
main: [],
|
|
131
|
+
workload: [],
|
|
132
|
+
earlier: [],
|
|
133
|
+
};
|
|
134
|
+
const collectionByGroup = new Map<string, EvidenceCollection>();
|
|
135
|
+
const adverse = (group: InvestigationEvidenceGroup) =>
|
|
136
|
+
["error", "alert", "warning"].includes(group.latest.tone);
|
|
137
|
+
for (const group of groups) {
|
|
138
|
+
const broader = group.latest.relevance === "broader";
|
|
139
|
+
// Citations select tool results, not individual rows in a broad search.
|
|
140
|
+
// Only a focused, unambiguous fact can be promoted by a source citation.
|
|
141
|
+
if (broader) {
|
|
142
|
+
const link = resolution?.links.find(
|
|
143
|
+
(item) => item.originalGroupId === group.id,
|
|
144
|
+
);
|
|
145
|
+
const focused = ["resource", "logs", "crash"].includes(
|
|
146
|
+
group.latest.data.type,
|
|
147
|
+
);
|
|
148
|
+
const sourceGroups = link
|
|
149
|
+
? groups.filter(
|
|
150
|
+
(candidate) =>
|
|
151
|
+
["resource", "logs", "crash"].includes(
|
|
152
|
+
candidate.latest.data.type,
|
|
153
|
+
) &&
|
|
154
|
+
candidate.observations.some(
|
|
155
|
+
(observation) => observation.source.id === link.source.id,
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
: [];
|
|
159
|
+
if (!selected.has(group.id) || !focused || sourceGroups.length !== 1)
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const main =
|
|
163
|
+
!group.historical &&
|
|
164
|
+
(selected.has(group.id) ||
|
|
165
|
+
(!broader &&
|
|
166
|
+
(group.latest.tier === "key" ||
|
|
167
|
+
(group.latest.tier === "supporting" && adverse(group)))));
|
|
168
|
+
const collection = main
|
|
169
|
+
? "main"
|
|
170
|
+
: group.historical
|
|
171
|
+
? "earlier"
|
|
172
|
+
: "workload";
|
|
173
|
+
collections[collection].push(group);
|
|
174
|
+
collectionByGroup.set(group.id, collection);
|
|
175
|
+
}
|
|
176
|
+
collections.main.sort(
|
|
177
|
+
(left, right) =>
|
|
178
|
+
Number(right.latest.tier === "key") -
|
|
179
|
+
Number(left.latest.tier === "key") ||
|
|
180
|
+
Number(adverse(right)) - Number(adverse(left)) ||
|
|
181
|
+
left.firstOrder - right.firstOrder,
|
|
182
|
+
);
|
|
183
|
+
return { ...collections, collectionByGroup };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function investigationEvidenceRevealCollection(
|
|
187
|
+
projection: InvestigationEvidenceProjection,
|
|
188
|
+
sourceId: string,
|
|
189
|
+
partition = partitionInvestigationEvidence(projection.groups),
|
|
190
|
+
): Exclude<EvidenceCollection, "main"> | "coverage" | undefined {
|
|
191
|
+
const source = projection.sources.find((item) => item.id === sourceId);
|
|
192
|
+
const collection = source?.primaryGroupId
|
|
193
|
+
? partition.collectionByGroup.get(source.primaryGroupId)
|
|
194
|
+
: undefined;
|
|
195
|
+
if (collection) return collection === "main" ? undefined : collection;
|
|
196
|
+
if (
|
|
197
|
+
projection.limitations.some((limitation) =>
|
|
198
|
+
limitation.sources.some((source) => source.id === sourceId),
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
return "coverage";
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function InvestigationEvidencePane({
|
|
206
|
+
projection,
|
|
207
|
+
rootCauseEvidence,
|
|
208
|
+
collecting,
|
|
209
|
+
animateGroupIds,
|
|
210
|
+
onViewSource,
|
|
211
|
+
onViewActivity,
|
|
212
|
+
onOpenResource,
|
|
213
|
+
afterEvidence,
|
|
214
|
+
revealRequest,
|
|
215
|
+
onRevealReady,
|
|
216
|
+
}: {
|
|
217
|
+
projection: InvestigationEvidenceProjection;
|
|
218
|
+
/** Server-validated links for the current root cause; absent without one. */
|
|
219
|
+
rootCauseEvidence?: InvestigationRootCauseEvidenceResolution;
|
|
220
|
+
collecting: boolean;
|
|
221
|
+
animateGroupIds: ReadonlySet<string>;
|
|
222
|
+
onViewSource: (
|
|
223
|
+
sourceId: string,
|
|
224
|
+
excerpt?: InvestigationSourceExcerpt,
|
|
225
|
+
) => void;
|
|
226
|
+
/** Opens the Activity record when no exact result can be identified. */
|
|
227
|
+
onViewActivity: () => void;
|
|
228
|
+
/** Opens an evidence subject in Radar when the producer identified it exactly. */
|
|
229
|
+
onOpenResource?: (ref: DiagnosisResourceRef) => void;
|
|
230
|
+
/** Actions follow the complete evidence section, including its disclosures. */
|
|
231
|
+
afterEvidence?: ReactNode;
|
|
232
|
+
/** Explicit Activity → Findings navigation, including repeat clicks. */
|
|
233
|
+
revealRequest?: { sourceId: string; requestId: number };
|
|
234
|
+
onRevealReady?: (sourceId: string) => void;
|
|
235
|
+
}) {
|
|
236
|
+
const [expandedGroupIds, setExpandedGroupIds] = useState<ReadonlySet<string>>(
|
|
237
|
+
new Set(),
|
|
238
|
+
);
|
|
239
|
+
const onGroupOpenChange = useCallback((id: string, open: boolean) => {
|
|
240
|
+
setExpandedGroupIds((previous) => {
|
|
241
|
+
if (previous.has(id) === open) return previous;
|
|
242
|
+
const next = new Set(previous);
|
|
243
|
+
if (open) next.add(id);
|
|
244
|
+
else next.delete(id);
|
|
245
|
+
return next;
|
|
246
|
+
});
|
|
247
|
+
}, []);
|
|
248
|
+
const [coverageOpen, setCoverageOpen] = useState(false);
|
|
249
|
+
const [workloadOpen, setWorkloadOpen] = useState(false);
|
|
250
|
+
const [earlierOpen, setEarlierOpen] = useState(false);
|
|
251
|
+
const handledRevealRequestRef = useRef<number | undefined>(undefined);
|
|
252
|
+
const openingForRevealRequestRef = useRef<number | undefined>(undefined);
|
|
253
|
+
const partition = partitionInvestigationEvidence(
|
|
254
|
+
projection.groups,
|
|
255
|
+
rootCauseEvidence,
|
|
256
|
+
);
|
|
257
|
+
const hasCurrentEvidence =
|
|
258
|
+
partition.main.length + partition.workload.length > 0;
|
|
259
|
+
const revealCollection = revealRequest
|
|
260
|
+
? investigationEvidenceRevealCollection(
|
|
261
|
+
projection,
|
|
262
|
+
revealRequest.sourceId,
|
|
263
|
+
partition,
|
|
264
|
+
)
|
|
265
|
+
: undefined;
|
|
266
|
+
|
|
267
|
+
// A source link is a navigation request, not a disclosure preference. Open
|
|
268
|
+
// whichever collection owns the source first, then tell the workspace that
|
|
269
|
+
// its double-rAF focus/scroll can safely run outside an inert subtree.
|
|
270
|
+
useLayoutEffect(() => {
|
|
271
|
+
if (
|
|
272
|
+
!revealRequest ||
|
|
273
|
+
handledRevealRequestRef.current === revealRequest.requestId
|
|
274
|
+
) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const { sourceId, requestId } = revealRequest;
|
|
278
|
+
if (
|
|
279
|
+
(revealCollection === "workload" || revealCollection === "earlier") &&
|
|
280
|
+
!workloadOpen
|
|
281
|
+
) {
|
|
282
|
+
openingForRevealRequestRef.current = requestId;
|
|
283
|
+
setWorkloadOpen(true);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (revealCollection === "earlier" && !earlierOpen) {
|
|
287
|
+
openingForRevealRequestRef.current = requestId;
|
|
288
|
+
setEarlierOpen(true);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (revealCollection === "coverage" && !coverageOpen) {
|
|
292
|
+
openingForRevealRequestRef.current = requestId;
|
|
293
|
+
setCoverageOpen(true);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const finishReveal = () => {
|
|
297
|
+
if (handledRevealRequestRef.current === requestId) return;
|
|
298
|
+
handledRevealRequestRef.current = requestId;
|
|
299
|
+
openingForRevealRequestRef.current = undefined;
|
|
300
|
+
onRevealReady?.(sourceId);
|
|
301
|
+
};
|
|
302
|
+
if (openingForRevealRequestRef.current !== requestId) {
|
|
303
|
+
finishReveal();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Two animation frames alone can target an item that is still moving. Wait
|
|
308
|
+
// through the shared Collapse transition only when motion is enabled.
|
|
309
|
+
const settleDelay = investigationDisclosureSettleDelay(
|
|
310
|
+
prefersReducedMotion(),
|
|
311
|
+
);
|
|
312
|
+
if (settleDelay === 0) {
|
|
313
|
+
finishReveal();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const timer = window.setTimeout(finishReveal, settleDelay);
|
|
317
|
+
return () => window.clearTimeout(timer);
|
|
318
|
+
}, [
|
|
319
|
+
revealRequest,
|
|
320
|
+
revealCollection,
|
|
321
|
+
onRevealReady,
|
|
322
|
+
earlierOpen,
|
|
323
|
+
coverageOpen,
|
|
324
|
+
workloadOpen,
|
|
325
|
+
]);
|
|
326
|
+
|
|
327
|
+
const coverageGroups = groupEvidenceCoverage(projection.limitations);
|
|
328
|
+
const limitationSummary = coverageGroups
|
|
329
|
+
.map((group) => `${group.label}: ${group.summary}`)
|
|
330
|
+
.join(" · ");
|
|
331
|
+
const content = (
|
|
332
|
+
<section
|
|
333
|
+
aria-labelledby="investigation-radar-evidence"
|
|
334
|
+
className="investigation-evidence @container/evidence space-y-4 rounded-xl border p-4"
|
|
335
|
+
>
|
|
336
|
+
<span className="sr-only" role="status" aria-live="polite">
|
|
337
|
+
{projection.limitations.length > 0
|
|
338
|
+
? `Evidence coverage update: ${limitationSummary}`
|
|
339
|
+
: ""}
|
|
340
|
+
</span>
|
|
341
|
+
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
342
|
+
<div className="min-w-0">
|
|
343
|
+
<div className="flex items-center gap-2">
|
|
344
|
+
<h2
|
|
345
|
+
id="investigation-radar-evidence"
|
|
346
|
+
className="text-lg font-semibold text-theme-text-primary"
|
|
347
|
+
>
|
|
348
|
+
Evidence
|
|
349
|
+
</h2>
|
|
350
|
+
{collecting ? (
|
|
351
|
+
<span className="inline-flex items-center gap-1.5 text-xs text-accent-text">
|
|
352
|
+
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
|
|
353
|
+
collecting
|
|
354
|
+
</span>
|
|
355
|
+
) : null}
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
{projection.limitations.length > 0 ? (
|
|
361
|
+
<CoverageStrip
|
|
362
|
+
groups={coverageGroups}
|
|
363
|
+
visibleGroupIds={new Set(partition.collectionByGroup.keys())}
|
|
364
|
+
summary={limitationSummary}
|
|
365
|
+
onViewSource={onViewSource}
|
|
366
|
+
open={coverageOpen}
|
|
367
|
+
onOpenChange={setCoverageOpen}
|
|
368
|
+
/>
|
|
369
|
+
) : null}
|
|
370
|
+
|
|
371
|
+
<div className="space-y-4">
|
|
372
|
+
{rootCauseEvidence && rootCauseEvidence.status !== "linked" ? (
|
|
373
|
+
<AssessmentEvidenceQualification
|
|
374
|
+
resolution={rootCauseEvidence}
|
|
375
|
+
onViewActivity={onViewActivity}
|
|
376
|
+
/>
|
|
377
|
+
) : null}
|
|
378
|
+
|
|
379
|
+
<div className="grid items-start gap-2.5">
|
|
380
|
+
{partition.main.map((group) => (
|
|
381
|
+
<EvidenceCard
|
|
382
|
+
key={group.id}
|
|
383
|
+
group={group}
|
|
384
|
+
spanFullRow
|
|
385
|
+
animateArrival={animateGroupIds.has(group.id)}
|
|
386
|
+
onViewSource={onViewSource}
|
|
387
|
+
/>
|
|
388
|
+
))}
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
{!hasCurrentEvidence ? (
|
|
392
|
+
<EmptyCollection
|
|
393
|
+
collecting={collecting}
|
|
394
|
+
hasEarlierEvidence={partition.earlier.length > 0}
|
|
395
|
+
onViewActivity={onViewActivity}
|
|
396
|
+
/>
|
|
397
|
+
) : null}
|
|
398
|
+
|
|
399
|
+
<CollapsedEvidenceCollection
|
|
400
|
+
id="investigation-workload-evidence"
|
|
401
|
+
title="More evidence about this workload"
|
|
402
|
+
description=""
|
|
403
|
+
groups={partition.workload}
|
|
404
|
+
totalCount={partition.workload.length + partition.earlier.length}
|
|
405
|
+
animateGroupIds={animateGroupIds}
|
|
406
|
+
onViewSource={onViewSource}
|
|
407
|
+
open={workloadOpen}
|
|
408
|
+
onOpenChange={setWorkloadOpen}
|
|
409
|
+
>
|
|
410
|
+
<CollapsedEvidenceCollection
|
|
411
|
+
id="investigation-earlier-evidence"
|
|
412
|
+
title="Previous observations"
|
|
413
|
+
description="Earlier does not mean resolved."
|
|
414
|
+
groups={partition.earlier}
|
|
415
|
+
animateGroupIds={animateGroupIds}
|
|
416
|
+
onViewSource={onViewSource}
|
|
417
|
+
open={earlierOpen}
|
|
418
|
+
onOpenChange={setEarlierOpen}
|
|
419
|
+
/>
|
|
420
|
+
</CollapsedEvidenceCollection>
|
|
421
|
+
</div>
|
|
422
|
+
</section>
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
return (
|
|
426
|
+
<EvidenceNavigationContext.Provider
|
|
427
|
+
value={{
|
|
428
|
+
onOpenResource,
|
|
429
|
+
expandedGroupIds,
|
|
430
|
+
onGroupOpenChange,
|
|
431
|
+
revealSourceId: revealRequest?.sourceId,
|
|
432
|
+
revealRequestId: revealRequest?.requestId,
|
|
433
|
+
citedOrderByGroup: new Map(
|
|
434
|
+
rootCauseEvidence?.links.flatMap((link) =>
|
|
435
|
+
link.originalGroupId
|
|
436
|
+
? [[link.originalGroupId, link.source.order] as const]
|
|
437
|
+
: [],
|
|
438
|
+
) ?? [],
|
|
439
|
+
),
|
|
440
|
+
}}
|
|
441
|
+
>
|
|
442
|
+
{content}
|
|
443
|
+
{afterEvidence}
|
|
444
|
+
</EvidenceNavigationContext.Provider>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function AssessmentEvidenceQualification({
|
|
449
|
+
resolution,
|
|
450
|
+
onViewActivity,
|
|
451
|
+
}: {
|
|
452
|
+
resolution: InvestigationRootCauseEvidenceResolution;
|
|
453
|
+
onViewActivity: () => void;
|
|
454
|
+
}) {
|
|
455
|
+
return (
|
|
456
|
+
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2.5">
|
|
457
|
+
<AlertTriangle
|
|
458
|
+
className="mt-0.5 h-4 w-4 shrink-0 text-amber-500"
|
|
459
|
+
aria-hidden
|
|
460
|
+
/>
|
|
461
|
+
<div className="min-w-0 flex-1">
|
|
462
|
+
<p className="text-xs font-medium text-theme-text-primary">
|
|
463
|
+
{resolution.status === "invalid"
|
|
464
|
+
? "Assessment references could not be matched"
|
|
465
|
+
: "Assessment does not cite specific Radar evidence"}
|
|
466
|
+
</p>
|
|
467
|
+
<p className="mt-0.5 text-xs leading-relaxed text-theme-text-tertiary">
|
|
468
|
+
{resolution.status === "invalid"
|
|
469
|
+
? "Radar could not match the assessment’s references to this investigation. Review Activity before acting."
|
|
470
|
+
: "Review Activity and the Radar evidence below before acting on the agent’s conclusion."}
|
|
471
|
+
</p>
|
|
472
|
+
</div>
|
|
473
|
+
<button
|
|
474
|
+
type="button"
|
|
475
|
+
onClick={onViewActivity}
|
|
476
|
+
className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-accent-text hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
477
|
+
>
|
|
478
|
+
View Activity
|
|
479
|
+
</button>
|
|
480
|
+
</div>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function EmptyCollection({
|
|
485
|
+
collecting,
|
|
486
|
+
hasEarlierEvidence,
|
|
487
|
+
onViewActivity,
|
|
488
|
+
}: {
|
|
489
|
+
collecting: boolean;
|
|
490
|
+
hasEarlierEvidence: boolean;
|
|
491
|
+
onViewActivity: () => void;
|
|
492
|
+
}) {
|
|
493
|
+
return (
|
|
494
|
+
<div className="rounded-lg border border-dashed border-theme-border px-4 py-5 text-center">
|
|
495
|
+
{collecting ? (
|
|
496
|
+
<Activity className="mx-auto h-5 w-5 text-accent" aria-hidden />
|
|
497
|
+
) : (
|
|
498
|
+
<SearchCheck
|
|
499
|
+
className="mx-auto h-5 w-5 text-theme-text-tertiary"
|
|
500
|
+
aria-hidden
|
|
501
|
+
/>
|
|
502
|
+
)}
|
|
503
|
+
<p className="mt-2 text-sm font-medium text-theme-text-secondary">
|
|
504
|
+
{collecting
|
|
505
|
+
? "Evidence will appear here"
|
|
506
|
+
: hasEarlierEvidence
|
|
507
|
+
? "No current evidence was captured during verification"
|
|
508
|
+
: "No relevant evidence to show yet"}
|
|
509
|
+
</p>
|
|
510
|
+
<p className="mx-auto mt-1 max-w-md text-xs leading-relaxed text-theme-text-tertiary">
|
|
511
|
+
{collecting
|
|
512
|
+
? "The Activity pane remains the live record while the agent investigates."
|
|
513
|
+
: hasEarlierEvidence
|
|
514
|
+
? "Earlier observations remain below. This does not prove that those conditions resolved."
|
|
515
|
+
: "Investigation details are still available in Activity. This does not mean the resource is healthy."}
|
|
516
|
+
</p>
|
|
517
|
+
<button
|
|
518
|
+
type="button"
|
|
519
|
+
onClick={onViewActivity}
|
|
520
|
+
className="mt-2 rounded-md px-2 py-1 text-xs font-medium text-accent-text hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
521
|
+
>
|
|
522
|
+
View Activity
|
|
523
|
+
</button>
|
|
524
|
+
</div>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function CollapsedEvidenceCollection({
|
|
529
|
+
id,
|
|
530
|
+
title,
|
|
531
|
+
description,
|
|
532
|
+
groups,
|
|
533
|
+
animateGroupIds,
|
|
534
|
+
onViewSource,
|
|
535
|
+
open,
|
|
536
|
+
onOpenChange,
|
|
537
|
+
totalCount = groups.length,
|
|
538
|
+
children,
|
|
539
|
+
}: {
|
|
540
|
+
id: string;
|
|
541
|
+
title: string;
|
|
542
|
+
description: string;
|
|
543
|
+
groups: InvestigationEvidenceGroup[];
|
|
544
|
+
animateGroupIds: ReadonlySet<string>;
|
|
545
|
+
onViewSource: (
|
|
546
|
+
sourceId: string,
|
|
547
|
+
excerpt?: InvestigationSourceExcerpt,
|
|
548
|
+
) => void;
|
|
549
|
+
open: boolean;
|
|
550
|
+
onOpenChange: (open: boolean) => void;
|
|
551
|
+
totalCount?: number;
|
|
552
|
+
children?: ReactNode;
|
|
553
|
+
}) {
|
|
554
|
+
const { elementRef, revealAfterToggle } = useDisclosureReveal<HTMLElement>();
|
|
555
|
+
if (totalCount === 0) return null;
|
|
556
|
+
const fullRowFlags = investigationEvidenceFullRowFlags(
|
|
557
|
+
groups.map((group) => group.latest.data.type),
|
|
558
|
+
);
|
|
559
|
+
return (
|
|
560
|
+
<section
|
|
561
|
+
ref={elementRef}
|
|
562
|
+
className="overflow-hidden rounded-lg border border-theme-border/80 bg-theme-base/20"
|
|
563
|
+
>
|
|
564
|
+
<button
|
|
565
|
+
type="button"
|
|
566
|
+
aria-expanded={open}
|
|
567
|
+
aria-controls={id}
|
|
568
|
+
onClick={() => {
|
|
569
|
+
onOpenChange(!open);
|
|
570
|
+
revealAfterToggle(!open);
|
|
571
|
+
}}
|
|
572
|
+
className="flex w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left hover:bg-theme-hover/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
573
|
+
>
|
|
574
|
+
<span className="min-w-0 flex-1">
|
|
575
|
+
<span className="block text-xs font-semibold text-theme-text-secondary">
|
|
576
|
+
{title}
|
|
577
|
+
</span>
|
|
578
|
+
{description ? (
|
|
579
|
+
<span className="block text-xs text-theme-text-tertiary">
|
|
580
|
+
{description}
|
|
581
|
+
</span>
|
|
582
|
+
) : null}
|
|
583
|
+
</span>
|
|
584
|
+
<span className="shrink-0 font-mono text-xs text-theme-text-tertiary">
|
|
585
|
+
{totalCount}
|
|
586
|
+
</span>
|
|
587
|
+
<CollapseChevron open={open} className="h-4 w-4" />
|
|
588
|
+
</button>
|
|
589
|
+
<div id={id}>
|
|
590
|
+
<Collapse open={open}>
|
|
591
|
+
<div
|
|
592
|
+
className={clsx(
|
|
593
|
+
"grid items-start gap-2.5 @min-[760px]/evidence:grid-cols-2",
|
|
594
|
+
"border-t border-theme-border/60 p-2.5",
|
|
595
|
+
)}
|
|
596
|
+
>
|
|
597
|
+
{groups.map((group, index) => (
|
|
598
|
+
<EvidenceCard
|
|
599
|
+
key={group.id}
|
|
600
|
+
group={group}
|
|
601
|
+
|
|
602
|
+
animateArrival={animateGroupIds.has(group.id)}
|
|
603
|
+
onViewSource={onViewSource}
|
|
604
|
+
spanFullRow={fullRowFlags[index]}
|
|
605
|
+
prominence="secondary"
|
|
606
|
+
/>
|
|
607
|
+
))}
|
|
608
|
+
{children ? <div className="col-span-full">{children}</div> : null}
|
|
609
|
+
</div>
|
|
610
|
+
</Collapse>
|
|
611
|
+
</div>
|
|
612
|
+
</section>
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function CoverageStrip({
|
|
617
|
+
groups,
|
|
618
|
+
visibleGroupIds,
|
|
619
|
+
summary,
|
|
620
|
+
onViewSource,
|
|
621
|
+
open,
|
|
622
|
+
onOpenChange,
|
|
623
|
+
}: {
|
|
624
|
+
groups: EvidenceCoverageGroup[];
|
|
625
|
+
visibleGroupIds: ReadonlySet<string>;
|
|
626
|
+
summary: string;
|
|
627
|
+
onViewSource: (
|
|
628
|
+
sourceId: string,
|
|
629
|
+
excerpt?: InvestigationSourceExcerpt,
|
|
630
|
+
) => void;
|
|
631
|
+
open: boolean;
|
|
632
|
+
onOpenChange: (open: boolean) => void;
|
|
633
|
+
}) {
|
|
634
|
+
const hasError = groups.some((group) => group.hasError);
|
|
635
|
+
const historyOnly = groups.every((group) => group.historyOnly);
|
|
636
|
+
const { elementRef, revealAfterToggle } =
|
|
637
|
+
useDisclosureReveal<HTMLDivElement>();
|
|
638
|
+
const regionId = "investigation-evidence-coverage";
|
|
639
|
+
const anchoredSources = new Set<string>();
|
|
640
|
+
return (
|
|
641
|
+
<div
|
|
642
|
+
ref={elementRef}
|
|
643
|
+
className="overflow-hidden rounded-lg border border-theme-border/80 bg-theme-base/20"
|
|
644
|
+
>
|
|
645
|
+
<button
|
|
646
|
+
type="button"
|
|
647
|
+
aria-expanded={open}
|
|
648
|
+
aria-controls={regionId}
|
|
649
|
+
onClick={() => {
|
|
650
|
+
onOpenChange(!open);
|
|
651
|
+
revealAfterToggle(!open);
|
|
652
|
+
}}
|
|
653
|
+
className="flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left hover:bg-theme-hover/50"
|
|
654
|
+
>
|
|
655
|
+
{historyOnly ? (
|
|
656
|
+
<Info
|
|
657
|
+
className="h-4 w-4 shrink-0 text-theme-text-tertiary"
|
|
658
|
+
aria-hidden
|
|
659
|
+
/>
|
|
660
|
+
) : hasError ? (
|
|
661
|
+
<CircleAlert className="h-4 w-4 shrink-0 text-red-400" aria-hidden />
|
|
662
|
+
) : (
|
|
663
|
+
<AlertTriangle
|
|
664
|
+
className="h-4 w-4 shrink-0 text-amber-500"
|
|
665
|
+
aria-hidden
|
|
666
|
+
/>
|
|
667
|
+
)}
|
|
668
|
+
<span className="min-w-0 flex-1">
|
|
669
|
+
<span
|
|
670
|
+
className={clsx(
|
|
671
|
+
"block text-xs",
|
|
672
|
+
historyOnly
|
|
673
|
+
? "font-medium text-theme-text-secondary"
|
|
674
|
+
: "font-semibold text-theme-text-primary",
|
|
675
|
+
)}
|
|
676
|
+
>
|
|
677
|
+
{historyOnly
|
|
678
|
+
? "Change history is limited"
|
|
679
|
+
: "Evidence coverage is incomplete"}
|
|
680
|
+
</span>
|
|
681
|
+
{!historyOnly && !open ? (
|
|
682
|
+
<span className="line-clamp-2 text-xs text-theme-text-tertiary [overflow-wrap:anywhere]">
|
|
683
|
+
{summary}
|
|
684
|
+
</span>
|
|
685
|
+
) : null}
|
|
686
|
+
</span>
|
|
687
|
+
<CollapseChevron open={open} className="h-4 w-4" />
|
|
688
|
+
</button>
|
|
689
|
+
<div id={regionId}>
|
|
690
|
+
<Collapse open={open}>
|
|
691
|
+
<div className="divide-y divide-theme-border/60 border-t border-theme-border/60">
|
|
692
|
+
{groups.map((group) => {
|
|
693
|
+
const sourceIds = group.limitations
|
|
694
|
+
.flatMap((item) => item.sources)
|
|
695
|
+
.filter((source) => {
|
|
696
|
+
if (
|
|
697
|
+
(source.primaryGroupId &&
|
|
698
|
+
visibleGroupIds.has(source.primaryGroupId)) ||
|
|
699
|
+
anchoredSources.has(source.id)
|
|
700
|
+
)
|
|
701
|
+
return false;
|
|
702
|
+
anchoredSources.add(source.id);
|
|
703
|
+
return true;
|
|
704
|
+
})
|
|
705
|
+
.map((source) => source.id);
|
|
706
|
+
return (
|
|
707
|
+
<CoverageGroupRow
|
|
708
|
+
key={group.label}
|
|
709
|
+
group={group}
|
|
710
|
+
sourceIds={sourceIds}
|
|
711
|
+
onViewSource={onViewSource}
|
|
712
|
+
/>
|
|
713
|
+
);
|
|
714
|
+
})}
|
|
715
|
+
</div>
|
|
716
|
+
</Collapse>
|
|
717
|
+
</div>
|
|
718
|
+
</div>
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function CoverageGroupRow({
|
|
723
|
+
group,
|
|
724
|
+
sourceIds,
|
|
725
|
+
onViewSource,
|
|
726
|
+
}: {
|
|
727
|
+
group: EvidenceCoverageGroup;
|
|
728
|
+
sourceIds: string[];
|
|
729
|
+
onViewSource: (
|
|
730
|
+
sourceId: string,
|
|
731
|
+
excerpt?: InvestigationSourceExcerpt,
|
|
732
|
+
) => void;
|
|
733
|
+
}) {
|
|
734
|
+
const [open, setOpen] = useState(false);
|
|
735
|
+
const regionId = useId();
|
|
736
|
+
const { elementRef, revealAfterToggle } =
|
|
737
|
+
useDisclosureReveal<HTMLDivElement>();
|
|
738
|
+
return (
|
|
739
|
+
<div
|
|
740
|
+
ref={elementRef}
|
|
741
|
+
data-evidence-source-container
|
|
742
|
+
tabIndex={-1}
|
|
743
|
+
aria-label={`Evidence limitation for ${group.label}: ${group.summary}`}
|
|
744
|
+
className="outline-none focus:ring-2 focus:ring-accent/40"
|
|
745
|
+
>
|
|
746
|
+
{sourceIds.map((id) => (
|
|
747
|
+
<span
|
|
748
|
+
key={id}
|
|
749
|
+
id={investigationEvidenceSourceDomId(id)}
|
|
750
|
+
className="sr-only scroll-mt-14"
|
|
751
|
+
aria-hidden
|
|
752
|
+
/>
|
|
753
|
+
))}
|
|
754
|
+
<button
|
|
755
|
+
type="button"
|
|
756
|
+
aria-expanded={open}
|
|
757
|
+
aria-controls={regionId}
|
|
758
|
+
onClick={() => {
|
|
759
|
+
setOpen(!open);
|
|
760
|
+
revealAfterToggle(!open);
|
|
761
|
+
}}
|
|
762
|
+
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-xs hover:bg-theme-hover/50"
|
|
763
|
+
>
|
|
764
|
+
{group.hasError ? (
|
|
765
|
+
<CircleAlert
|
|
766
|
+
className="h-3.5 w-3.5 shrink-0 text-red-400"
|
|
767
|
+
aria-hidden
|
|
768
|
+
/>
|
|
769
|
+
) : (
|
|
770
|
+
<Info
|
|
771
|
+
className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
|
|
772
|
+
aria-hidden
|
|
773
|
+
/>
|
|
774
|
+
)}
|
|
775
|
+
<span className="min-w-0 flex-1 leading-relaxed text-theme-text-secondary">
|
|
776
|
+
<span className="font-medium text-theme-text-primary">
|
|
777
|
+
{group.label}:{" "}
|
|
778
|
+
</span>
|
|
779
|
+
{group.summary}
|
|
780
|
+
</span>
|
|
781
|
+
<CollapseChevron open={open} className="h-3.5 w-3.5 shrink-0" />
|
|
782
|
+
</button>
|
|
783
|
+
<div id={regionId}>
|
|
784
|
+
<Collapse open={open}>
|
|
785
|
+
<ul className="space-y-2 border-t border-theme-border/60 px-3 py-2.5">
|
|
786
|
+
{group.limitations.map((limitation, index) => (
|
|
787
|
+
<li
|
|
788
|
+
key={index}
|
|
789
|
+
className="flex min-w-0 items-start gap-2 text-xs"
|
|
790
|
+
>
|
|
791
|
+
{limitation.kind === "error" ? (
|
|
792
|
+
<CircleAlert
|
|
793
|
+
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-400"
|
|
794
|
+
aria-hidden
|
|
795
|
+
/>
|
|
796
|
+
) : limitation.kind === "truncated" ? (
|
|
797
|
+
<AlertTriangle
|
|
798
|
+
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500"
|
|
799
|
+
aria-hidden
|
|
800
|
+
/>
|
|
801
|
+
) : (
|
|
802
|
+
<Info
|
|
803
|
+
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
|
|
804
|
+
aria-hidden
|
|
805
|
+
/>
|
|
806
|
+
)}
|
|
807
|
+
<p className="min-w-0 flex-1 leading-relaxed text-theme-text-secondary">
|
|
808
|
+
<span className="font-medium text-theme-text-primary">
|
|
809
|
+
{limitation.source}:
|
|
810
|
+
</span>{" "}
|
|
811
|
+
{limitation.message}
|
|
812
|
+
</p>
|
|
813
|
+
{limitation.sources.at(-1) ? (
|
|
814
|
+
<SourceButton
|
|
815
|
+
ariaLabel={`View source for ${limitation.source}`}
|
|
816
|
+
buttonLabel={
|
|
817
|
+
limitation.sources.length > 1
|
|
818
|
+
? "View latest in Activity"
|
|
819
|
+
: undefined
|
|
820
|
+
}
|
|
821
|
+
onClick={() => onViewSource(limitation.sources.at(-1)!.id)}
|
|
822
|
+
/>
|
|
823
|
+
) : null}
|
|
824
|
+
</li>
|
|
825
|
+
))}
|
|
826
|
+
</ul>
|
|
827
|
+
</Collapse>
|
|
828
|
+
</div>
|
|
829
|
+
</div>
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function previousDifferentObservations(
|
|
834
|
+
group: InvestigationEvidenceGroup,
|
|
835
|
+
citedOrder?: number,
|
|
836
|
+
) {
|
|
837
|
+
const seen = new Set([evidenceDisplaySnapshot(group.latest)]);
|
|
838
|
+
return [...group.observations].reverse().filter((observation) => {
|
|
839
|
+
if (
|
|
840
|
+
observation.source.order !== citedOrder &&
|
|
841
|
+
group.observations.some(
|
|
842
|
+
(other) =>
|
|
843
|
+
other.source.order === citedOrder &&
|
|
844
|
+
evidenceDisplaySnapshot(other) ===
|
|
845
|
+
evidenceDisplaySnapshot(observation),
|
|
846
|
+
)
|
|
847
|
+
)
|
|
848
|
+
return false;
|
|
849
|
+
const snapshot = evidenceDisplaySnapshot(observation);
|
|
850
|
+
if (seen.has(snapshot)) return false;
|
|
851
|
+
seen.add(snapshot);
|
|
852
|
+
return true;
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
export function investigationEvidenceShouldRevealHistory(
|
|
857
|
+
group: InvestigationEvidenceGroup,
|
|
858
|
+
sourceId?: string,
|
|
859
|
+
): boolean {
|
|
860
|
+
return (
|
|
861
|
+
Boolean(sourceId) &&
|
|
862
|
+
group.latest.source.id !== sourceId &&
|
|
863
|
+
group.observations.some(
|
|
864
|
+
(observation) =>
|
|
865
|
+
observation.source.id === sourceId &&
|
|
866
|
+
evidenceDisplaySnapshot(observation) !==
|
|
867
|
+
evidenceDisplaySnapshot(group.latest),
|
|
868
|
+
)
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function EvidenceCard({
|
|
873
|
+
group,
|
|
874
|
+
domId = group.id,
|
|
875
|
+
animateArrival,
|
|
876
|
+
onViewSource,
|
|
877
|
+
spanFullRow = false,
|
|
878
|
+
prominence = "primary",
|
|
879
|
+
}: {
|
|
880
|
+
group: InvestigationEvidenceGroup;
|
|
881
|
+
/** Stable layout/scroll identity when an existing card changes section. */
|
|
882
|
+
domId?: string;
|
|
883
|
+
animateArrival: boolean;
|
|
884
|
+
onViewSource: (
|
|
885
|
+
sourceId: string,
|
|
886
|
+
excerpt?: InvestigationSourceExcerpt,
|
|
887
|
+
) => void;
|
|
888
|
+
/** Fill both columns when this card has no compact row partner. */
|
|
889
|
+
spanFullRow?: boolean;
|
|
890
|
+
prominence?: "primary" | "supporting" | "secondary";
|
|
891
|
+
}) {
|
|
892
|
+
const {
|
|
893
|
+
onOpenResource,
|
|
894
|
+
revealSourceId,
|
|
895
|
+
revealRequestId,
|
|
896
|
+
citedOrderByGroup,
|
|
897
|
+
expandedGroupIds,
|
|
898
|
+
onGroupOpenChange,
|
|
899
|
+
} = useContext(EvidenceNavigationContext);
|
|
900
|
+
const open = expandedGroupIds?.has(group.id) ?? false;
|
|
901
|
+
const setOpen = useCallback(
|
|
902
|
+
(value: boolean) => onGroupOpenChange?.(group.id, value),
|
|
903
|
+
[onGroupOpenChange, group.id],
|
|
904
|
+
);
|
|
905
|
+
const { elementRef, revealAfterToggle } = useDisclosureReveal<HTMLElement>();
|
|
906
|
+
const observation = group.latest;
|
|
907
|
+
const displaySummary =
|
|
908
|
+
observation.data.type === "crash" && observation.summary
|
|
909
|
+
? parseLogLine(observation.summary).content
|
|
910
|
+
: observation.summary;
|
|
911
|
+
const citedOrder = citedOrderByGroup?.get(group.id);
|
|
912
|
+
const previousObservations = previousDifferentObservations(group, citedOrder);
|
|
913
|
+
const meaningfulHistory = previousObservations.length > 0;
|
|
914
|
+
const citedObservation = group.observations.find(
|
|
915
|
+
(item) => item.source.order === citedOrder,
|
|
916
|
+
);
|
|
917
|
+
const differsFromAssessment =
|
|
918
|
+
citedObservation &&
|
|
919
|
+
evidenceDisplaySnapshot(citedObservation) !==
|
|
920
|
+
evidenceDisplaySnapshot(observation);
|
|
921
|
+
const bodyId = `${domId}-body`;
|
|
922
|
+
const hasEvidenceDetails = evidenceHasDetails(
|
|
923
|
+
observation.data,
|
|
924
|
+
observation.summary,
|
|
925
|
+
);
|
|
926
|
+
const canExpand = hasEvidenceDetails || meaningfulHistory;
|
|
927
|
+
const revealHistory = investigationEvidenceShouldRevealHistory(
|
|
928
|
+
group,
|
|
929
|
+
revealSourceId,
|
|
930
|
+
);
|
|
931
|
+
useLayoutEffect(() => {
|
|
932
|
+
const destination = revealSourceId
|
|
933
|
+
? document.getElementById(
|
|
934
|
+
investigationEvidenceSourceDomId(revealSourceId),
|
|
935
|
+
)
|
|
936
|
+
: null;
|
|
937
|
+
if (
|
|
938
|
+
revealHistory ||
|
|
939
|
+
(destination && elementRef.current?.contains(destination))
|
|
940
|
+
)
|
|
941
|
+
setOpen(true);
|
|
942
|
+
}, [revealHistory, revealSourceId, revealRequestId, elementRef, setOpen]);
|
|
943
|
+
const wide = spanFullRow || evidenceTypePrefersFullRow(observation.data.type);
|
|
944
|
+
const resourceRef = investigationEvidenceSubjectRef(observation.data);
|
|
945
|
+
const resourceIdentity = resourceRef
|
|
946
|
+
? `${resourceRef.namespace ? `${resourceRef.namespace}/` : ""}${resourceRef.name}`
|
|
947
|
+
: undefined;
|
|
948
|
+
const primarySources = uniquePrimarySources(group);
|
|
949
|
+
const inlineSecretKeys =
|
|
950
|
+
observation.data.type === "resource" &&
|
|
951
|
+
observation.data.resource.kind.toLowerCase() === "secret" &&
|
|
952
|
+
!canExpand;
|
|
953
|
+
const headerContent = (
|
|
954
|
+
<>
|
|
955
|
+
<EvidenceIcon observation={observation} prominence={prominence} />
|
|
956
|
+
<span className="min-w-0 flex-1">
|
|
957
|
+
<span className="flex flex-wrap items-center gap-1.5">
|
|
958
|
+
<span className="text-sm font-semibold leading-snug text-theme-text-primary">
|
|
959
|
+
{observation.title}
|
|
960
|
+
</span>
|
|
961
|
+
</span>
|
|
962
|
+
{observation.relevance === "broader" &&
|
|
963
|
+
resourceRef &&
|
|
964
|
+
resourceIdentity &&
|
|
965
|
+
!observation.title.includes(resourceIdentity) ? (
|
|
966
|
+
<span className="mt-0.5 block text-xs text-theme-text-secondary">
|
|
967
|
+
{resourceIdentity} · {resourceRef.kind}
|
|
968
|
+
</span>
|
|
969
|
+
) : null}
|
|
970
|
+
{displaySummary ? (
|
|
971
|
+
<span
|
|
972
|
+
className={clsx(
|
|
973
|
+
"mt-0.5 block text-xs leading-relaxed text-theme-text-secondary",
|
|
974
|
+
!open && !inlineSecretKeys && "line-clamp-2",
|
|
975
|
+
"[overflow-wrap:anywhere]",
|
|
976
|
+
)}
|
|
977
|
+
>
|
|
978
|
+
{displaySummary}
|
|
979
|
+
</span>
|
|
980
|
+
) : null}
|
|
981
|
+
{group.historical ? (
|
|
982
|
+
<span className="mt-0.5 block text-xs text-theme-text-tertiary">
|
|
983
|
+
Previous observation · not confirmed by the latest check
|
|
984
|
+
</span>
|
|
985
|
+
) : differsFromAssessment &&
|
|
986
|
+
citedOrder != null &&
|
|
987
|
+
observation.source.order !== citedOrder ? (
|
|
988
|
+
<span className="mt-0.5 block text-xs text-theme-text-tertiary">
|
|
989
|
+
{observation.source.order > citedOrder
|
|
990
|
+
? "Observed after the assessment’s source"
|
|
991
|
+
: "Earlier observation retained from a more direct source"}
|
|
992
|
+
</span>
|
|
993
|
+
) : null}
|
|
994
|
+
</span>
|
|
995
|
+
{canExpand ? (
|
|
996
|
+
<CollapseChevron open={open} className="h-4 w-4 self-center" />
|
|
997
|
+
) : null}
|
|
998
|
+
</>
|
|
999
|
+
);
|
|
1000
|
+
return (
|
|
1001
|
+
<article
|
|
1002
|
+
data-evidence-source={observation.source.id}
|
|
1003
|
+
ref={elementRef}
|
|
1004
|
+
id={domId}
|
|
1005
|
+
data-evidence-card
|
|
1006
|
+
tabIndex={-1}
|
|
1007
|
+
aria-label={`${observation.title} evidence`}
|
|
1008
|
+
className={clsx(
|
|
1009
|
+
"@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 bg-theme-surface",
|
|
1011
|
+
toneBorder(observation.tone, observation.tier, prominence),
|
|
1012
|
+
wide && "@min-[760px]/evidence:col-span-2",
|
|
1013
|
+
animateArrival && "animate-transcript-enter",
|
|
1014
|
+
)}
|
|
1015
|
+
>
|
|
1016
|
+
{primarySources.map((source) => (
|
|
1017
|
+
<span
|
|
1018
|
+
key={source.id}
|
|
1019
|
+
id={investigationEvidenceSourceDomId(source.id)}
|
|
1020
|
+
className="block scroll-mt-14"
|
|
1021
|
+
aria-hidden
|
|
1022
|
+
/>
|
|
1023
|
+
))}
|
|
1024
|
+
<div className="flex min-w-0 items-stretch">
|
|
1025
|
+
{canExpand ? (
|
|
1026
|
+
<button
|
|
1027
|
+
type="button"
|
|
1028
|
+
aria-expanded={open}
|
|
1029
|
+
aria-controls={bodyId}
|
|
1030
|
+
onClick={() => {
|
|
1031
|
+
const opening = !open;
|
|
1032
|
+
setOpen(opening);
|
|
1033
|
+
revealAfterToggle(opening);
|
|
1034
|
+
}}
|
|
1035
|
+
className={clsx(
|
|
1036
|
+
"flex min-w-0 flex-1 items-start text-left hover:bg-theme-hover/60",
|
|
1037
|
+
prominence === "primary"
|
|
1038
|
+
? "gap-2.5 px-3 py-2.5"
|
|
1039
|
+
: "gap-2 px-2.5 py-2",
|
|
1040
|
+
)}
|
|
1041
|
+
>
|
|
1042
|
+
{headerContent}
|
|
1043
|
+
</button>
|
|
1044
|
+
) : (
|
|
1045
|
+
<div
|
|
1046
|
+
className={clsx(
|
|
1047
|
+
"flex min-w-0 flex-1 items-start text-left",
|
|
1048
|
+
prominence === "primary"
|
|
1049
|
+
? "gap-2.5 px-3 py-2.5"
|
|
1050
|
+
: "gap-2 px-2.5 py-2",
|
|
1051
|
+
)}
|
|
1052
|
+
>
|
|
1053
|
+
{headerContent}
|
|
1054
|
+
</div>
|
|
1055
|
+
)}
|
|
1056
|
+
<div className="flex shrink-0 items-center gap-0.5 px-2">
|
|
1057
|
+
{observation.data.type === "changes" ? (
|
|
1058
|
+
<EvidenceCaveat data={observation.data} />
|
|
1059
|
+
) : null}
|
|
1060
|
+
<SourceButton
|
|
1061
|
+
ariaLabel={`View source for ${observation.title}`}
|
|
1062
|
+
compact
|
|
1063
|
+
onClick={() =>
|
|
1064
|
+
onViewSource(
|
|
1065
|
+
observation.source.id,
|
|
1066
|
+
evidenceSourceExcerpt(observation.data),
|
|
1067
|
+
)
|
|
1068
|
+
}
|
|
1069
|
+
/>
|
|
1070
|
+
<OpenResourceButton
|
|
1071
|
+
resourceRef={resourceRef}
|
|
1072
|
+
onOpenResource={onOpenResource}
|
|
1073
|
+
compact
|
|
1074
|
+
/>
|
|
1075
|
+
</div>
|
|
1076
|
+
</div>
|
|
1077
|
+
{canExpand ? (
|
|
1078
|
+
<div id={bodyId}>
|
|
1079
|
+
<Collapse open={open}>
|
|
1080
|
+
<div
|
|
1081
|
+
className={clsx(
|
|
1082
|
+
"space-y-3 border-t border-theme-border/60",
|
|
1083
|
+
prominence === "primary" ? "px-3 py-3" : "px-2.5 py-2.5",
|
|
1084
|
+
)}
|
|
1085
|
+
>
|
|
1086
|
+
{hasEvidenceDetails ? (
|
|
1087
|
+
<EvidenceBody
|
|
1088
|
+
data={observation.data}
|
|
1089
|
+
cardSummary={observation.summary}
|
|
1090
|
+
/>
|
|
1091
|
+
) : null}
|
|
1092
|
+
{meaningfulHistory ? (
|
|
1093
|
+
<RevisionHistory
|
|
1094
|
+
observations={previousObservations}
|
|
1095
|
+
citedOrder={citedOrder}
|
|
1096
|
+
reveal={revealHistory}
|
|
1097
|
+
revealRequestId={revealRequestId}
|
|
1098
|
+
onViewSource={onViewSource}
|
|
1099
|
+
/>
|
|
1100
|
+
) : null}
|
|
1101
|
+
{observation.data.type !== "changes" ? (
|
|
1102
|
+
<EvidenceCaveat data={observation.data} />
|
|
1103
|
+
) : null}
|
|
1104
|
+
</div>
|
|
1105
|
+
</Collapse>
|
|
1106
|
+
</div>
|
|
1107
|
+
) : null}
|
|
1108
|
+
</article>
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function OpenResourceButton({
|
|
1113
|
+
resourceRef,
|
|
1114
|
+
onOpenResource,
|
|
1115
|
+
compact = false,
|
|
1116
|
+
}: {
|
|
1117
|
+
resourceRef?: DiagnosisResourceRef;
|
|
1118
|
+
onOpenResource?: (ref: DiagnosisResourceRef) => void;
|
|
1119
|
+
compact?: boolean;
|
|
1120
|
+
}) {
|
|
1121
|
+
if (!resourceRef || !onOpenResource) return null;
|
|
1122
|
+
const identity = `${resourceRef.namespace ? `${resourceRef.namespace}/` : ""}${resourceRef.name}`;
|
|
1123
|
+
const label = `Open current ${displayKind(resourceRef.kind)} ${identity} in Radar`;
|
|
1124
|
+
return (
|
|
1125
|
+
<Tooltip
|
|
1126
|
+
content={label}
|
|
1127
|
+
delay={350}
|
|
1128
|
+
position="left"
|
|
1129
|
+
wrapperClassName="flex shrink-0"
|
|
1130
|
+
>
|
|
1131
|
+
<button
|
|
1132
|
+
type="button"
|
|
1133
|
+
aria-label={label}
|
|
1134
|
+
onClick={() => onOpenResource(resourceRef)}
|
|
1135
|
+
className={clsx(
|
|
1136
|
+
"flex h-7 shrink-0 items-center justify-center rounded 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",
|
|
1137
|
+
"gap-1 px-2 text-xs font-medium",
|
|
1138
|
+
)}
|
|
1139
|
+
>
|
|
1140
|
+
{compact ? null : (
|
|
1141
|
+
<span>Open current {displayKind(resourceRef.kind)}</span>
|
|
1142
|
+
)}
|
|
1143
|
+
<SquareArrowOutUpRight className="h-3.5 w-3.5" aria-hidden />
|
|
1144
|
+
</button>
|
|
1145
|
+
</Tooltip>
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function uniquePrimarySources(
|
|
1150
|
+
group: InvestigationEvidenceGroup,
|
|
1151
|
+
): InvestigationEvidenceSource[] {
|
|
1152
|
+
// One tool call can contribute repeated revisions to the same semantic group.
|
|
1153
|
+
// It still owns one navigation destination, and DOM ids must remain unique.
|
|
1154
|
+
const sources = new Map<string, InvestigationEvidenceSource>();
|
|
1155
|
+
for (const observation of group.observations) {
|
|
1156
|
+
const { source } = observation;
|
|
1157
|
+
if (source.primaryGroupId === group.id && !sources.has(source.id)) {
|
|
1158
|
+
sources.set(source.id, source);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
return [...sources.values()];
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function EvidenceBody({
|
|
1165
|
+
data,
|
|
1166
|
+
cardSummary,
|
|
1167
|
+
}: {
|
|
1168
|
+
data: InvestigationEvidenceData;
|
|
1169
|
+
cardSummary?: string;
|
|
1170
|
+
}) {
|
|
1171
|
+
switch (data.type) {
|
|
1172
|
+
case "issue":
|
|
1173
|
+
return <IssueBody data={data} cardSummary={cardSummary} />;
|
|
1174
|
+
case "startup":
|
|
1175
|
+
return <StartupBody data={data} />;
|
|
1176
|
+
case "crash":
|
|
1177
|
+
return <CrashBody data={data} />;
|
|
1178
|
+
case "resource":
|
|
1179
|
+
return <ResourceBody data={data} />;
|
|
1180
|
+
case "logs":
|
|
1181
|
+
return <LogsBody data={data} />;
|
|
1182
|
+
case "events":
|
|
1183
|
+
return <EventsBody data={data} />;
|
|
1184
|
+
case "changes":
|
|
1185
|
+
return <ChangesBody data={data} />;
|
|
1186
|
+
case "dns":
|
|
1187
|
+
return <DNSBody data={data} />;
|
|
1188
|
+
case "network":
|
|
1189
|
+
return <NetworkBody data={data} />;
|
|
1190
|
+
case "relationships":
|
|
1191
|
+
return <RelationshipsBody data={data} />;
|
|
1192
|
+
case "topology":
|
|
1193
|
+
return <TopologyBody data={data} />;
|
|
1194
|
+
case "inventory":
|
|
1195
|
+
return <InventoryBody data={data} />;
|
|
1196
|
+
case "receipt":
|
|
1197
|
+
return (
|
|
1198
|
+
<p className="text-xs text-theme-text-secondary">{data.message}</p>
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function evidenceHasDetails(
|
|
1204
|
+
data: InvestigationEvidenceData,
|
|
1205
|
+
cardSummary?: string,
|
|
1206
|
+
): boolean {
|
|
1207
|
+
switch (data.type) {
|
|
1208
|
+
case "issue": {
|
|
1209
|
+
const summary = cardSummary?.trim();
|
|
1210
|
+
const cause = data.issue.cause?.trim();
|
|
1211
|
+
const message = data.issue.message?.trim();
|
|
1212
|
+
return Boolean(
|
|
1213
|
+
(cause && cause !== summary) ||
|
|
1214
|
+
(message && message !== summary && message !== cause),
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
case "startup":
|
|
1218
|
+
case "receipt":
|
|
1219
|
+
return false;
|
|
1220
|
+
case "resource": {
|
|
1221
|
+
const replicas = data.resourceContext?.workloadSummary?.replicas;
|
|
1222
|
+
return Boolean(
|
|
1223
|
+
investigationResourceEvidenceHasDetails(data.resource) ||
|
|
1224
|
+
replicas?.desired !== undefined ||
|
|
1225
|
+
data.resourceContext?.statusSummary?.conditions?.length ||
|
|
1226
|
+
data.gitOpsDiagnosis ||
|
|
1227
|
+
data.warnings.length,
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
case "logs":
|
|
1231
|
+
return (data.logs?.lines?.length ?? 0) > 0 || Boolean(data.error);
|
|
1232
|
+
case "changes":
|
|
1233
|
+
return data.changes.length > 0 || Boolean(data.changeContext?.evidence);
|
|
1234
|
+
default:
|
|
1235
|
+
return true;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
type EvidenceDataOf<T extends InvestigationEvidenceData["type"]> = Extract<
|
|
1240
|
+
InvestigationEvidenceData,
|
|
1241
|
+
{ type: T }
|
|
1242
|
+
>;
|
|
1243
|
+
|
|
1244
|
+
function IssueBody({
|
|
1245
|
+
data,
|
|
1246
|
+
cardSummary,
|
|
1247
|
+
}: {
|
|
1248
|
+
data: EvidenceDataOf<"issue">;
|
|
1249
|
+
cardSummary?: string;
|
|
1250
|
+
}) {
|
|
1251
|
+
const issue = data.issue;
|
|
1252
|
+
const showCause =
|
|
1253
|
+
Boolean(issue.cause) && issue.cause?.trim() !== cardSummary?.trim();
|
|
1254
|
+
const showMessage =
|
|
1255
|
+
Boolean(issue.message) &&
|
|
1256
|
+
issue.message?.trim() !== cardSummary?.trim() &&
|
|
1257
|
+
issue.message?.trim() !== issue.cause?.trim();
|
|
1258
|
+
return (
|
|
1259
|
+
<div className="space-y-2">
|
|
1260
|
+
<div className="flex flex-wrap gap-1.5">
|
|
1261
|
+
<Badge
|
|
1262
|
+
severity={issue.severity === "critical" ? "error" : "warning"}
|
|
1263
|
+
size="sm"
|
|
1264
|
+
>
|
|
1265
|
+
{issue.severity}
|
|
1266
|
+
</Badge>
|
|
1267
|
+
<Badge tone="structural" size="sm">
|
|
1268
|
+
{issue.kind}
|
|
1269
|
+
</Badge>
|
|
1270
|
+
<Badge tone="structural" size="sm">
|
|
1271
|
+
{issue.namespace ? `${issue.namespace}/` : ""}
|
|
1272
|
+
{issue.name}
|
|
1273
|
+
</Badge>
|
|
1274
|
+
</div>
|
|
1275
|
+
{showCause ? (
|
|
1276
|
+
<p className="text-sm font-medium leading-relaxed text-theme-text-primary">
|
|
1277
|
+
{issue.cause}
|
|
1278
|
+
</p>
|
|
1279
|
+
) : null}
|
|
1280
|
+
{showMessage ? (
|
|
1281
|
+
<p className="text-xs leading-relaxed text-theme-text-secondary">
|
|
1282
|
+
{issue.message}
|
|
1283
|
+
</p>
|
|
1284
|
+
) : null}
|
|
1285
|
+
</div>
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function StartupBody({ data }: { data: EvidenceDataOf<"startup"> }) {
|
|
1290
|
+
const blocker = data.blocker;
|
|
1291
|
+
return (
|
|
1292
|
+
<div className="space-y-2">
|
|
1293
|
+
<div className="flex flex-wrap gap-1.5">
|
|
1294
|
+
<Badge tone="structural" size="sm">
|
|
1295
|
+
{blocker.kind}
|
|
1296
|
+
</Badge>
|
|
1297
|
+
<Badge tone="structural" size="sm">
|
|
1298
|
+
{blocker.name}
|
|
1299
|
+
</Badge>
|
|
1300
|
+
<Badge severity={severityBadge(blocker.severity)} size="sm">
|
|
1301
|
+
{blocker.severity}
|
|
1302
|
+
</Badge>
|
|
1303
|
+
</div>
|
|
1304
|
+
<p className="text-sm leading-relaxed text-theme-text-primary">
|
|
1305
|
+
{blocker.message}
|
|
1306
|
+
</p>
|
|
1307
|
+
</div>
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function CrashBody({ data }: { data: EvidenceDataOf<"crash"> }) {
|
|
1312
|
+
const crash = data.crash;
|
|
1313
|
+
return (
|
|
1314
|
+
<div className="space-y-2.5">
|
|
1315
|
+
<div className="flex flex-wrap gap-1.5">
|
|
1316
|
+
<Badge severity="error" size="sm">
|
|
1317
|
+
{crash.reason || crash.state}
|
|
1318
|
+
</Badge>
|
|
1319
|
+
<Badge tone="structural" size="sm">
|
|
1320
|
+
exit {crash.exitCode}
|
|
1321
|
+
</Badge>
|
|
1322
|
+
<Badge tone="structural" size="sm">
|
|
1323
|
+
{crash.container}
|
|
1324
|
+
</Badge>
|
|
1325
|
+
</div>
|
|
1326
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
1327
|
+
{crash.pods.join(", ")} · {crash.logSource.replaceAll("_", " ")}
|
|
1328
|
+
</p>
|
|
1329
|
+
<TerminalBlock label="Selected crash line">{crash.logLine}</TerminalBlock>
|
|
1330
|
+
</div>
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
|
|
1335
|
+
const replicas = data.resourceContext?.workloadSummary?.replicas;
|
|
1336
|
+
// SealedSecret's dedicated body renders the resource's conditions beside
|
|
1337
|
+
// its controller state, so repeating the derived summary here adds noise.
|
|
1338
|
+
const conditions =
|
|
1339
|
+
data.resource.kind.toLowerCase() === "sealedsecret"
|
|
1340
|
+
? []
|
|
1341
|
+
: (data.resourceContext?.statusSummary?.conditions ?? []);
|
|
1342
|
+
const desired = replicas?.desired;
|
|
1343
|
+
const ready = replicas ? (replicas.ready ?? 0) : undefined;
|
|
1344
|
+
const shortfall =
|
|
1345
|
+
desired !== undefined && ready !== undefined && ready < desired;
|
|
1346
|
+
return (
|
|
1347
|
+
<div className="space-y-3">
|
|
1348
|
+
<InvestigationResourceEvidence resource={data.resource} />
|
|
1349
|
+
{data.gitOpsDiagnosis ? (
|
|
1350
|
+
<GitOpsStatusBody status={data.gitOpsDiagnosis} />
|
|
1351
|
+
) : null}
|
|
1352
|
+
{desired !== undefined && ready !== undefined ? (
|
|
1353
|
+
<dl className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
|
1354
|
+
<div>
|
|
1355
|
+
<dt className="text-theme-text-tertiary">Ready replicas</dt>
|
|
1356
|
+
<dd
|
|
1357
|
+
className={clsx(
|
|
1358
|
+
"font-mono font-semibold tabular-nums",
|
|
1359
|
+
shortfall ? "text-warning-text" : "text-theme-text-primary",
|
|
1360
|
+
)}
|
|
1361
|
+
>
|
|
1362
|
+
{ready}/{desired}
|
|
1363
|
+
</dd>
|
|
1364
|
+
</div>
|
|
1365
|
+
<ResourceFact label="Available" value={replicas?.available} />
|
|
1366
|
+
<ResourceFact label="Updated" value={replicas?.updated} />
|
|
1367
|
+
<ResourceFact label="Unavailable" value={replicas?.unavailable} />
|
|
1368
|
+
<ResourceFact
|
|
1369
|
+
label="Phase"
|
|
1370
|
+
value={data.resourceContext?.statusSummary?.phase}
|
|
1371
|
+
/>
|
|
1372
|
+
</dl>
|
|
1373
|
+
) : null}
|
|
1374
|
+
{conditions.length > 0 ? (
|
|
1375
|
+
<div>
|
|
1376
|
+
<div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
1377
|
+
Conditions
|
|
1378
|
+
</div>
|
|
1379
|
+
<div className="max-h-52 space-y-1.5 overflow-y-auto pr-1">
|
|
1380
|
+
{conditions.map((condition) => (
|
|
1381
|
+
<div
|
|
1382
|
+
key={`${condition.type}-${condition.status}-${condition.reason ?? ""}`}
|
|
1383
|
+
className="flex min-w-0 items-start gap-2 text-xs"
|
|
1384
|
+
>
|
|
1385
|
+
<StatusDot
|
|
1386
|
+
tone={conditionStatusTone(condition)}
|
|
1387
|
+
className="mt-1 shrink-0"
|
|
1388
|
+
/>
|
|
1389
|
+
<p className="min-w-0 text-theme-text-secondary">
|
|
1390
|
+
<span className="font-medium text-theme-text-primary">
|
|
1391
|
+
{condition.type}={condition.status}
|
|
1392
|
+
</span>
|
|
1393
|
+
{condition.reason ? ` · ${condition.reason}` : ""}
|
|
1394
|
+
{condition.message ? ` — ${condition.message}` : ""}
|
|
1395
|
+
</p>
|
|
1396
|
+
</div>
|
|
1397
|
+
))}
|
|
1398
|
+
</div>
|
|
1399
|
+
</div>
|
|
1400
|
+
) : null}
|
|
1401
|
+
{data.warnings.length > 0 ? (
|
|
1402
|
+
<ul className="space-y-1 text-xs text-theme-text-secondary">
|
|
1403
|
+
{data.warnings.map((warning) => (
|
|
1404
|
+
<li key={warning} className="flex items-start gap-1.5">
|
|
1405
|
+
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
1406
|
+
<span>{warning}</span>
|
|
1407
|
+
</li>
|
|
1408
|
+
))}
|
|
1409
|
+
</ul>
|
|
1410
|
+
) : null}
|
|
1411
|
+
</div>
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function GitOpsStatusBody({
|
|
1416
|
+
status,
|
|
1417
|
+
}: {
|
|
1418
|
+
status: NonNullable<EvidenceDataOf<"resource">["gitOpsDiagnosis"]>;
|
|
1419
|
+
}) {
|
|
1420
|
+
const fields = [
|
|
1421
|
+
["Sync", status.sync],
|
|
1422
|
+
["Health", status.health],
|
|
1423
|
+
["Operation", status.operationPhase],
|
|
1424
|
+
["Ready", status.ready],
|
|
1425
|
+
] as const;
|
|
1426
|
+
return (
|
|
1427
|
+
<div className="rounded-md border border-theme-border bg-theme-base/40 p-2.5">
|
|
1428
|
+
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
|
1429
|
+
<span className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
1430
|
+
GitOps controller status
|
|
1431
|
+
</span>
|
|
1432
|
+
<Badge tone="note" size="sm">
|
|
1433
|
+
{status.tool === "argocd" ? "Argo CD" : "Flux"}
|
|
1434
|
+
</Badge>
|
|
1435
|
+
{status.suspended ? (
|
|
1436
|
+
<Badge severity="info" size="sm">
|
|
1437
|
+
Suspended
|
|
1438
|
+
</Badge>
|
|
1439
|
+
) : null}
|
|
1440
|
+
</div>
|
|
1441
|
+
<div className="flex flex-wrap gap-1.5">
|
|
1442
|
+
{fields.map(([label, value]) =>
|
|
1443
|
+
value ? (
|
|
1444
|
+
<Badge
|
|
1445
|
+
key={label}
|
|
1446
|
+
severity={gitOpsValueSeverity(label, value)}
|
|
1447
|
+
size="sm"
|
|
1448
|
+
>
|
|
1449
|
+
{label}: {value}
|
|
1450
|
+
</Badge>
|
|
1451
|
+
) : null,
|
|
1452
|
+
)}
|
|
1453
|
+
{status.appliedRevision ? (
|
|
1454
|
+
<Badge tone="structural" size="sm">
|
|
1455
|
+
{status.appliedRevision}
|
|
1456
|
+
</Badge>
|
|
1457
|
+
) : null}
|
|
1458
|
+
</div>
|
|
1459
|
+
</div>
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function gitOpsValueSeverity(label: string, value: string) {
|
|
1464
|
+
const normalized = value.toLowerCase();
|
|
1465
|
+
if (
|
|
1466
|
+
normalized === "healthy" ||
|
|
1467
|
+
normalized === "synced" ||
|
|
1468
|
+
normalized === "succeeded" ||
|
|
1469
|
+
(label === "Ready" && normalized.startsWith("true"))
|
|
1470
|
+
) {
|
|
1471
|
+
return "success" as const;
|
|
1472
|
+
}
|
|
1473
|
+
if (
|
|
1474
|
+
normalized === "degraded" ||
|
|
1475
|
+
normalized === "missing" ||
|
|
1476
|
+
normalized === "failed" ||
|
|
1477
|
+
normalized === "error" ||
|
|
1478
|
+
(label === "Ready" && normalized.startsWith("false"))
|
|
1479
|
+
) {
|
|
1480
|
+
return "error" as const;
|
|
1481
|
+
}
|
|
1482
|
+
if (normalized === "outofsync") return "warning" as const;
|
|
1483
|
+
if (normalized === "progressing" || normalized === "running")
|
|
1484
|
+
return "info" as const;
|
|
1485
|
+
return "neutral" as const;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function ResourceFact({ label, value }: { label: string; value: unknown }) {
|
|
1489
|
+
if (value === undefined || value === null || value === "") return null;
|
|
1490
|
+
return (
|
|
1491
|
+
<div className="flex justify-between gap-2 @min-[560px]/evidence:block">
|
|
1492
|
+
<dt className="text-theme-text-tertiary">{label}</dt>
|
|
1493
|
+
<dd className="font-mono font-medium text-theme-text-primary">
|
|
1494
|
+
{String(value)}
|
|
1495
|
+
</dd>
|
|
1496
|
+
</div>
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function conditionStatusTone(condition: {
|
|
1501
|
+
type: string;
|
|
1502
|
+
status: string;
|
|
1503
|
+
}): "healthy" | "degraded" | "unhealthy" | "unknown" {
|
|
1504
|
+
switch (defaultConditionTone(condition)) {
|
|
1505
|
+
case "ok":
|
|
1506
|
+
return "healthy";
|
|
1507
|
+
case "warning":
|
|
1508
|
+
return "degraded";
|
|
1509
|
+
case "fail":
|
|
1510
|
+
return "unhealthy";
|
|
1511
|
+
case "unknown":
|
|
1512
|
+
return "unknown";
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function LogsBody({ data }: { data: EvidenceDataOf<"logs"> }) {
|
|
1517
|
+
const lines = data.logs?.lines ?? [];
|
|
1518
|
+
const visibleLines = lines
|
|
1519
|
+
.slice(-VISIBLE_LOG_EVIDENCE_LINES)
|
|
1520
|
+
.map((line) => stripAnsi(line));
|
|
1521
|
+
const omittedLines = lines.length - visibleLines.length;
|
|
1522
|
+
return (
|
|
1523
|
+
<div className="space-y-2">
|
|
1524
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
1525
|
+
<Badge tone="structural" size="sm">
|
|
1526
|
+
{data.pod} / {data.container}
|
|
1527
|
+
</Badge>
|
|
1528
|
+
<Badge severity="neutral" size="sm">
|
|
1529
|
+
{data.previous ? "previous instance" : "current instance"}
|
|
1530
|
+
</Badge>
|
|
1531
|
+
{data.logs?.fallback ? (
|
|
1532
|
+
<Badge severity="warning" size="sm">
|
|
1533
|
+
Unfiltered log tail
|
|
1534
|
+
</Badge>
|
|
1535
|
+
) : null}
|
|
1536
|
+
{data.logs ? (
|
|
1537
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
1538
|
+
{data.logs.matchedLines} matching lines · {data.logs.totalLines}{" "}
|
|
1539
|
+
processed from the requested log tail
|
|
1540
|
+
</span>
|
|
1541
|
+
) : null}
|
|
1542
|
+
</div>
|
|
1543
|
+
{visibleLines.length > 0 ? (
|
|
1544
|
+
<TerminalBlock
|
|
1545
|
+
label={
|
|
1546
|
+
omittedLines > 0
|
|
1547
|
+
? `Selected log excerpt · last ${visibleLines.length} of ${lines.length} lines`
|
|
1548
|
+
: "Selected log excerpt"
|
|
1549
|
+
}
|
|
1550
|
+
>
|
|
1551
|
+
{visibleLines.join("\n")}
|
|
1552
|
+
</TerminalBlock>
|
|
1553
|
+
) : (
|
|
1554
|
+
<p className="text-xs italic text-theme-text-tertiary">
|
|
1555
|
+
No lines were captured from this stream.
|
|
1556
|
+
</p>
|
|
1557
|
+
)}
|
|
1558
|
+
{data.error ? (
|
|
1559
|
+
<p className="text-xs leading-relaxed text-red-400">{data.error}</p>
|
|
1560
|
+
) : null}
|
|
1561
|
+
{data.warnings.map((warning) => (
|
|
1562
|
+
<p key={warning} className="text-xs text-warning-text">
|
|
1563
|
+
{warning}
|
|
1564
|
+
</p>
|
|
1565
|
+
))}
|
|
1566
|
+
</div>
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function EventsBody({ data }: { data: EvidenceDataOf<"events"> }) {
|
|
1571
|
+
return (
|
|
1572
|
+
<div>
|
|
1573
|
+
<p className="mb-2 text-xs text-theme-text-tertiary">{data.scope}</p>
|
|
1574
|
+
<ol className="max-h-[28rem] space-y-0 overflow-y-auto pr-1">
|
|
1575
|
+
{data.events.map((event, index) => (
|
|
1576
|
+
<li
|
|
1577
|
+
key={`${event.reason}-${event.lastTimestamp}-${index}`}
|
|
1578
|
+
className="relative flex gap-3 pb-3 last:pb-0"
|
|
1579
|
+
>
|
|
1580
|
+
{index < data.events.length - 1 ? (
|
|
1581
|
+
<span className="absolute bottom-0 left-[5px] top-3 w-px bg-theme-border" />
|
|
1582
|
+
) : null}
|
|
1583
|
+
<span className="relative mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full border-2 border-amber-500 bg-theme-surface" />
|
|
1584
|
+
<div className="min-w-0 flex-1">
|
|
1585
|
+
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
|
|
1586
|
+
<span className="text-xs font-semibold text-theme-text-primary">
|
|
1587
|
+
{event.reason}
|
|
1588
|
+
{event.count > 1 ? ` ×${event.count}` : ""}
|
|
1589
|
+
</span>
|
|
1590
|
+
<Tooltip
|
|
1591
|
+
content={new Date(event.lastTimestamp).toLocaleString()}
|
|
1592
|
+
delay={150}
|
|
1593
|
+
position="left"
|
|
1594
|
+
>
|
|
1595
|
+
<time
|
|
1596
|
+
dateTime={event.lastTimestamp}
|
|
1597
|
+
className="text-xs text-theme-text-tertiary"
|
|
1598
|
+
>
|
|
1599
|
+
{formatRelativeAgeTime(event.lastTimestamp)}
|
|
1600
|
+
</time>
|
|
1601
|
+
</Tooltip>
|
|
1602
|
+
</div>
|
|
1603
|
+
<p className="mt-0.5 text-xs leading-relaxed text-theme-text-secondary">
|
|
1604
|
+
{event.message}
|
|
1605
|
+
</p>
|
|
1606
|
+
</div>
|
|
1607
|
+
</li>
|
|
1608
|
+
))}
|
|
1609
|
+
</ol>
|
|
1610
|
+
</div>
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function ChangesBody({ data }: { data: EvidenceDataOf<"changes"> }) {
|
|
1615
|
+
const { onOpenResource } = useContext(EvidenceNavigationContext);
|
|
1616
|
+
return (
|
|
1617
|
+
<div className="space-y-2.5">
|
|
1618
|
+
{data.changeContext?.evidence ? (
|
|
1619
|
+
<p className="text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
|
|
1620
|
+
{data.changeContext.evidence}
|
|
1621
|
+
</p>
|
|
1622
|
+
) : null}
|
|
1623
|
+
{data.changes.map((change, index) => (
|
|
1624
|
+
<div
|
|
1625
|
+
key={`${change.kind}-${change.namespace ?? ""}-${change.name}-${change.timestamp}-${index}`}
|
|
1626
|
+
className="rounded-md border border-theme-border/70 bg-theme-base/30 p-2.5"
|
|
1627
|
+
>
|
|
1628
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
1629
|
+
<Badge tone="structural" size="sm">
|
|
1630
|
+
{change.kind}
|
|
1631
|
+
</Badge>
|
|
1632
|
+
<span className="font-mono text-xs">
|
|
1633
|
+
<ResourceLink
|
|
1634
|
+
name={change.name}
|
|
1635
|
+
kind={change.kind}
|
|
1636
|
+
namespace={change.namespace ?? ""}
|
|
1637
|
+
group={
|
|
1638
|
+
change.apiVersion
|
|
1639
|
+
? apiVersionToGroup(change.apiVersion)
|
|
1640
|
+
: undefined
|
|
1641
|
+
}
|
|
1642
|
+
label={`${change.namespace ? `${change.namespace}/` : ""}${change.name}`}
|
|
1643
|
+
onNavigate={
|
|
1644
|
+
change.apiVersion && onOpenResource
|
|
1645
|
+
? (ref) => onOpenResource(ref)
|
|
1646
|
+
: undefined
|
|
1647
|
+
}
|
|
1648
|
+
/>
|
|
1649
|
+
</span>
|
|
1650
|
+
<Badge tone="note" size="sm">
|
|
1651
|
+
{change.changeType.replaceAll("_", " ")}
|
|
1652
|
+
</Badge>
|
|
1653
|
+
<Tooltip
|
|
1654
|
+
content={new Date(change.timestamp).toLocaleString()}
|
|
1655
|
+
delay={150}
|
|
1656
|
+
position="left"
|
|
1657
|
+
wrapperClassName="ml-auto"
|
|
1658
|
+
>
|
|
1659
|
+
<time
|
|
1660
|
+
dateTime={change.timestamp}
|
|
1661
|
+
className="text-xs text-theme-text-tertiary"
|
|
1662
|
+
>
|
|
1663
|
+
{formatRelativeAgeTime(change.timestamp)}
|
|
1664
|
+
</time>
|
|
1665
|
+
</Tooltip>
|
|
1666
|
+
</div>
|
|
1667
|
+
{change.summary ? (
|
|
1668
|
+
<p className="mt-1.5 text-xs text-theme-text-secondary">
|
|
1669
|
+
{change.summary}
|
|
1670
|
+
</p>
|
|
1671
|
+
) : null}
|
|
1672
|
+
{change.fields?.length ? (
|
|
1673
|
+
<div className="mt-2">
|
|
1674
|
+
<DiffViewer
|
|
1675
|
+
diff={{
|
|
1676
|
+
summary: `${change.fields.length} changed ${change.fields.length === 1 ? "field" : "fields"}`,
|
|
1677
|
+
fields: change.fields.map((field) => ({
|
|
1678
|
+
path: field.path,
|
|
1679
|
+
oldValue: field.oldValue ?? null,
|
|
1680
|
+
newValue: field.newValue ?? null,
|
|
1681
|
+
})),
|
|
1682
|
+
}}
|
|
1683
|
+
/>
|
|
1684
|
+
</div>
|
|
1685
|
+
) : null}
|
|
1686
|
+
</div>
|
|
1687
|
+
))}
|
|
1688
|
+
</div>
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function DNSBody({ data }: { data: EvidenceDataOf<"dns"> }) {
|
|
1693
|
+
const { onOpenResource } = useContext(EvidenceNavigationContext);
|
|
1694
|
+
return (
|
|
1695
|
+
<div className="space-y-2">
|
|
1696
|
+
{(data.dns.signals ?? []).map((signal) => (
|
|
1697
|
+
<p
|
|
1698
|
+
key={signal}
|
|
1699
|
+
className="text-xs leading-relaxed text-theme-text-secondary"
|
|
1700
|
+
>
|
|
1701
|
+
{signal}
|
|
1702
|
+
</p>
|
|
1703
|
+
))}
|
|
1704
|
+
{(data.dns.coreDNSFindings ?? []).map((finding) => (
|
|
1705
|
+
<div
|
|
1706
|
+
key={`${finding.kind}-${finding.namespace}-${finding.name}-${finding.reason}`}
|
|
1707
|
+
className="rounded-md border border-theme-border bg-theme-base/40 p-2"
|
|
1708
|
+
>
|
|
1709
|
+
<div className="flex flex-wrap gap-1.5">
|
|
1710
|
+
<Badge tone="structural" size="sm">
|
|
1711
|
+
{finding.kind}
|
|
1712
|
+
</Badge>
|
|
1713
|
+
<span className="font-mono text-xs">
|
|
1714
|
+
<ResourceLink
|
|
1715
|
+
name={finding.name}
|
|
1716
|
+
kind={finding.kind}
|
|
1717
|
+
namespace={finding.namespace}
|
|
1718
|
+
group=""
|
|
1719
|
+
label={`${finding.namespace}/${finding.name}`}
|
|
1720
|
+
onNavigate={
|
|
1721
|
+
finding.kind.toLowerCase() === "configmap" && onOpenResource
|
|
1722
|
+
? (ref) => onOpenResource(ref)
|
|
1723
|
+
: undefined
|
|
1724
|
+
}
|
|
1725
|
+
/>
|
|
1726
|
+
</span>
|
|
1727
|
+
<Badge severity={severityBadge(finding.severity)} size="sm">
|
|
1728
|
+
{finding.severity}
|
|
1729
|
+
</Badge>
|
|
1730
|
+
</div>
|
|
1731
|
+
<p className="mt-1 text-xs text-theme-text-secondary">
|
|
1732
|
+
{finding.reason}
|
|
1733
|
+
{finding.message ? ` — ${finding.message}` : ""}
|
|
1734
|
+
</p>
|
|
1735
|
+
</div>
|
|
1736
|
+
))}
|
|
1737
|
+
</div>
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
function NetworkBody({ data }: { data: EvidenceDataOf<"network"> }) {
|
|
1742
|
+
const { network } = data;
|
|
1743
|
+
const stats = [
|
|
1744
|
+
["Tested", network.summary.tested],
|
|
1745
|
+
["Passed", network.summary.passed],
|
|
1746
|
+
["Failed", network.summary.failed],
|
|
1747
|
+
["Inferred", network.summary.derived ?? 0],
|
|
1748
|
+
["Skipped", network.summary.skipped],
|
|
1749
|
+
] as const;
|
|
1750
|
+
return (
|
|
1751
|
+
<div className="space-y-3">
|
|
1752
|
+
<div className="grid grid-cols-3 gap-1.5 @min-[620px]/evidence:grid-cols-5">
|
|
1753
|
+
{stats.map(([label, value]) => (
|
|
1754
|
+
<div
|
|
1755
|
+
key={label}
|
|
1756
|
+
className="rounded-md border border-theme-border bg-theme-base/40 px-2 py-1.5 text-center"
|
|
1757
|
+
>
|
|
1758
|
+
<div className="font-mono text-sm font-semibold text-theme-text-primary">
|
|
1759
|
+
{value}
|
|
1760
|
+
</div>
|
|
1761
|
+
<div className="text-xs uppercase tracking-wide text-theme-text-tertiary">
|
|
1762
|
+
{label}
|
|
1763
|
+
</div>
|
|
1764
|
+
</div>
|
|
1765
|
+
))}
|
|
1766
|
+
</div>
|
|
1767
|
+
{network.diagnosis ? (
|
|
1768
|
+
<div className="rounded-md border border-theme-border bg-theme-base/40 px-2.5 py-2">
|
|
1769
|
+
<p className="text-xs font-medium leading-relaxed text-theme-text-primary">
|
|
1770
|
+
{network.diagnosis.summary}
|
|
1771
|
+
</p>
|
|
1772
|
+
{network.diagnosis.nextAction ? (
|
|
1773
|
+
<p className="mt-1 border-l-2 border-accent/50 pl-2 text-xs leading-relaxed text-theme-text-secondary">
|
|
1774
|
+
Next check: {network.diagnosis.nextAction}
|
|
1775
|
+
</p>
|
|
1776
|
+
) : null}
|
|
1777
|
+
</div>
|
|
1778
|
+
) : (
|
|
1779
|
+
<p className="text-xs leading-relaxed text-theme-text-secondary">
|
|
1780
|
+
{network.summary.headline}
|
|
1781
|
+
</p>
|
|
1782
|
+
)}
|
|
1783
|
+
{network.routes.length > 0 ? (
|
|
1784
|
+
<ol className="max-h-64 space-y-1.5 overflow-y-auto pr-1">
|
|
1785
|
+
{network.routes.map((route, index) => (
|
|
1786
|
+
<li
|
|
1787
|
+
key={`${route.route}-${route.target ?? ""}-${index}`}
|
|
1788
|
+
className="flex min-w-0 items-start gap-2 rounded-md border border-theme-border/70 bg-theme-base/30 px-2.5 py-2"
|
|
1789
|
+
>
|
|
1790
|
+
<StatusDot
|
|
1791
|
+
tone={networkOutcomeTone(route.outcome, route.benign)}
|
|
1792
|
+
className="mt-1 shrink-0"
|
|
1793
|
+
/>
|
|
1794
|
+
<span className="min-w-0 flex-1">
|
|
1795
|
+
<span className="block truncate font-mono text-xs text-theme-text-primary">
|
|
1796
|
+
{route.route}
|
|
1797
|
+
{route.target ? ` → ${route.target}` : ""}
|
|
1798
|
+
</span>
|
|
1799
|
+
{route.evidence ? (
|
|
1800
|
+
<span className="mt-0.5 block text-xs leading-relaxed text-theme-text-secondary">
|
|
1801
|
+
{route.evidence}
|
|
1802
|
+
</span>
|
|
1803
|
+
) : null}
|
|
1804
|
+
</span>
|
|
1805
|
+
<Badge
|
|
1806
|
+
severity={networkOutcomeSeverity(route.outcome, route.benign)}
|
|
1807
|
+
size="sm"
|
|
1808
|
+
>
|
|
1809
|
+
{route.benign
|
|
1810
|
+
? "intentional"
|
|
1811
|
+
: route.outcome.replaceAll("_", " ")}
|
|
1812
|
+
</Badge>
|
|
1813
|
+
</li>
|
|
1814
|
+
))}
|
|
1815
|
+
</ol>
|
|
1816
|
+
) : null}
|
|
1817
|
+
</div>
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function networkOutcomeTone(
|
|
1822
|
+
outcome: string,
|
|
1823
|
+
benign?: boolean,
|
|
1824
|
+
): "healthy" | "degraded" | "unhealthy" | "unknown" {
|
|
1825
|
+
if (benign) return "degraded";
|
|
1826
|
+
const normalized = outcome.toLowerCase();
|
|
1827
|
+
if (normalized.includes("verified") || normalized.includes("reached"))
|
|
1828
|
+
return "healthy";
|
|
1829
|
+
if (normalized.includes("fail") || normalized.includes("unreachable"))
|
|
1830
|
+
return "unhealthy";
|
|
1831
|
+
if (normalized.includes("skip") || normalized.includes("not"))
|
|
1832
|
+
return "unknown";
|
|
1833
|
+
return "degraded";
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function networkOutcomeSeverity(outcome: string, benign?: boolean) {
|
|
1837
|
+
switch (networkOutcomeTone(outcome, benign)) {
|
|
1838
|
+
case "healthy":
|
|
1839
|
+
return "success" as const;
|
|
1840
|
+
case "degraded":
|
|
1841
|
+
return "warning" as const;
|
|
1842
|
+
case "unhealthy":
|
|
1843
|
+
return "error" as const;
|
|
1844
|
+
case "unknown":
|
|
1845
|
+
return "neutral" as const;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function RelationshipsBody({
|
|
1850
|
+
data,
|
|
1851
|
+
}: {
|
|
1852
|
+
data: EvidenceDataOf<"relationships">;
|
|
1853
|
+
}) {
|
|
1854
|
+
return (
|
|
1855
|
+
<div className="space-y-2.5">
|
|
1856
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
1857
|
+
<Badge tone="structural" size="sm">
|
|
1858
|
+
{data.root.kind}
|
|
1859
|
+
</Badge>
|
|
1860
|
+
<span className="font-mono text-xs text-theme-text-secondary">
|
|
1861
|
+
{data.root.namespace ? `${data.root.namespace}/` : ""}
|
|
1862
|
+
{data.root.name}
|
|
1863
|
+
</span>
|
|
1864
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
1865
|
+
{data.nodes.length} resources · {data.edges.length} direct
|
|
1866
|
+
relationships
|
|
1867
|
+
</span>
|
|
1868
|
+
</div>
|
|
1869
|
+
<div className="max-h-44 overflow-y-auto pr-1">
|
|
1870
|
+
<div className="flex flex-wrap gap-1.5">
|
|
1871
|
+
{data.nodes.map((node) => (
|
|
1872
|
+
<span
|
|
1873
|
+
key={node.id}
|
|
1874
|
+
className="inline-flex items-center gap-1 rounded-md border border-theme-border bg-theme-base px-2 py-1 text-xs"
|
|
1875
|
+
>
|
|
1876
|
+
<Badge tone="structural" size="sm">
|
|
1877
|
+
{node.kind}
|
|
1878
|
+
</Badge>
|
|
1879
|
+
<span className="font-mono text-theme-text-secondary">
|
|
1880
|
+
{node.name}
|
|
1881
|
+
</span>
|
|
1882
|
+
</span>
|
|
1883
|
+
))}
|
|
1884
|
+
</div>
|
|
1885
|
+
</div>
|
|
1886
|
+
{data.edges.length > 0 ? (
|
|
1887
|
+
<div className="grid max-h-52 gap-1 overflow-y-auto pr-1 @min-[680px]/evidence:grid-cols-2">
|
|
1888
|
+
{data.edges.map((edge, index) => (
|
|
1889
|
+
<div
|
|
1890
|
+
key={
|
|
1891
|
+
edge.id ?? `${edge.source}-${edge.target}-${edge.type}-${index}`
|
|
1892
|
+
}
|
|
1893
|
+
className="flex min-w-0 items-center gap-1.5 rounded bg-theme-base/50 px-2 py-1.5 font-mono text-xs text-theme-text-secondary"
|
|
1894
|
+
>
|
|
1895
|
+
<span className="truncate">{edge.source}</span>
|
|
1896
|
+
<span className="shrink-0 text-theme-text-tertiary">→</span>
|
|
1897
|
+
<span className="truncate">{edge.target}</span>
|
|
1898
|
+
<Badge tone="structural" size="sm">
|
|
1899
|
+
{edge.label || edge.type}
|
|
1900
|
+
</Badge>
|
|
1901
|
+
</div>
|
|
1902
|
+
))}
|
|
1903
|
+
</div>
|
|
1904
|
+
) : null}
|
|
1905
|
+
</div>
|
|
1906
|
+
);
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
function TopologyBody({ data }: { data: EvidenceDataOf<"topology"> }) {
|
|
1910
|
+
return (
|
|
1911
|
+
<div className="space-y-2.5">
|
|
1912
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1913
|
+
<TopologyStat label="Nodes" value={data.stats.nodes} />
|
|
1914
|
+
<TopologyStat label="Relationships" value={data.stats.edges} />
|
|
1915
|
+
</div>
|
|
1916
|
+
{data.problems.map((problem) => (
|
|
1917
|
+
<p
|
|
1918
|
+
key={problem}
|
|
1919
|
+
className="flex items-start gap-1.5 text-xs text-warning-text"
|
|
1920
|
+
>
|
|
1921
|
+
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
1922
|
+
{problem}
|
|
1923
|
+
</p>
|
|
1924
|
+
))}
|
|
1925
|
+
<div className="max-h-64 space-y-2 overflow-y-auto pr-1">
|
|
1926
|
+
{data.namespaces.map((namespace) => (
|
|
1927
|
+
<div key={namespace.namespace}>
|
|
1928
|
+
<div className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
1929
|
+
{namespace.namespace || "cluster-scoped"}
|
|
1930
|
+
</div>
|
|
1931
|
+
<ul className="mt-1 space-y-1 font-mono text-xs text-theme-text-secondary">
|
|
1932
|
+
{namespace.chains.map((chain) => (
|
|
1933
|
+
<li key={chain}>{chain}</li>
|
|
1934
|
+
))}
|
|
1935
|
+
</ul>
|
|
1936
|
+
</div>
|
|
1937
|
+
))}
|
|
1938
|
+
</div>
|
|
1939
|
+
{data.warnings.map((warning) => (
|
|
1940
|
+
<p key={warning} className="text-xs text-warning-text">
|
|
1941
|
+
{warning}
|
|
1942
|
+
</p>
|
|
1943
|
+
))}
|
|
1944
|
+
</div>
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function TopologyStat({ label, value }: { label: string; value: number }) {
|
|
1949
|
+
return (
|
|
1950
|
+
<div className="rounded-md border border-theme-border bg-theme-base/40 px-3 py-2">
|
|
1951
|
+
<div className="font-mono text-lg font-semibold text-theme-text-primary">
|
|
1952
|
+
{value}
|
|
1953
|
+
</div>
|
|
1954
|
+
<div className="text-xs uppercase tracking-wide text-theme-text-tertiary">
|
|
1955
|
+
{label}
|
|
1956
|
+
</div>
|
|
1957
|
+
</div>
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
function InventoryBody({ data }: { data: EvidenceDataOf<"inventory"> }) {
|
|
1962
|
+
return (
|
|
1963
|
+
<div className="max-h-72 overflow-y-auto rounded-md border border-theme-border">
|
|
1964
|
+
{data.resources.map((resource, index) => (
|
|
1965
|
+
<div
|
|
1966
|
+
key={`${resource.kind}-${resource.namespace ?? ""}-${resource.name}`}
|
|
1967
|
+
className={clsx(
|
|
1968
|
+
"flex min-w-0 items-center gap-2 px-2.5 py-1.5",
|
|
1969
|
+
index > 0 && "border-t border-theme-border/60",
|
|
1970
|
+
)}
|
|
1971
|
+
>
|
|
1972
|
+
<StatusDot
|
|
1973
|
+
tone={mapHealthToTone(resource.summaryContext?.health ?? "")}
|
|
1974
|
+
className="shrink-0"
|
|
1975
|
+
/>
|
|
1976
|
+
<Badge tone="structural" size="sm">
|
|
1977
|
+
{resource.kind}
|
|
1978
|
+
</Badge>
|
|
1979
|
+
<span className="min-w-0 flex-1">
|
|
1980
|
+
<span className="block truncate font-mono text-xs text-theme-text-secondary">
|
|
1981
|
+
{resource.namespace ? `${resource.namespace}/` : ""}
|
|
1982
|
+
{resource.name}
|
|
1983
|
+
</span>
|
|
1984
|
+
{resource.issue ? (
|
|
1985
|
+
<span className="block truncate text-xs text-warning-text">
|
|
1986
|
+
{resource.issue}
|
|
1987
|
+
</span>
|
|
1988
|
+
) : null}
|
|
1989
|
+
</span>
|
|
1990
|
+
{resource.ready || resource.status ? (
|
|
1991
|
+
<span className="ml-auto shrink-0 font-mono text-xs text-theme-text-tertiary">
|
|
1992
|
+
{resource.ready || resource.status}
|
|
1993
|
+
</span>
|
|
1994
|
+
) : null}
|
|
1995
|
+
{(resource.summaryContext?.issueCount ?? 0) > 0 ? (
|
|
1996
|
+
<Badge severity="warning" size="sm">
|
|
1997
|
+
{resource.summaryContext?.issueCount} issues
|
|
1998
|
+
</Badge>
|
|
1999
|
+
) : null}
|
|
2000
|
+
</div>
|
|
2001
|
+
))}
|
|
2002
|
+
</div>
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
function RevisionHistory({
|
|
2007
|
+
observations,
|
|
2008
|
+
citedOrder,
|
|
2009
|
+
reveal,
|
|
2010
|
+
revealRequestId,
|
|
2011
|
+
onViewSource,
|
|
2012
|
+
}: {
|
|
2013
|
+
observations: InvestigationEvidenceObservation[];
|
|
2014
|
+
citedOrder?: number;
|
|
2015
|
+
reveal: boolean;
|
|
2016
|
+
revealRequestId?: number;
|
|
2017
|
+
onViewSource: (
|
|
2018
|
+
sourceId: string,
|
|
2019
|
+
excerpt?: InvestigationSourceExcerpt,
|
|
2020
|
+
) => void;
|
|
2021
|
+
}) {
|
|
2022
|
+
const [open, setOpen] = useState(false);
|
|
2023
|
+
const regionId = useId();
|
|
2024
|
+
const { elementRef, revealAfterToggle } =
|
|
2025
|
+
useDisclosureReveal<HTMLDivElement>();
|
|
2026
|
+
useLayoutEffect(() => {
|
|
2027
|
+
if (reveal) setOpen(true);
|
|
2028
|
+
}, [reveal, revealRequestId]);
|
|
2029
|
+
return (
|
|
2030
|
+
<div>
|
|
2031
|
+
<button
|
|
2032
|
+
type="button"
|
|
2033
|
+
aria-expanded={open}
|
|
2034
|
+
aria-controls={regionId}
|
|
2035
|
+
onClick={() => {
|
|
2036
|
+
setOpen(!open);
|
|
2037
|
+
revealAfterToggle(!open);
|
|
2038
|
+
}}
|
|
2039
|
+
className="flex items-center gap-1.5 rounded-md px-1 py-1 text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-secondary"
|
|
2040
|
+
>
|
|
2041
|
+
<CollapseChevron open={open} className="h-3.5 w-3.5" />
|
|
2042
|
+
Previous observations · {observations.length}
|
|
2043
|
+
</button>
|
|
2044
|
+
<div id={regionId} ref={elementRef}>
|
|
2045
|
+
<Collapse open={open}>
|
|
2046
|
+
<ol className="space-y-3 pt-2">
|
|
2047
|
+
{observations.map((observation) => (
|
|
2048
|
+
<li
|
|
2049
|
+
key={`${observation.source.id}-${observation.revision}`}
|
|
2050
|
+
className="flex min-w-0 items-start gap-2 text-xs"
|
|
2051
|
+
>
|
|
2052
|
+
<span className="mt-0.5 shrink-0 text-theme-text-tertiary">
|
|
2053
|
+
<span className="font-medium text-theme-text-secondary">
|
|
2054
|
+
{observation.source.order === citedOrder
|
|
2055
|
+
? "Used for assessment"
|
|
2056
|
+
: phaseLabel(observation.source.phase)}
|
|
2057
|
+
</span>
|
|
2058
|
+
</span>
|
|
2059
|
+
<div className="min-w-0 flex-1 space-y-1 text-theme-text-secondary">
|
|
2060
|
+
<p>{observation.summary || observation.title}</p>
|
|
2061
|
+
{evidenceHasDetails(
|
|
2062
|
+
observation.data,
|
|
2063
|
+
observation.summary,
|
|
2064
|
+
) && (
|
|
2065
|
+
<EvidenceBody
|
|
2066
|
+
data={observation.data}
|
|
2067
|
+
cardSummary={observation.summary}
|
|
2068
|
+
/>
|
|
2069
|
+
)}
|
|
2070
|
+
</div>
|
|
2071
|
+
<SourceButton
|
|
2072
|
+
ariaLabel={`View source for ${phaseLabel(observation.source.phase).toLowerCase()} observation of ${observation.title}`}
|
|
2073
|
+
onClick={() =>
|
|
2074
|
+
onViewSource(
|
|
2075
|
+
observation.source.id,
|
|
2076
|
+
evidenceSourceExcerpt(observation.data),
|
|
2077
|
+
)
|
|
2078
|
+
}
|
|
2079
|
+
/>
|
|
2080
|
+
</li>
|
|
2081
|
+
))}
|
|
2082
|
+
</ol>
|
|
2083
|
+
</Collapse>
|
|
2084
|
+
</div>
|
|
2085
|
+
</div>
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
function EvidenceCaveat({ data }: { data: InvestigationEvidenceData }) {
|
|
2090
|
+
let text: string | undefined;
|
|
2091
|
+
if (data.type === "events") {
|
|
2092
|
+
text =
|
|
2093
|
+
"Events support the timeline; proximity alone does not establish cause.";
|
|
2094
|
+
} else if (data.type === "changes") {
|
|
2095
|
+
return (
|
|
2096
|
+
<Tooltip
|
|
2097
|
+
content={`A change alone does not establish the cause.${data.changeContext?.when ? " The reported age is as of collection." : ""}`}
|
|
2098
|
+
position="left"
|
|
2099
|
+
className="pointer-events-none"
|
|
2100
|
+
wrapperClassName="flex shrink-0"
|
|
2101
|
+
>
|
|
2102
|
+
<button
|
|
2103
|
+
type="button"
|
|
2104
|
+
aria-label="About change evidence"
|
|
2105
|
+
className="flex h-7 w-7 items-center justify-center rounded-md text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
2106
|
+
>
|
|
2107
|
+
<Info className="h-3.5 w-3.5" aria-hidden />
|
|
2108
|
+
</button>
|
|
2109
|
+
</Tooltip>
|
|
2110
|
+
);
|
|
2111
|
+
} else if (data.type === "relationships" || data.type === "topology") {
|
|
2112
|
+
text =
|
|
2113
|
+
"This shows direct relationships Radar found, not an inferred blast radius.";
|
|
2114
|
+
}
|
|
2115
|
+
if (!text) return null;
|
|
2116
|
+
return (
|
|
2117
|
+
<p className="flex items-start gap-1.5 text-xs leading-relaxed text-theme-text-tertiary">
|
|
2118
|
+
<Info className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
|
|
2119
|
+
{text}
|
|
2120
|
+
</p>
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function EvidenceIcon({
|
|
2125
|
+
observation,
|
|
2126
|
+
prominence,
|
|
2127
|
+
}: {
|
|
2128
|
+
observation: InvestigationEvidenceObservation;
|
|
2129
|
+
prominence: "primary" | "supporting" | "secondary";
|
|
2130
|
+
}) {
|
|
2131
|
+
const Icon = evidenceIcon(observation.data.type);
|
|
2132
|
+
return (
|
|
2133
|
+
<span
|
|
2134
|
+
className={clsx(
|
|
2135
|
+
"flex shrink-0 items-center justify-center text-theme-text-tertiary",
|
|
2136
|
+
prominence === "primary" ? "h-7 w-7" : "h-6 w-6",
|
|
2137
|
+
)}
|
|
2138
|
+
>
|
|
2139
|
+
<Icon
|
|
2140
|
+
className={prominence === "primary" ? "h-4 w-4" : "h-3.5 w-3.5"}
|
|
2141
|
+
aria-hidden
|
|
2142
|
+
/>
|
|
2143
|
+
</span>
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function SourceButton({
|
|
2148
|
+
ariaLabel,
|
|
2149
|
+
buttonLabel = "View source",
|
|
2150
|
+
compact = false,
|
|
2151
|
+
onClick,
|
|
2152
|
+
}: {
|
|
2153
|
+
ariaLabel?: string;
|
|
2154
|
+
buttonLabel?: string;
|
|
2155
|
+
compact?: boolean;
|
|
2156
|
+
onClick: () => void;
|
|
2157
|
+
}) {
|
|
2158
|
+
const tooltip = "Open the original tool result";
|
|
2159
|
+
return (
|
|
2160
|
+
<Tooltip
|
|
2161
|
+
content={tooltip}
|
|
2162
|
+
delay={350}
|
|
2163
|
+
position="left"
|
|
2164
|
+
wrapperClassName="flex shrink-0"
|
|
2165
|
+
>
|
|
2166
|
+
<button
|
|
2167
|
+
type="button"
|
|
2168
|
+
aria-label={ariaLabel ?? tooltip}
|
|
2169
|
+
onClick={onClick}
|
|
2170
|
+
className={clsx(
|
|
2171
|
+
"flex h-7 shrink-0 items-center justify-center rounded-md text-theme-text-tertiary hover:bg-theme-hover hover:text-accent-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50",
|
|
2172
|
+
"gap-1 px-2 text-xs font-medium",
|
|
2173
|
+
)}
|
|
2174
|
+
>
|
|
2175
|
+
<FileSearch className="h-3.5 w-3.5" aria-hidden />
|
|
2176
|
+
<span
|
|
2177
|
+
className={compact ? "hidden @min-[540px]/card:inline" : undefined}
|
|
2178
|
+
>
|
|
2179
|
+
{buttonLabel}
|
|
2180
|
+
</span>
|
|
2181
|
+
</button>
|
|
2182
|
+
</Tooltip>
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
function evidenceIcon(type: InvestigationEvidenceData["type"]) {
|
|
2187
|
+
switch (type) {
|
|
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
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
function severityBadge(value: string) {
|
|
2217
|
+
const tone = value.toLowerCase();
|
|
2218
|
+
if (tone === "error" || tone === "critical" || tone === "failed")
|
|
2219
|
+
return "error" as const;
|
|
2220
|
+
if (tone === "alert" || tone === "high") return "alert" as const;
|
|
2221
|
+
if (tone === "warning" || tone === "medium") return "warning" as const;
|
|
2222
|
+
if (tone === "info" || tone === "low") return "info" as const;
|
|
2223
|
+
return "neutral" as const;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
function phaseLabel(
|
|
2227
|
+
phase: InvestigationEvidenceObservation["source"]["phase"],
|
|
2228
|
+
): string {
|
|
2229
|
+
switch (phase) {
|
|
2230
|
+
case "initial":
|
|
2231
|
+
return "Initial";
|
|
2232
|
+
case "followup":
|
|
2233
|
+
return "Follow-up";
|
|
2234
|
+
case "verification":
|
|
2235
|
+
return "Verification";
|
|
2236
|
+
case "apply":
|
|
2237
|
+
return "Apply";
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
function toneBorder(
|
|
2242
|
+
tone: InvestigationEvidenceObservation["tone"],
|
|
2243
|
+
tier: InvestigationEvidenceTier,
|
|
2244
|
+
prominence: "primary" | "supporting" | "secondary",
|
|
2245
|
+
): string {
|
|
2246
|
+
if (prominence !== "primary") return "border-theme-border/70";
|
|
2247
|
+
if (tier !== "key") return "border-theme-border";
|
|
2248
|
+
if (tone === "error")
|
|
2249
|
+
return "border-l-[3px] border-l-red-500 border-theme-border";
|
|
2250
|
+
if (tone === "alert")
|
|
2251
|
+
return "border-l-[3px] border-l-orange-500 border-theme-border";
|
|
2252
|
+
return "border-l-[3px] border-l-amber-500 border-theme-border";
|
|
2253
|
+
}
|