@skyhook-io/radar-app 1.14.2 → 1.14.4
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/api/client.ts +32 -0
- package/src/api/diagnose.ts +7 -1
- package/src/components/CloudConnectFlow.tsx +21 -7
- package/src/components/CloudFunnelButton.tsx +85 -8
- package/src/components/cloudConnectHandoff.test.ts +10 -1
- package/src/components/cloudConnectHandoff.ts +15 -1
- package/src/components/diagnose/AIMarkdown.tsx +87 -0
- package/src/components/diagnose/ActivityTurn.tsx +1244 -0
- package/src/components/diagnose/AgentControls.tsx +603 -0
- package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
- package/src/components/diagnose/AnalysisStory.tsx +13 -18
- package/src/components/diagnose/ApplyDialog.tsx +289 -0
- package/src/components/diagnose/AssessmentCard.tsx +1579 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
- package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
- package/src/components/diagnose/EvidenceCard.tsx +611 -0
- package/src/components/diagnose/Home.test.tsx +1 -3
- package/src/components/diagnose/Home.tsx +2 -1
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
- package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
- package/src/components/diagnose/InvestigationView.tsx +39 -51
- package/src/components/diagnose/StoryExcerpt.tsx +118 -0
- package/src/components/diagnose/assessmentCopy.ts +169 -0
- package/src/components/diagnose/investigationCase.test.tsx +658 -2
- package/src/components/diagnose/investigationCase.ts +135 -26
- package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +682 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
- package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
- package/src/components/diagnose/investigationEvidence/adapters/listings.ts +136 -0
- package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
- package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
- package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
- package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
- package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
- package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
- package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
- package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
- package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
- package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
- package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
- package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
- package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
- package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
- package/src/components/diagnose/investigationEvidence/index.ts +1 -1
- package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +13 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
- package/src/components/diagnose/investigationEvidence/types.ts +97 -2
- package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
- package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
- package/src/components/diagnose/investigationState.test.ts +2 -2
- package/src/components/diagnose/investigationState.ts +2 -2
- package/src/components/diagnose/investigationStory.ts +1 -1
- package/src/components/diagnose/parts.test.tsx +15 -1
- package/src/components/diagnose/parts.tsx +26 -3904
- package/src/components/diagnose/target.ts +9 -3
- package/src/components/diagnose/toolCallLabel.test.ts +47 -1
- package/src/components/diagnose/toolCallLabel.ts +41 -2
- package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +27 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
- package/src/components/resources/PodFilePreview.tsx +1 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +20 -1
- package/src/components/ui/CommandPalette.tsx +12 -8
- package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
- package/src/components/ui/Omnibar.tsx +96 -60
- package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
- package/src/components/workload/WorkloadView.tsx +7 -1
- package/src/components/ui/Disclosure.tsx +0 -47
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge,
|
|
3
|
+
TerminalBlock,
|
|
4
|
+
formatRelativeAgeTime,
|
|
5
|
+
} from "@skyhook-io/k8s-ui";
|
|
6
|
+
import {
|
|
7
|
+
AreaChart,
|
|
8
|
+
SERIES_COLORS,
|
|
9
|
+
SeriesLegend,
|
|
10
|
+
formatMetricValue,
|
|
11
|
+
seriesDisplayLabels,
|
|
12
|
+
seriesFill,
|
|
13
|
+
type TimeSeries,
|
|
14
|
+
} from "@skyhook-io/k8s-ui/components/charts";
|
|
15
|
+
import {
|
|
16
|
+
metricsDomain,
|
|
17
|
+
type MetricsChangeCoverage,
|
|
18
|
+
} from "../../investigationMetrics";
|
|
19
|
+
import { Tooltip } from "../../../ui/Tooltip";
|
|
20
|
+
import { type EvidenceDataOf, severityBadge } from "../cardParts";
|
|
21
|
+
|
|
22
|
+
const METRICS_AXIS_LABEL_MAX_CHARS = 72;
|
|
23
|
+
|
|
24
|
+
function finiteSamples(series: TimeSeries): TimeSeries["dataPoints"] {
|
|
25
|
+
return series.dataPoints.filter((point) => typeof point.value === "number");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function MetricsValueTable({
|
|
29
|
+
series,
|
|
30
|
+
labels,
|
|
31
|
+
unit,
|
|
32
|
+
withTime,
|
|
33
|
+
}: {
|
|
34
|
+
series: TimeSeries[];
|
|
35
|
+
/** Display name per series, derived from the complete result. */
|
|
36
|
+
labels: string[];
|
|
37
|
+
unit: string;
|
|
38
|
+
withTime: boolean;
|
|
39
|
+
}) {
|
|
40
|
+
return (
|
|
41
|
+
<table className="w-full text-xs">
|
|
42
|
+
<tbody>
|
|
43
|
+
{series.map((item, index) => {
|
|
44
|
+
const sample = finiteSamples(item).at(-1);
|
|
45
|
+
return (
|
|
46
|
+
<tr
|
|
47
|
+
key={`${labels[index]}-${index}`}
|
|
48
|
+
className="border-b border-theme-border/60 last:border-b-0"
|
|
49
|
+
>
|
|
50
|
+
<td className="py-1 pr-3 font-mono text-theme-text-secondary [overflow-wrap:anywhere]">
|
|
51
|
+
{labels[index]}
|
|
52
|
+
</td>
|
|
53
|
+
{withTime ? (
|
|
54
|
+
<td className="py-1 pr-3 text-right text-theme-text-tertiary tabular-nums">
|
|
55
|
+
{sample
|
|
56
|
+
? new Date(sample.timestamp * 1000).toLocaleTimeString()
|
|
57
|
+
: ""}
|
|
58
|
+
</td>
|
|
59
|
+
) : null}
|
|
60
|
+
<td className="py-1 text-right font-mono tabular-nums text-theme-text-primary">
|
|
61
|
+
{sample && typeof sample.value === "number"
|
|
62
|
+
? formatMetricValue(sample.value, unit)
|
|
63
|
+
: "no value"}
|
|
64
|
+
</td>
|
|
65
|
+
</tr>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</tbody>
|
|
69
|
+
</table>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function MetricsBody({
|
|
74
|
+
data,
|
|
75
|
+
changeCoverage,
|
|
76
|
+
}: {
|
|
77
|
+
data: EvidenceDataOf<"metrics">;
|
|
78
|
+
changeCoverage?: MetricsChangeCoverage;
|
|
79
|
+
}) {
|
|
80
|
+
const annotations = changeCoverage?.markers;
|
|
81
|
+
const unit = data.unit ?? "";
|
|
82
|
+
const axisLabel = data.label ?? data.query;
|
|
83
|
+
const axisTruncated = axisLabel.length > METRICS_AXIS_LABEL_MAX_CHARS;
|
|
84
|
+
const axisText = axisTruncated
|
|
85
|
+
? `${axisLabel.slice(0, METRICS_AXIS_LABEL_MAX_CHARS - 1)}…`
|
|
86
|
+
: axisLabel;
|
|
87
|
+
const domain = metricsDomain(data);
|
|
88
|
+
const windowText = domain
|
|
89
|
+
? `${new Date(domain.start * 1000).toLocaleString()} to ${new Date(domain.end * 1000).toLocaleString()}`
|
|
90
|
+
: undefined;
|
|
91
|
+
if (data.series.length === 0) {
|
|
92
|
+
return (
|
|
93
|
+
<div className="space-y-2">
|
|
94
|
+
<p className="text-xs text-theme-text-secondary">
|
|
95
|
+
No series matched this query
|
|
96
|
+
{data.mode === "range" ? " in the window" : ""}.
|
|
97
|
+
</p>
|
|
98
|
+
<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">
|
|
99
|
+
{data.query}
|
|
100
|
+
</pre>
|
|
101
|
+
{data.note ? (
|
|
102
|
+
<p className="text-xs text-theme-text-tertiary">{data.note}</p>
|
|
103
|
+
) : null}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (data.mode === "instant") {
|
|
108
|
+
return (
|
|
109
|
+
<div className="space-y-2">
|
|
110
|
+
<MetricsValueTable
|
|
111
|
+
series={data.series}
|
|
112
|
+
labels={seriesDisplayLabels(data.series)}
|
|
113
|
+
unit={unit}
|
|
114
|
+
withTime={false}
|
|
115
|
+
/>
|
|
116
|
+
<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">
|
|
117
|
+
{data.query}
|
|
118
|
+
</pre>
|
|
119
|
+
{data.note ? (
|
|
120
|
+
<p className="text-xs text-theme-text-tertiary">{data.note}</p>
|
|
121
|
+
) : null}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
// Names come from the whole result so two series that differ only by a
|
|
126
|
+
// label the chart would hide stay distinguishable wherever they are listed.
|
|
127
|
+
const labels = seriesDisplayLabels(data.series);
|
|
128
|
+
const indexed = data.series.map((item, index) => ({
|
|
129
|
+
item,
|
|
130
|
+
label: labels[index],
|
|
131
|
+
finite: finiteSamples(item).length,
|
|
132
|
+
}));
|
|
133
|
+
// A series with one finite sample has no line to draw, and one with none
|
|
134
|
+
// (Prometheus serializes NaN and infinities as gaps) has nothing to show.
|
|
135
|
+
// Listing both keeps what was captured readable and states what was not.
|
|
136
|
+
const charted = indexed.filter((entry) => entry.finite >= 2);
|
|
137
|
+
const single = indexed.filter((entry) => entry.finite === 1);
|
|
138
|
+
const empty = indexed.filter((entry) => entry.finite === 0);
|
|
139
|
+
return (
|
|
140
|
+
<div className="space-y-2">
|
|
141
|
+
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs">
|
|
142
|
+
<Tooltip
|
|
143
|
+
content={axisLabel}
|
|
144
|
+
delay={200}
|
|
145
|
+
position="top"
|
|
146
|
+
disabled={!axisTruncated}
|
|
147
|
+
>
|
|
148
|
+
<span
|
|
149
|
+
className="min-w-0 truncate font-mono text-theme-text-secondary"
|
|
150
|
+
data-testid="investigation-metrics-axis-label"
|
|
151
|
+
>
|
|
152
|
+
{axisText}
|
|
153
|
+
</span>
|
|
154
|
+
</Tooltip>
|
|
155
|
+
{unit ? (
|
|
156
|
+
<span className="text-theme-text-tertiary">({unit})</span>
|
|
157
|
+
) : null}
|
|
158
|
+
{windowText ? (
|
|
159
|
+
<span className="ml-auto text-theme-text-tertiary">{windowText}</span>
|
|
160
|
+
) : null}
|
|
161
|
+
</div>
|
|
162
|
+
{charted.length > 0 ? (
|
|
163
|
+
<div className="space-y-1.5 rounded-md border border-theme-border/70 bg-theme-base/30 p-2">
|
|
164
|
+
<AreaChart
|
|
165
|
+
series={charted.map((entry) => entry.item)}
|
|
166
|
+
seriesLabels={charted.map((entry) => entry.label)}
|
|
167
|
+
color={SERIES_COLORS[0]}
|
|
168
|
+
fillColor={seriesFill(0, SERIES_COLORS[0])}
|
|
169
|
+
unit={unit}
|
|
170
|
+
annotations={annotations}
|
|
171
|
+
domain={domain}
|
|
172
|
+
layout="auto"
|
|
173
|
+
/>
|
|
174
|
+
{charted.length > 1 ? (
|
|
175
|
+
<SeriesLegend
|
|
176
|
+
series={charted.map((entry) => entry.item)}
|
|
177
|
+
seriesLabels={charted.map((entry) => entry.label)}
|
|
178
|
+
color={SERIES_COLORS[0]}
|
|
179
|
+
/>
|
|
180
|
+
) : null}
|
|
181
|
+
</div>
|
|
182
|
+
) : null}
|
|
183
|
+
{single.length > 0 ? (
|
|
184
|
+
<div
|
|
185
|
+
className="space-y-1"
|
|
186
|
+
data-testid="investigation-metrics-sparse-series"
|
|
187
|
+
>
|
|
188
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
189
|
+
{single.length === 1
|
|
190
|
+
? "1 series has a single sample in this window, listed with its time:"
|
|
191
|
+
: `${single.length} series have a single sample in this window, listed with their times:`}
|
|
192
|
+
</p>
|
|
193
|
+
<MetricsValueTable
|
|
194
|
+
series={single.map((entry) => entry.item)}
|
|
195
|
+
labels={single.map((entry) => entry.label)}
|
|
196
|
+
unit={unit}
|
|
197
|
+
withTime
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
) : null}
|
|
201
|
+
{empty.length > 0 ? (
|
|
202
|
+
<p
|
|
203
|
+
className="text-xs text-theme-text-tertiary [overflow-wrap:anywhere]"
|
|
204
|
+
data-testid="investigation-metrics-empty-series"
|
|
205
|
+
>
|
|
206
|
+
{empty.length === 1
|
|
207
|
+
? "1 series had no usable samples in this window: "
|
|
208
|
+
: `${empty.length} series had no usable samples in this window: `}
|
|
209
|
+
<span className="font-mono">
|
|
210
|
+
{empty.map((entry) => entry.label).join("; ")}
|
|
211
|
+
</span>
|
|
212
|
+
</p>
|
|
213
|
+
) : null}
|
|
214
|
+
{changeCoverage?.checked && charted.length > 0 ? (
|
|
215
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
216
|
+
{annotations?.length === 1
|
|
217
|
+
? "1 change recorded in this window is marked on the chart."
|
|
218
|
+
: annotations?.length
|
|
219
|
+
? `${annotations.length} changes recorded in this window are marked on the chart.`
|
|
220
|
+
: // Only that the changes Radar read miss this window — not that
|
|
221
|
+
// the window was fully covered. The change lookup has its own
|
|
222
|
+
// window, which need not span the chart's.
|
|
223
|
+
"None of the changes Radar read fall in this window."}
|
|
224
|
+
</p>
|
|
225
|
+
) : null}
|
|
226
|
+
{axisTruncated ? (
|
|
227
|
+
<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">
|
|
228
|
+
{data.query}
|
|
229
|
+
</pre>
|
|
230
|
+
) : null}
|
|
231
|
+
{data.note ? (
|
|
232
|
+
<p className="text-xs text-theme-text-tertiary">{data.note}</p>
|
|
233
|
+
) : null}
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const VISIBLE_ALERT_LABELS = 6;
|
|
239
|
+
|
|
240
|
+
function alertStateSeverity(state: string | undefined) {
|
|
241
|
+
switch ((state ?? "").toLowerCase()) {
|
|
242
|
+
case "firing":
|
|
243
|
+
return "error" as const;
|
|
244
|
+
case "pending":
|
|
245
|
+
return "warning" as const;
|
|
246
|
+
case "inactive":
|
|
247
|
+
return "success" as const;
|
|
248
|
+
default:
|
|
249
|
+
return "neutral" as const;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function AlertsBody({ data }: { data: EvidenceDataOf<"alerts"> }) {
|
|
254
|
+
const { rule, instances, annotations } = data;
|
|
255
|
+
const health = rule.health?.toLowerCase();
|
|
256
|
+
// Target instances lead; the rest stay visible in producer order.
|
|
257
|
+
const ordered = [...instances].sort(
|
|
258
|
+
(left, right) => Number(right.namesTarget) - Number(left.namesTarget),
|
|
259
|
+
);
|
|
260
|
+
const annotationEntries = Object.entries(annotations);
|
|
261
|
+
return (
|
|
262
|
+
<div className="space-y-2.5">
|
|
263
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
264
|
+
{rule.state ? (
|
|
265
|
+
<Badge severity={alertStateSeverity(rule.state)} size="sm">
|
|
266
|
+
{rule.state}
|
|
267
|
+
</Badge>
|
|
268
|
+
) : null}
|
|
269
|
+
{rule.labels.severity ? (
|
|
270
|
+
<Badge severity={severityBadge(rule.labels.severity)} size="sm">
|
|
271
|
+
severity {rule.labels.severity}
|
|
272
|
+
</Badge>
|
|
273
|
+
) : null}
|
|
274
|
+
<Badge tone="structural" size="sm">
|
|
275
|
+
{rule.group}
|
|
276
|
+
</Badge>
|
|
277
|
+
{health && health !== "ok" ? (
|
|
278
|
+
<Badge severity={health === "err" ? "error" : "neutral"} size="sm">
|
|
279
|
+
health {health}
|
|
280
|
+
</Badge>
|
|
281
|
+
) : null}
|
|
282
|
+
</div>
|
|
283
|
+
{ordered.length > 0 ? (
|
|
284
|
+
<ol className="max-h-64 space-y-1.5 overflow-y-auto pr-1">
|
|
285
|
+
{ordered.map((instance, index) => {
|
|
286
|
+
const labels = Object.entries(instance.labels).filter(
|
|
287
|
+
([key]) => key !== "alertname" && key !== "severity",
|
|
288
|
+
);
|
|
289
|
+
const hidden = labels.length - VISIBLE_ALERT_LABELS;
|
|
290
|
+
return (
|
|
291
|
+
<li
|
|
292
|
+
key={`${instance.state}-${index}-${labels.map(([k, v]) => `${k}=${v}`).join(",")}`}
|
|
293
|
+
className="rounded-md border border-theme-border/70 bg-theme-base/30 px-2.5 py-2"
|
|
294
|
+
>
|
|
295
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
296
|
+
<Badge
|
|
297
|
+
severity={alertStateSeverity(instance.state)}
|
|
298
|
+
size="sm"
|
|
299
|
+
>
|
|
300
|
+
{instance.state}
|
|
301
|
+
</Badge>
|
|
302
|
+
{instance.namesTarget ? (
|
|
303
|
+
<Badge severity="info" size="sm">
|
|
304
|
+
names this resource
|
|
305
|
+
</Badge>
|
|
306
|
+
) : null}
|
|
307
|
+
{instance.value ? (
|
|
308
|
+
<span className="font-mono text-xs text-theme-text-secondary">
|
|
309
|
+
value {instance.value}
|
|
310
|
+
</span>
|
|
311
|
+
) : null}
|
|
312
|
+
{instance.activeAt ? (
|
|
313
|
+
<Tooltip
|
|
314
|
+
content={new Date(instance.activeAt).toLocaleString()}
|
|
315
|
+
delay={150}
|
|
316
|
+
position="left"
|
|
317
|
+
wrapperClassName="ml-auto"
|
|
318
|
+
>
|
|
319
|
+
<time
|
|
320
|
+
dateTime={instance.activeAt}
|
|
321
|
+
className="text-xs text-theme-text-tertiary"
|
|
322
|
+
>
|
|
323
|
+
active {formatRelativeAgeTime(instance.activeAt)}
|
|
324
|
+
</time>
|
|
325
|
+
</Tooltip>
|
|
326
|
+
) : null}
|
|
327
|
+
</div>
|
|
328
|
+
{labels.length > 0 ? (
|
|
329
|
+
<div className="mt-1.5 flex flex-wrap gap-1 font-mono text-[11px] text-theme-text-secondary">
|
|
330
|
+
{labels
|
|
331
|
+
.slice(0, VISIBLE_ALERT_LABELS)
|
|
332
|
+
.map(([key, value]) => (
|
|
333
|
+
<span
|
|
334
|
+
key={key}
|
|
335
|
+
className="rounded bg-theme-base px-1.5 py-0.5 [overflow-wrap:anywhere]"
|
|
336
|
+
>
|
|
337
|
+
{key}={value}
|
|
338
|
+
</span>
|
|
339
|
+
))}
|
|
340
|
+
{hidden > 0 ? (
|
|
341
|
+
<span className="px-1 py-0.5 text-theme-text-tertiary">
|
|
342
|
+
+{hidden} more
|
|
343
|
+
</span>
|
|
344
|
+
) : null}
|
|
345
|
+
</div>
|
|
346
|
+
) : null}
|
|
347
|
+
</li>
|
|
348
|
+
);
|
|
349
|
+
})}
|
|
350
|
+
</ol>
|
|
351
|
+
) : (
|
|
352
|
+
<p className="text-xs italic text-theme-text-tertiary">
|
|
353
|
+
No active instances were reported for this rule.
|
|
354
|
+
</p>
|
|
355
|
+
)}
|
|
356
|
+
{annotationEntries.length > 0 ? (
|
|
357
|
+
<dl className="space-y-1 text-xs">
|
|
358
|
+
{annotationEntries.map(([key, value]) => (
|
|
359
|
+
<div key={key} className="flex min-w-0 gap-2">
|
|
360
|
+
<dt className="shrink-0 text-theme-text-tertiary">{key}</dt>
|
|
361
|
+
<dd className="min-w-0 leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
|
|
362
|
+
{value}
|
|
363
|
+
</dd>
|
|
364
|
+
</div>
|
|
365
|
+
))}
|
|
366
|
+
</dl>
|
|
367
|
+
) : null}
|
|
368
|
+
{rule.query ? (
|
|
369
|
+
<TerminalBlock label="Rule expression">{rule.query}</TerminalBlock>
|
|
370
|
+
) : null}
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import { AlertTriangle } from "lucide-react";
|
|
3
|
+
import { Badge, StatusDot, ResourceLink } from "@skyhook-io/k8s-ui";
|
|
4
|
+
import { EvidenceNavigationContext } from "../navigation";
|
|
5
|
+
import { type EvidenceDataOf, severityBadge } from "../cardParts";
|
|
6
|
+
|
|
7
|
+
export function DNSBody({ data }: { data: EvidenceDataOf<"dns"> }) {
|
|
8
|
+
const { onOpenResource } = useContext(EvidenceNavigationContext);
|
|
9
|
+
return (
|
|
10
|
+
<div className="space-y-2">
|
|
11
|
+
{(data.dns.signals ?? []).map((signal) => (
|
|
12
|
+
<p
|
|
13
|
+
key={signal}
|
|
14
|
+
className="text-xs leading-relaxed text-theme-text-secondary"
|
|
15
|
+
>
|
|
16
|
+
{signal}
|
|
17
|
+
</p>
|
|
18
|
+
))}
|
|
19
|
+
{(data.dns.coreDNSFindings ?? []).map((finding) => (
|
|
20
|
+
<div
|
|
21
|
+
key={`${finding.kind}-${finding.namespace}-${finding.name}-${finding.reason}`}
|
|
22
|
+
className="rounded-md border border-theme-border bg-theme-base/40 p-2"
|
|
23
|
+
>
|
|
24
|
+
<div className="flex flex-wrap gap-1.5">
|
|
25
|
+
<Badge tone="structural" size="sm">
|
|
26
|
+
{finding.kind}
|
|
27
|
+
</Badge>
|
|
28
|
+
<span className="font-mono text-xs">
|
|
29
|
+
<ResourceLink
|
|
30
|
+
name={finding.name}
|
|
31
|
+
kind={finding.kind}
|
|
32
|
+
namespace={finding.namespace}
|
|
33
|
+
group=""
|
|
34
|
+
label={`${finding.namespace}/${finding.name}`}
|
|
35
|
+
onNavigate={
|
|
36
|
+
finding.kind.toLowerCase() === "configmap" && onOpenResource
|
|
37
|
+
? (ref) => onOpenResource(ref)
|
|
38
|
+
: undefined
|
|
39
|
+
}
|
|
40
|
+
/>
|
|
41
|
+
</span>
|
|
42
|
+
<Badge severity={severityBadge(finding.severity)} size="sm">
|
|
43
|
+
{finding.severity}
|
|
44
|
+
</Badge>
|
|
45
|
+
</div>
|
|
46
|
+
<p className="mt-1 text-xs text-theme-text-secondary">
|
|
47
|
+
{finding.reason}
|
|
48
|
+
{finding.message ? ` — ${finding.message}` : ""}
|
|
49
|
+
</p>
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function NetworkBody({ data }: { data: EvidenceDataOf<"network"> }) {
|
|
57
|
+
const { network } = data;
|
|
58
|
+
const stats = [
|
|
59
|
+
["Tested", network.summary.tested],
|
|
60
|
+
["Passed", network.summary.passed],
|
|
61
|
+
["Failed", network.summary.failed],
|
|
62
|
+
["Inferred", network.summary.derived ?? 0],
|
|
63
|
+
["Skipped", network.summary.skipped],
|
|
64
|
+
] as const;
|
|
65
|
+
return (
|
|
66
|
+
<div className="space-y-3">
|
|
67
|
+
<div className="grid grid-cols-3 gap-1.5 @min-[620px]/evidence:grid-cols-5">
|
|
68
|
+
{stats.map(([label, value]) => (
|
|
69
|
+
<div
|
|
70
|
+
key={label}
|
|
71
|
+
className="rounded-md border border-theme-border bg-theme-base/40 px-2 py-1.5 text-center"
|
|
72
|
+
>
|
|
73
|
+
<div className="font-mono text-sm font-semibold text-theme-text-primary">
|
|
74
|
+
{value}
|
|
75
|
+
</div>
|
|
76
|
+
<div className="text-xs uppercase tracking-wide text-theme-text-tertiary">
|
|
77
|
+
{label}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
{network.diagnosis ? (
|
|
83
|
+
<div className="rounded-md border border-theme-border bg-theme-base/40 px-2.5 py-2">
|
|
84
|
+
<p className="text-xs font-medium leading-relaxed text-theme-text-primary">
|
|
85
|
+
{network.diagnosis.summary}
|
|
86
|
+
</p>
|
|
87
|
+
{network.diagnosis.nextAction ? (
|
|
88
|
+
<p className="mt-1 border-l-2 border-accent/50 pl-2 text-xs leading-relaxed text-theme-text-secondary">
|
|
89
|
+
Next check: {network.diagnosis.nextAction}
|
|
90
|
+
</p>
|
|
91
|
+
) : null}
|
|
92
|
+
</div>
|
|
93
|
+
) : (
|
|
94
|
+
<p className="text-xs leading-relaxed text-theme-text-secondary">
|
|
95
|
+
{network.summary.headline}
|
|
96
|
+
</p>
|
|
97
|
+
)}
|
|
98
|
+
{network.routes.length > 0 ? (
|
|
99
|
+
<ol className="max-h-64 space-y-1.5 overflow-y-auto pr-1">
|
|
100
|
+
{network.routes.map((route, index) => (
|
|
101
|
+
<li
|
|
102
|
+
key={`${route.route}-${route.target ?? ""}-${index}`}
|
|
103
|
+
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"
|
|
104
|
+
>
|
|
105
|
+
<StatusDot
|
|
106
|
+
tone={networkOutcomeTone(route.outcome, route.benign)}
|
|
107
|
+
className="mt-1 shrink-0"
|
|
108
|
+
/>
|
|
109
|
+
<span className="min-w-0 flex-1">
|
|
110
|
+
<span className="block truncate font-mono text-xs text-theme-text-primary">
|
|
111
|
+
{route.route}
|
|
112
|
+
{route.target ? ` → ${route.target}` : ""}
|
|
113
|
+
</span>
|
|
114
|
+
{route.evidence ? (
|
|
115
|
+
<span className="mt-0.5 block text-xs leading-relaxed text-theme-text-secondary">
|
|
116
|
+
{route.evidence}
|
|
117
|
+
</span>
|
|
118
|
+
) : null}
|
|
119
|
+
</span>
|
|
120
|
+
<Badge
|
|
121
|
+
severity={networkOutcomeSeverity(route.outcome, route.benign)}
|
|
122
|
+
size="sm"
|
|
123
|
+
>
|
|
124
|
+
{route.benign
|
|
125
|
+
? "intentional"
|
|
126
|
+
: route.outcome.replaceAll("_", " ")}
|
|
127
|
+
</Badge>
|
|
128
|
+
</li>
|
|
129
|
+
))}
|
|
130
|
+
</ol>
|
|
131
|
+
) : null}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function networkOutcomeTone(
|
|
137
|
+
outcome: string,
|
|
138
|
+
benign?: boolean,
|
|
139
|
+
): "healthy" | "degraded" | "unhealthy" | "unknown" {
|
|
140
|
+
if (benign) return "degraded";
|
|
141
|
+
const normalized = outcome.toLowerCase();
|
|
142
|
+
if (normalized.includes("verified") || normalized.includes("reached"))
|
|
143
|
+
return "healthy";
|
|
144
|
+
if (normalized.includes("fail") || normalized.includes("unreachable"))
|
|
145
|
+
return "unhealthy";
|
|
146
|
+
if (normalized.includes("skip") || normalized.includes("not"))
|
|
147
|
+
return "unknown";
|
|
148
|
+
return "degraded";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function networkOutcomeSeverity(outcome: string, benign?: boolean) {
|
|
152
|
+
switch (networkOutcomeTone(outcome, benign)) {
|
|
153
|
+
case "healthy":
|
|
154
|
+
return "success" as const;
|
|
155
|
+
case "degraded":
|
|
156
|
+
return "warning" as const;
|
|
157
|
+
case "unhealthy":
|
|
158
|
+
return "error" as const;
|
|
159
|
+
case "unknown":
|
|
160
|
+
return "neutral" as const;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function RelationshipsBody({
|
|
165
|
+
data,
|
|
166
|
+
}: {
|
|
167
|
+
data: EvidenceDataOf<"relationships">;
|
|
168
|
+
}) {
|
|
169
|
+
return (
|
|
170
|
+
<div className="space-y-2.5">
|
|
171
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
172
|
+
<Badge tone="structural" size="sm">
|
|
173
|
+
{data.root.kind}
|
|
174
|
+
</Badge>
|
|
175
|
+
<span className="font-mono text-xs text-theme-text-secondary">
|
|
176
|
+
{data.root.namespace ? `${data.root.namespace}/` : ""}
|
|
177
|
+
{data.root.name}
|
|
178
|
+
</span>
|
|
179
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
180
|
+
{data.nodes.length} resources · {data.edges.length} direct
|
|
181
|
+
relationships
|
|
182
|
+
</span>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="max-h-44 overflow-y-auto pr-1">
|
|
185
|
+
<div className="flex flex-wrap gap-1.5">
|
|
186
|
+
{data.nodes.map((node) => (
|
|
187
|
+
<span
|
|
188
|
+
key={node.id}
|
|
189
|
+
className="inline-flex items-center gap-1 rounded-md border border-theme-border bg-theme-base px-2 py-1 text-xs"
|
|
190
|
+
>
|
|
191
|
+
<Badge tone="structural" size="sm">
|
|
192
|
+
{node.kind}
|
|
193
|
+
</Badge>
|
|
194
|
+
<span className="font-mono text-theme-text-secondary">
|
|
195
|
+
{node.name}
|
|
196
|
+
</span>
|
|
197
|
+
</span>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
{data.edges.length > 0 ? (
|
|
202
|
+
<div className="grid max-h-52 gap-1 overflow-y-auto pr-1 @min-[680px]/evidence:grid-cols-2">
|
|
203
|
+
{data.edges.map((edge, index) => (
|
|
204
|
+
<div
|
|
205
|
+
key={
|
|
206
|
+
edge.id ?? `${edge.source}-${edge.target}-${edge.type}-${index}`
|
|
207
|
+
}
|
|
208
|
+
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"
|
|
209
|
+
>
|
|
210
|
+
<span className="truncate">{edge.source}</span>
|
|
211
|
+
<span className="shrink-0 text-theme-text-tertiary">→</span>
|
|
212
|
+
<span className="truncate">{edge.target}</span>
|
|
213
|
+
<Badge tone="structural" size="sm">
|
|
214
|
+
{edge.label || edge.type}
|
|
215
|
+
</Badge>
|
|
216
|
+
</div>
|
|
217
|
+
))}
|
|
218
|
+
</div>
|
|
219
|
+
) : null}
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function TopologyBody({ data }: { data: EvidenceDataOf<"topology"> }) {
|
|
225
|
+
return (
|
|
226
|
+
<div className="space-y-2.5">
|
|
227
|
+
<div className="grid grid-cols-2 gap-2">
|
|
228
|
+
<TopologyStat label="Nodes" value={data.stats.nodes} />
|
|
229
|
+
<TopologyStat label="Relationships" value={data.stats.edges} />
|
|
230
|
+
</div>
|
|
231
|
+
{data.problems.map((problem) => (
|
|
232
|
+
<p
|
|
233
|
+
key={problem}
|
|
234
|
+
className="flex items-start gap-1.5 text-xs text-warning-text"
|
|
235
|
+
>
|
|
236
|
+
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
237
|
+
{problem}
|
|
238
|
+
</p>
|
|
239
|
+
))}
|
|
240
|
+
<div className="max-h-64 space-y-2 overflow-y-auto pr-1">
|
|
241
|
+
{data.namespaces.map((namespace) => (
|
|
242
|
+
<div key={namespace.namespace}>
|
|
243
|
+
<div className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
244
|
+
{namespace.namespace || "cluster-scoped"}
|
|
245
|
+
</div>
|
|
246
|
+
<ul className="mt-1 space-y-1 font-mono text-xs text-theme-text-secondary">
|
|
247
|
+
{namespace.chains.map((chain) => (
|
|
248
|
+
<li key={chain}>{chain}</li>
|
|
249
|
+
))}
|
|
250
|
+
</ul>
|
|
251
|
+
</div>
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
254
|
+
{data.warnings.map((warning) => (
|
|
255
|
+
<p key={warning} className="text-xs text-warning-text">
|
|
256
|
+
{warning}
|
|
257
|
+
</p>
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function TopologyStat({ label, value }: { label: string; value: number }) {
|
|
264
|
+
return (
|
|
265
|
+
<div className="rounded-md border border-theme-border bg-theme-base/40 px-3 py-2">
|
|
266
|
+
<div className="font-mono text-lg font-semibold text-theme-text-primary">
|
|
267
|
+
{value}
|
|
268
|
+
</div>
|
|
269
|
+
<div className="text-xs uppercase tracking-wide text-theme-text-tertiary">
|
|
270
|
+
{label}
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
}
|