@skyhook-io/radar-app 1.13.4 → 1.13.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/RadarApp.tsx +5 -0
- package/src/api/client.ts +4 -0
- package/src/api/diagnose.ts +61 -15
- package/src/components/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- package/src/components/diagnose/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
- package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
- package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
- package/src/components/diagnose/InvestigationView.tsx +222 -5
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
- package/src/components/diagnose/investigationCase.test.tsx +1568 -0
- package/src/components/diagnose/investigationCase.ts +439 -0
- package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
- package/src/components/diagnose/investigationEvidence.ts +831 -43
- package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
- package/src/components/diagnose/investigationMetrics.test.ts +712 -0
- package/src/components/diagnose/investigationMetrics.ts +393 -0
- package/src/components/diagnose/investigationSourceFocus.ts +2 -0
- package/src/components/diagnose/investigationState.test.ts +322 -0
- package/src/components/diagnose/investigationState.ts +147 -25
- package/src/components/diagnose/parts.test.tsx +482 -0
- package/src/components/diagnose/parts.tsx +418 -41
- package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
- package/src/components/resources/PodFilePreview.test.tsx +131 -0
- package/src/components/resources/PodFilePreview.tsx +394 -0
- package/src/components/resources/PodFilesystemModal.tsx +157 -67
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +14 -2
- package/src/index.ts +1 -0
- package/src/utils/shell-safe.test.ts +25 -1
- package/src/utils/shell-safe.ts +16 -0
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { useMemo, useState } from
|
|
2
|
-
import { Loader2, Wifi, WifiOff } from
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { Loader2, Wifi, WifiOff } from "lucide-react";
|
|
3
3
|
import {
|
|
4
4
|
AreaChart,
|
|
5
5
|
SeriesLegend,
|
|
6
6
|
computeSaturation,
|
|
7
|
+
describePodCoverage,
|
|
7
8
|
type ReferenceLine,
|
|
8
|
-
} from
|
|
9
|
-
import {
|
|
9
|
+
} from "@skyhook-io/k8s-ui/components/charts";
|
|
10
|
+
import {
|
|
11
|
+
SEVERITY_BADGE,
|
|
12
|
+
SEVERITY_TEXT,
|
|
13
|
+
type Severity,
|
|
14
|
+
} from "@skyhook-io/k8s-ui/utils/badge-colors";
|
|
10
15
|
import {
|
|
11
16
|
usePrometheusStatus,
|
|
12
17
|
usePrometheusConnect,
|
|
@@ -14,7 +19,7 @@ import {
|
|
|
14
19
|
useAutoPromConnect,
|
|
15
20
|
type PrometheusMetricCategory,
|
|
16
21
|
type PrometheusTimeRange,
|
|
17
|
-
} from
|
|
22
|
+
} from "../../api/client";
|
|
18
23
|
import {
|
|
19
24
|
MetricsSummary,
|
|
20
25
|
TIME_RANGES,
|
|
@@ -22,24 +27,31 @@ import {
|
|
|
22
27
|
NODE_CATEGORIES,
|
|
23
28
|
computeRequestLimitLines,
|
|
24
29
|
type CategoryDef,
|
|
25
|
-
} from
|
|
26
|
-
import { RestartEventLane } from
|
|
27
|
-
import { Tooltip } from
|
|
30
|
+
} from "./PrometheusCharts";
|
|
31
|
+
import { RestartEventLane } from "./RestartChart";
|
|
32
|
+
import { Tooltip } from "../ui/Tooltip";
|
|
28
33
|
|
|
29
34
|
// Used when MetricsTabContent is in expanded (full-screen) mode. Drawer mode
|
|
30
35
|
// uses the single-chart tabbed `PrometheusCharts` instead — drawer width
|
|
31
36
|
// can't fit the grid cleanly.
|
|
32
37
|
export interface PrometheusChartsGridProps {
|
|
33
|
-
kind: string
|
|
34
|
-
namespace: string
|
|
35
|
-
name: string
|
|
38
|
+
kind: string;
|
|
39
|
+
namespace: string;
|
|
40
|
+
name: string;
|
|
36
41
|
/** Optional full K8s resource for request/limit overlay derivation. */
|
|
37
|
-
resource?: any
|
|
42
|
+
resource?: any;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
const SUPPORTED_KINDS = new Set([
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
"Pod",
|
|
47
|
+
"Deployment",
|
|
48
|
+
"StatefulSet",
|
|
49
|
+
"DaemonSet",
|
|
50
|
+
"ReplicaSet",
|
|
51
|
+
"Job",
|
|
52
|
+
"CronJob",
|
|
53
|
+
"Node",
|
|
54
|
+
]);
|
|
43
55
|
|
|
44
56
|
export function PrometheusChartsGrid({
|
|
45
57
|
kind,
|
|
@@ -47,29 +59,31 @@ export function PrometheusChartsGrid({
|
|
|
47
59
|
name,
|
|
48
60
|
resource,
|
|
49
61
|
}: PrometheusChartsGridProps) {
|
|
50
|
-
useAutoPromConnect()
|
|
51
|
-
const { data: status, isLoading: statusLoading } = usePrometheusStatus()
|
|
52
|
-
const connectMutation = usePrometheusConnect()
|
|
53
|
-
const isConnected = status?.connected === true
|
|
54
|
-
const isSupported = SUPPORTED_KINDS.has(kind)
|
|
55
|
-
const showRestartLane = isSupported && kind !==
|
|
62
|
+
useAutoPromConnect();
|
|
63
|
+
const { data: status, isLoading: statusLoading } = usePrometheusStatus();
|
|
64
|
+
const connectMutation = usePrometheusConnect();
|
|
65
|
+
const isConnected = status?.connected === true;
|
|
66
|
+
const isSupported = SUPPORTED_KINDS.has(kind);
|
|
67
|
+
const showRestartLane = isSupported && kind !== "Node";
|
|
56
68
|
|
|
57
|
-
const [timeRange, setTimeRange] = useState<PrometheusTimeRange>(
|
|
69
|
+
const [timeRange, setTimeRange] = useState<PrometheusTimeRange>("1h");
|
|
58
70
|
|
|
59
|
-
const categories = kind ===
|
|
71
|
+
const categories = kind === "Node" ? NODE_CATEGORIES : WORKLOAD_CATEGORIES;
|
|
60
72
|
|
|
61
73
|
// CPU + memory get reference-line overlays when a resource is provided.
|
|
62
74
|
// Computed once at the parent so each panel can stay otherwise generic.
|
|
63
75
|
const cpuRefLines = useMemo<ReferenceLine[] | undefined>(
|
|
64
|
-
() =>
|
|
76
|
+
() =>
|
|
77
|
+
resource ? computeRequestLimitLines(resource, kind, "cpu") : undefined,
|
|
65
78
|
[resource, kind],
|
|
66
|
-
)
|
|
79
|
+
);
|
|
67
80
|
const memRefLines = useMemo<ReferenceLine[] | undefined>(
|
|
68
|
-
() =>
|
|
81
|
+
() =>
|
|
82
|
+
resource ? computeRequestLimitLines(resource, kind, "memory") : undefined,
|
|
69
83
|
[resource, kind],
|
|
70
|
-
)
|
|
84
|
+
);
|
|
71
85
|
|
|
72
|
-
if (!isSupported) return null
|
|
86
|
+
if (!isSupported) return null;
|
|
73
87
|
|
|
74
88
|
if (statusLoading) {
|
|
75
89
|
return (
|
|
@@ -77,7 +91,7 @@ export function PrometheusChartsGrid({
|
|
|
77
91
|
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
78
92
|
Checking Prometheus availability...
|
|
79
93
|
</div>
|
|
80
|
-
)
|
|
94
|
+
);
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
if (!isConnected) {
|
|
@@ -85,9 +99,12 @@ export function PrometheusChartsGrid({
|
|
|
85
99
|
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
|
86
100
|
<WifiOff className="w-10 h-10 text-theme-text-quaternary" />
|
|
87
101
|
<div className="text-center">
|
|
88
|
-
<p className="text-sm text-theme-text-secondary mb-1">
|
|
102
|
+
<p className="text-sm text-theme-text-secondary mb-1">
|
|
103
|
+
Prometheus not connected
|
|
104
|
+
</p>
|
|
89
105
|
<p className="text-xs text-theme-text-tertiary mb-4">
|
|
90
|
-
{status?.error ||
|
|
106
|
+
{status?.error ||
|
|
107
|
+
"Connect to view historical CPU, memory, and network metrics"}
|
|
91
108
|
</p>
|
|
92
109
|
<button
|
|
93
110
|
onClick={() => connectMutation.mutate()}
|
|
@@ -103,25 +120,26 @@ export function PrometheusChartsGrid({
|
|
|
103
120
|
</button>
|
|
104
121
|
</div>
|
|
105
122
|
</div>
|
|
106
|
-
)
|
|
123
|
+
);
|
|
107
124
|
}
|
|
108
125
|
|
|
109
|
-
const findCategory = (
|
|
110
|
-
|
|
126
|
+
const findCategory = (
|
|
127
|
+
key: PrometheusMetricCategory,
|
|
128
|
+
): CategoryDef | undefined => categories.find((c) => c.key === key);
|
|
111
129
|
|
|
112
|
-
const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = []
|
|
113
|
-
const cpu = findCategory(
|
|
114
|
-
if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines })
|
|
115
|
-
const mem = findCategory(
|
|
116
|
-
if (mem) primaryCats.push({ def: mem, refLines: memRefLines })
|
|
117
|
-
if (kind !==
|
|
118
|
-
const rx = findCategory(
|
|
119
|
-
if (rx) primaryCats.push({ def: rx })
|
|
120
|
-
const tx = findCategory(
|
|
121
|
-
if (tx) primaryCats.push({ def: tx })
|
|
130
|
+
const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = [];
|
|
131
|
+
const cpu = findCategory("cpu");
|
|
132
|
+
if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines });
|
|
133
|
+
const mem = findCategory("memory");
|
|
134
|
+
if (mem) primaryCats.push({ def: mem, refLines: memRefLines });
|
|
135
|
+
if (kind !== "Node") {
|
|
136
|
+
const rx = findCategory("network_rx");
|
|
137
|
+
if (rx) primaryCats.push({ def: rx });
|
|
138
|
+
const tx = findCategory("network_tx");
|
|
139
|
+
if (tx) primaryCats.push({ def: tx });
|
|
122
140
|
}
|
|
123
|
-
const disk = findCategory(
|
|
124
|
-
const chartPanels = disk ? [...primaryCats, { def: disk }] : primaryCats
|
|
141
|
+
const disk = findCategory("filesystem");
|
|
142
|
+
const chartPanels = disk ? [...primaryCats, { def: disk }] : primaryCats;
|
|
125
143
|
|
|
126
144
|
return (
|
|
127
145
|
<div className="flex flex-col h-full overflow-auto">
|
|
@@ -129,11 +147,13 @@ export function PrometheusChartsGrid({
|
|
|
129
147
|
<select
|
|
130
148
|
aria-label="Metrics time range"
|
|
131
149
|
value={timeRange}
|
|
132
|
-
onChange={e => setTimeRange(e.target.value as PrometheusTimeRange)}
|
|
150
|
+
onChange={(e) => setTimeRange(e.target.value as PrometheusTimeRange)}
|
|
133
151
|
className="rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-xs text-theme-text-secondary shadow-theme-sm focus:outline-none focus:ring-1 focus:ring-accent/50"
|
|
134
152
|
>
|
|
135
|
-
{TIME_RANGES.map(tr => (
|
|
136
|
-
<option key={tr.value} value={tr.value}>
|
|
153
|
+
{TIME_RANGES.map((tr) => (
|
|
154
|
+
<option key={tr.value} value={tr.value}>
|
|
155
|
+
{tr.label}
|
|
156
|
+
</option>
|
|
137
157
|
))}
|
|
138
158
|
</select>
|
|
139
159
|
</div>
|
|
@@ -142,11 +162,16 @@ export function PrometheusChartsGrid({
|
|
|
142
162
|
the time axis of the charts below. */}
|
|
143
163
|
{showRestartLane && (
|
|
144
164
|
<div className="px-4 pt-3">
|
|
145
|
-
<RestartEventLane
|
|
165
|
+
<RestartEventLane
|
|
166
|
+
kind={kind}
|
|
167
|
+
namespace={namespace}
|
|
168
|
+
name={name}
|
|
169
|
+
range={timeRange}
|
|
170
|
+
/>
|
|
146
171
|
</div>
|
|
147
172
|
)}
|
|
148
173
|
|
|
149
|
-
<div className="grid grid-cols-1 md:grid-cols-2 gap-3
|
|
174
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 px-4 pt-4">
|
|
150
175
|
{chartPanels.map(({ def, refLines }) => (
|
|
151
176
|
<MetricsPanel
|
|
152
177
|
key={def.key}
|
|
@@ -159,33 +184,85 @@ export function PrometheusChartsGrid({
|
|
|
159
184
|
/>
|
|
160
185
|
))}
|
|
161
186
|
</div>
|
|
187
|
+
|
|
188
|
+
{/* Every chart here covers the same pods, so which pods those are is
|
|
189
|
+
stated once for the grid rather than repeated under each one. */}
|
|
190
|
+
<PodCoverageNote
|
|
191
|
+
kind={kind}
|
|
192
|
+
namespace={namespace}
|
|
193
|
+
name={name}
|
|
194
|
+
timeRange={timeRange}
|
|
195
|
+
/>
|
|
162
196
|
</div>
|
|
163
|
-
)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function PodCoverageNote({
|
|
201
|
+
kind,
|
|
202
|
+
namespace,
|
|
203
|
+
name,
|
|
204
|
+
timeRange,
|
|
205
|
+
}: {
|
|
206
|
+
kind: string;
|
|
207
|
+
namespace: string;
|
|
208
|
+
name: string;
|
|
209
|
+
timeRange: PrometheusTimeRange;
|
|
210
|
+
}) {
|
|
211
|
+
// Any category answers this: the pod scope is per workload, not per metric.
|
|
212
|
+
// This is the same query key the CPU panel uses, so it is already cached.
|
|
213
|
+
const { data } = usePrometheusResourceMetrics(
|
|
214
|
+
kind,
|
|
215
|
+
namespace,
|
|
216
|
+
name,
|
|
217
|
+
"cpu",
|
|
218
|
+
timeRange,
|
|
219
|
+
true,
|
|
220
|
+
);
|
|
221
|
+
const note = data ? describePodCoverage(data) : undefined;
|
|
222
|
+
if (!note) return null;
|
|
223
|
+
return <p className="px-4 pb-4 text-xs text-theme-text-tertiary">{note}</p>;
|
|
164
224
|
}
|
|
165
225
|
|
|
166
226
|
interface MetricsPanelProps {
|
|
167
|
-
category: CategoryDef
|
|
168
|
-
kind: string
|
|
169
|
-
namespace: string
|
|
170
|
-
name: string
|
|
171
|
-
timeRange: PrometheusTimeRange
|
|
172
|
-
referenceLines?: ReferenceLine[]
|
|
227
|
+
category: CategoryDef;
|
|
228
|
+
kind: string;
|
|
229
|
+
namespace: string;
|
|
230
|
+
name: string;
|
|
231
|
+
timeRange: PrometheusTimeRange;
|
|
232
|
+
referenceLines?: ReferenceLine[];
|
|
173
233
|
}
|
|
174
234
|
|
|
175
|
-
function MetricsPanel({
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
235
|
+
function MetricsPanel({
|
|
236
|
+
category,
|
|
237
|
+
kind,
|
|
238
|
+
namespace,
|
|
239
|
+
name,
|
|
240
|
+
timeRange,
|
|
241
|
+
referenceLines,
|
|
242
|
+
}: MetricsPanelProps) {
|
|
243
|
+
const {
|
|
244
|
+
data: metrics,
|
|
245
|
+
isLoading,
|
|
246
|
+
error,
|
|
247
|
+
} = usePrometheusResourceMetrics(
|
|
248
|
+
kind,
|
|
249
|
+
namespace,
|
|
250
|
+
name,
|
|
251
|
+
category.key,
|
|
252
|
+
timeRange,
|
|
253
|
+
true,
|
|
254
|
+
);
|
|
179
255
|
|
|
180
|
-
const series = metrics?.result?.series
|
|
181
|
-
const hasData = (series?.length ?? 0) > 0
|
|
256
|
+
const series = metrics?.result?.series;
|
|
257
|
+
const hasData = (series?.length ?? 0) > 0;
|
|
182
258
|
|
|
183
259
|
// "% of limit / request" derived from current peak vs reference lines.
|
|
184
260
|
// Without this, a low-utilization workload with a high limit looks like
|
|
185
261
|
// an empty chart — the user can't tell healthy from starved at a glance.
|
|
186
|
-
const saturation =
|
|
187
|
-
|
|
188
|
-
|
|
262
|
+
const saturation =
|
|
263
|
+
hasData && series && referenceLines
|
|
264
|
+
? computeSaturation(series, referenceLines)
|
|
265
|
+
: undefined;
|
|
189
266
|
|
|
190
267
|
return (
|
|
191
268
|
<section className="rounded-lg border border-theme-border bg-theme-surface/30 p-3 flex flex-col min-h-[260px]">
|
|
@@ -197,7 +274,11 @@ function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLin
|
|
|
197
274
|
{saturation && <SaturationChip {...saturation} />}
|
|
198
275
|
</div>
|
|
199
276
|
{hasData && series && (
|
|
200
|
-
<MetricsSummary
|
|
277
|
+
<MetricsSummary
|
|
278
|
+
series={series}
|
|
279
|
+
category={category}
|
|
280
|
+
unit={metrics!.unit}
|
|
281
|
+
/>
|
|
201
282
|
)}
|
|
202
283
|
</header>
|
|
203
284
|
|
|
@@ -226,16 +307,31 @@ function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLin
|
|
|
226
307
|
)}
|
|
227
308
|
</div>
|
|
228
309
|
</section>
|
|
229
|
-
)
|
|
310
|
+
);
|
|
230
311
|
}
|
|
231
312
|
|
|
232
|
-
function SaturationChip({
|
|
313
|
+
function SaturationChip({
|
|
314
|
+
ratio,
|
|
315
|
+
against,
|
|
316
|
+
}: {
|
|
317
|
+
ratio: number;
|
|
318
|
+
against: "limit" | "request";
|
|
319
|
+
}) {
|
|
233
320
|
// Thresholds match the rightsizing tone vocabulary: amber from 75% (start
|
|
234
321
|
// watching), red at 90% (the same OOM-risk boundary the backend uses for
|
|
235
322
|
// memory in classifyRightsizing).
|
|
236
|
-
const tone: Severity =
|
|
237
|
-
|
|
238
|
-
|
|
323
|
+
const tone: Severity =
|
|
324
|
+
ratio >= 0.9
|
|
325
|
+
? "error"
|
|
326
|
+
: ratio >= 0.75
|
|
327
|
+
? "warning"
|
|
328
|
+
: ratio < 0.05
|
|
329
|
+
? "info"
|
|
330
|
+
: "neutral";
|
|
331
|
+
const label = `${(ratio * 100).toFixed(ratio < 0.1 ? 1 : 0)}% of ${against}`;
|
|
332
|
+
return (
|
|
333
|
+
<span className={`badge badge-sm ${SEVERITY_BADGE[tone]}`}>{label}</span>
|
|
334
|
+
);
|
|
239
335
|
}
|
|
240
336
|
|
|
241
337
|
function PanelLoading() {
|
|
@@ -244,27 +340,33 @@ function PanelLoading() {
|
|
|
244
340
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
245
341
|
Loading…
|
|
246
342
|
</div>
|
|
247
|
-
)
|
|
343
|
+
);
|
|
248
344
|
}
|
|
249
345
|
|
|
250
346
|
function PanelError({ message }: { message: string }) {
|
|
251
347
|
return (
|
|
252
|
-
<div
|
|
348
|
+
<div
|
|
349
|
+
className={`flex flex-col items-center justify-center h-full min-h-[160px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}
|
|
350
|
+
>
|
|
253
351
|
Query failed
|
|
254
352
|
<Tooltip content={message} wrapperClassName="!block w-full">
|
|
255
|
-
|
|
353
|
+
<span className="text-theme-text-quaternary mt-0.5 line-clamp-2">
|
|
354
|
+
{message}
|
|
355
|
+
</span>
|
|
256
356
|
</Tooltip>
|
|
257
357
|
</div>
|
|
258
|
-
)
|
|
358
|
+
);
|
|
259
359
|
}
|
|
260
360
|
|
|
261
361
|
function PanelNoData({ hint }: { hint?: string }) {
|
|
262
362
|
return (
|
|
263
363
|
<div className="flex flex-col items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs px-3 text-center">
|
|
264
364
|
No data
|
|
265
|
-
{hint &&
|
|
365
|
+
{hint && (
|
|
366
|
+
<span className="text-theme-text-quaternary mt-1 max-w-xs">{hint}</span>
|
|
367
|
+
)}
|
|
266
368
|
</div>
|
|
267
|
-
)
|
|
369
|
+
);
|
|
268
370
|
}
|
|
269
371
|
|
|
270
|
-
export { isPrometheusSupported } from
|
|
372
|
+
export { isPrometheusSupported } from "./PrometheusCharts";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { curateError, detectLanguage } from './PodFilePreview'
|
|
4
|
+
|
|
5
|
+
// Every backend error code becomes a curated screen. What matters here is
|
|
6
|
+
// that each code carries the right action affordances (Download vs Retry)
|
|
7
|
+
// and never leaks a raw stderr line as the primary description.
|
|
8
|
+
|
|
9
|
+
describe('curateError — action affordances per code', () => {
|
|
10
|
+
it('offers Download for file_too_large and names the actual size', () => {
|
|
11
|
+
const shape = curateError({
|
|
12
|
+
ok: false,
|
|
13
|
+
code: 'file_too_large',
|
|
14
|
+
message: 'File is 12582912 bytes; preview is limited to 1048576 bytes.',
|
|
15
|
+
size: 12 * 1024 * 1024,
|
|
16
|
+
})
|
|
17
|
+
expect(shape.actions).toEqual({ download: true, retry: false })
|
|
18
|
+
expect(shape.description).toMatch(/MiB|MB/i)
|
|
19
|
+
// The raw byte-count message must not be the primary description.
|
|
20
|
+
expect(shape.description).not.toContain('12582912')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('offers Download for binary_file and names the detected mime type', () => {
|
|
24
|
+
const shape = curateError({
|
|
25
|
+
ok: false,
|
|
26
|
+
code: 'binary_file',
|
|
27
|
+
message: 'server-side detail',
|
|
28
|
+
mimeType: 'application/x-executable',
|
|
29
|
+
})
|
|
30
|
+
expect(shape.actions).toEqual({ download: true, retry: false })
|
|
31
|
+
expect(shape.description).toContain('application/x-executable')
|
|
32
|
+
expect(shape.description).not.toContain('server-side detail')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('offers Retry for not_found — the file may have been rotated', () => {
|
|
36
|
+
const shape = curateError({ ok: false, code: 'not_found', message: 'x' })
|
|
37
|
+
expect(shape.actions.retry).toBe(true)
|
|
38
|
+
expect(shape.actions.download).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('offers neither Download nor Retry for permission_denied', () => {
|
|
42
|
+
const shape = curateError({ ok: false, code: 'permission_denied', message: 'x' })
|
|
43
|
+
expect(shape.actions).toEqual({ download: false, retry: false })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('offers neither for no_shell — the container cannot be read this way', () => {
|
|
47
|
+
const shape = curateError({ ok: false, code: 'no_shell', message: 'x' })
|
|
48
|
+
expect(shape.actions).toEqual({ download: false, retry: false })
|
|
49
|
+
expect(shape.description).toMatch(/distroless|scratch|no shell/i)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('offers Retry for network_error and reveals details for diagnostics', () => {
|
|
53
|
+
const shape = curateError({
|
|
54
|
+
ok: false,
|
|
55
|
+
code: 'network_error',
|
|
56
|
+
message: 'Failed to fetch',
|
|
57
|
+
})
|
|
58
|
+
expect(shape.actions.retry).toBe(true)
|
|
59
|
+
expect(shape.details).toBe('Failed to fetch')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('falls unknown/read_failed to a generic curated line with both actions', () => {
|
|
63
|
+
const shape = curateError({ ok: false, code: 'read_failed', message: 'stderr blob' })
|
|
64
|
+
expect(shape.actions).toEqual({ download: true, retry: true })
|
|
65
|
+
// The raw stderr belongs behind the details disclosure, not in the headline.
|
|
66
|
+
expect(shape.description).not.toContain('stderr blob')
|
|
67
|
+
expect(shape.details).toBe('stderr blob')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// The primary description is what an operator reads first. It must never be
|
|
72
|
+
// the raw server message on any curated code — that was the maintainer's
|
|
73
|
+
// specific concern ("curated error message rather than only dumping raw error").
|
|
74
|
+
describe('curateError — no raw messages in primary text', () => {
|
|
75
|
+
const rawStderr = 'sh: /etc/shadow: Permission denied — some raw text'
|
|
76
|
+
it.each([
|
|
77
|
+
'file_too_large',
|
|
78
|
+
'binary_file',
|
|
79
|
+
'not_a_regular_file',
|
|
80
|
+
'not_found',
|
|
81
|
+
'permission_denied',
|
|
82
|
+
'no_shell',
|
|
83
|
+
'container_missing_tools',
|
|
84
|
+
'read_failed',
|
|
85
|
+
] as const)('%s does not leak the raw server message into the title or description', (code) => {
|
|
86
|
+
const shape = curateError({ ok: false, code, message: rawStderr })
|
|
87
|
+
expect(shape.title).not.toContain(rawStderr)
|
|
88
|
+
expect(shape.description).not.toContain(rawStderr)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('detectLanguage', () => {
|
|
93
|
+
it.each([
|
|
94
|
+
['nginx.conf', 'ini'],
|
|
95
|
+
['deployment.yaml', 'yaml'],
|
|
96
|
+
['deployment.yml', 'yaml'],
|
|
97
|
+
['schema.json', 'yaml'],
|
|
98
|
+
['Dockerfile', 'dockerfile'],
|
|
99
|
+
['index.html', 'html'],
|
|
100
|
+
['README.md', 'markdown'],
|
|
101
|
+
['app.log', 'plaintext'],
|
|
102
|
+
['unknown', 'plaintext'],
|
|
103
|
+
['UPPERCASE.YAML', 'yaml'],
|
|
104
|
+
])('%s → %s', (name, want) => {
|
|
105
|
+
expect(detectLanguage(name)).toBe(want)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// The empty state and the loading state each render distinct content so an
|
|
110
|
+
// operator can tell them apart. These are the two states most likely to look
|
|
111
|
+
// like a bug if they collide.
|
|
112
|
+
describe('curated states are distinguishable', () => {
|
|
113
|
+
it('an empty file description is not confusable with a failure', () => {
|
|
114
|
+
const shape = curateError({ ok: false, code: 'read_failed', message: '' })
|
|
115
|
+
// The empty-file state is rendered separately (PreviewEmptyState) and does
|
|
116
|
+
// NOT come through curateError — a read_failed with an empty message must
|
|
117
|
+
// therefore never say "empty" in its curated description.
|
|
118
|
+
expect(shape.description.toLowerCase()).not.toContain('empty')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Static markup smoke: the module imports at all and the exported pure helpers
|
|
123
|
+
// stay tree-shakeable / server-renderable. If curateError were to reach for
|
|
124
|
+
// browser-only globals this would trip.
|
|
125
|
+
describe('curated shapes render on the server', () => {
|
|
126
|
+
it('renders a title node without throwing in a server context', () => {
|
|
127
|
+
const shape = curateError({ ok: false, code: 'binary_file', message: 'x', mimeType: 'application/octet-stream' })
|
|
128
|
+
const html = renderToStaticMarkup(<span>{shape.title}</span>)
|
|
129
|
+
expect(html).toContain('Binary')
|
|
130
|
+
})
|
|
131
|
+
})
|