@skyhook-io/radar-app 1.14.3 → 1.14.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/api/client.ts +2 -0
- package/src/api/diagnose.ts +7 -1
- package/src/components/diagnose/AIMarkdown.tsx +8 -3
- package/src/components/diagnose/ActivityTurn.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +269 -238
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +47 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +15 -2
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +1 -1
- package/src/components/diagnose/InvestigationView.tsx +32 -24
- package/src/components/diagnose/investigationCase.test.tsx +83 -0
- package/src/components/diagnose/investigationCase.ts +3 -2
- package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +22 -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/listings.ts +2 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +60 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.ts +7 -3
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +1 -1
- package/src/components/diagnose/investigationEvidence/observations.ts +10 -5
- package/src/components/diagnose/investigationEvidence/types.ts +1 -1
- 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 +153 -2
- package/src/components/home/mcpToolCatalog.ts +27 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +194 -63
- package/src/components/rightsizing/empty-state.test.tsx +67 -0
- package/src/components/rightsizing/model.test.ts +118 -1
- package/src/components/rightsizing/model.ts +36 -13
- package/src/components/rightsizing/notices.test.tsx +175 -0
- package/src/components/ui/Markdown.tsx +33 -6
- package/src/components/ui/Omnibar.tsx +82 -69
- package/src/components/workload/WorkloadView.tsx +7 -1
|
@@ -48,7 +48,7 @@ export type StorySegment =
|
|
|
48
48
|
| {
|
|
49
49
|
kind: "placement";
|
|
50
50
|
index: number;
|
|
51
|
-
/**
|
|
51
|
+
/** Header and role only, except logs retain their last two selected lines. */
|
|
52
52
|
compact?: boolean;
|
|
53
53
|
/** Placed by Radar under the paragraph that first mentioned it inline. */
|
|
54
54
|
auto?: boolean;
|
|
@@ -31,6 +31,82 @@ import { ThemeProvider } from "../../context/ThemeContext";
|
|
|
31
31
|
|
|
32
32
|
const noop = vi.fn();
|
|
33
33
|
|
|
34
|
+
describe("explanations without a root cause", () => {
|
|
35
|
+
it("keeps inconclusive explanation, sources and action in one control row", () => {
|
|
36
|
+
const html = renderToStaticMarkup(
|
|
37
|
+
<ResultCard
|
|
38
|
+
diagnosis={
|
|
39
|
+
{
|
|
40
|
+
inconclusive: true,
|
|
41
|
+
summary: "Not enough evidence",
|
|
42
|
+
report: "Logs unavailable",
|
|
43
|
+
rootCause: "",
|
|
44
|
+
remediation: [],
|
|
45
|
+
} as Diagnosis
|
|
46
|
+
}
|
|
47
|
+
explanation={{ status: "idle", onGenerate: noop }}
|
|
48
|
+
assessmentSources={<span>Saved sources</span>}
|
|
49
|
+
assessmentAction={<button>Next steps</button>}
|
|
50
|
+
/>,
|
|
51
|
+
);
|
|
52
|
+
expect(html.match(/data-assessment-actions/g)).toHaveLength(1);
|
|
53
|
+
const row = html.slice(
|
|
54
|
+
html.indexOf("data-assessment-actions"),
|
|
55
|
+
html.indexOf("data-assessment-actions") + 6000,
|
|
56
|
+
);
|
|
57
|
+
expect(row).toContain("Assessment details");
|
|
58
|
+
expect(row).toContain("Explain simply");
|
|
59
|
+
expect(row).toContain("Next steps");
|
|
60
|
+
});
|
|
61
|
+
it.each(["healthy", "inconclusive"] as const)(
|
|
62
|
+
"offers Explain for a %s assessment",
|
|
63
|
+
(state) => {
|
|
64
|
+
const diagnosis = {
|
|
65
|
+
[state]: true,
|
|
66
|
+
summary: "Saved assessment",
|
|
67
|
+
report: "Saved analysis",
|
|
68
|
+
rootCause: "",
|
|
69
|
+
remediation: [],
|
|
70
|
+
} as Diagnosis;
|
|
71
|
+
const html = renderToStaticMarkup(
|
|
72
|
+
<ResultCard
|
|
73
|
+
diagnosis={diagnosis}
|
|
74
|
+
explanation={{ status: "idle", onGenerate: noop }}
|
|
75
|
+
section="conclusion"
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
expect(html.match(/Explain simply/g)).toHaveLength(1);
|
|
79
|
+
expect(html).not.toContain('disabled=""');
|
|
80
|
+
const running = renderToStaticMarkup(
|
|
81
|
+
<ResultCard
|
|
82
|
+
diagnosis={diagnosis}
|
|
83
|
+
explanation={{ status: "running" }}
|
|
84
|
+
section="conclusion"
|
|
85
|
+
/>,
|
|
86
|
+
);
|
|
87
|
+
expect(running).toContain("Explaining this assessment");
|
|
88
|
+
expect(
|
|
89
|
+
renderToStaticMarkup(
|
|
90
|
+
<ResultCard
|
|
91
|
+
diagnosis={diagnosis}
|
|
92
|
+
explanation={{ status: "idle", onGenerate: noop }}
|
|
93
|
+
section="actions"
|
|
94
|
+
/>,
|
|
95
|
+
),
|
|
96
|
+
).not.toContain("Explain simply");
|
|
97
|
+
expect(
|
|
98
|
+
renderToStaticMarkup(
|
|
99
|
+
<ResultCard
|
|
100
|
+
diagnosis={diagnosis}
|
|
101
|
+
explanation={{ status: "idle", onGenerate: noop }}
|
|
102
|
+
followup
|
|
103
|
+
/>,
|
|
104
|
+
),
|
|
105
|
+
).not.toContain("Explain simply");
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
34
110
|
it("keeps earlier remediation copyable without suggesting it is executable", () => {
|
|
35
111
|
const html = renderToStaticMarkup(
|
|
36
112
|
<ResultCard
|
|
@@ -95,10 +171,73 @@ describe("remediation commands", () => {
|
|
|
95
171
|
compactActions
|
|
96
172
|
/>,
|
|
97
173
|
);
|
|
98
|
-
expect(html
|
|
99
|
-
|
|
174
|
+
expect(html.match(/aria-label="Copy command from step 1"/g)).toHaveLength(
|
|
175
|
+
2,
|
|
176
|
+
);
|
|
100
177
|
expect(html).not.toContain("Copy remediation step");
|
|
178
|
+
expect(
|
|
179
|
+
html.indexOf("kubectl rollout undo deployment/api -n dev</code>"),
|
|
180
|
+
).toBeLessThan(html.indexOf("Copy command from step 1"));
|
|
181
|
+
expect(html.indexOf("Copy command from step 1")).toBeLessThan(
|
|
182
|
+
html.indexOf("kubectl -n dev get pods</code>"),
|
|
183
|
+
);
|
|
101
184
|
});
|
|
185
|
+
|
|
186
|
+
it.each(["bash", ""])(
|
|
187
|
+
"places each copy button inside its own %s fenced command box",
|
|
188
|
+
(language) => {
|
|
189
|
+
const html = renderToStaticMarkup(
|
|
190
|
+
<ResultCard
|
|
191
|
+
diagnosis={
|
|
192
|
+
{
|
|
193
|
+
rootCause: "Image pull failure",
|
|
194
|
+
report: "Assessment",
|
|
195
|
+
remediation: [
|
|
196
|
+
`Roll back:\n\`\`\`${language}\nkubectl rollout undo deployment/api -n dev\n\`\`\`\nThen watch:\n\`\`\`${language}\nkubectl rollout status deployment/api -n dev\n\`\`\``,
|
|
197
|
+
],
|
|
198
|
+
recommendedIndex: 1,
|
|
199
|
+
} as Diagnosis
|
|
200
|
+
}
|
|
201
|
+
section="actions"
|
|
202
|
+
compactActions
|
|
203
|
+
onApply={noop}
|
|
204
|
+
/>,
|
|
205
|
+
);
|
|
206
|
+
const blocks = [...html.matchAll(/<pre\b[^>]*>([\s\S]*?)<\/pre>/g)].map(
|
|
207
|
+
(match) => match[1],
|
|
208
|
+
);
|
|
209
|
+
expect(blocks).toHaveLength(2);
|
|
210
|
+
expect(blocks[0]).toContain("kubectl rollout undo");
|
|
211
|
+
expect(blocks[0]).toContain("Copy command from step 1");
|
|
212
|
+
expect(blocks[1]).toContain("kubectl rollout status");
|
|
213
|
+
expect(blocks[1]).toContain("Copy command from step 1");
|
|
214
|
+
expect(html.match(/aria-label="Copy command/g)).toHaveLength(2);
|
|
215
|
+
expect(html.indexOf("Apply…")).toBeLessThan(html.indexOf("<pre"));
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("keeps repeated and normalized commands copyable at their rendered locations", () => {
|
|
221
|
+
const step =
|
|
222
|
+
"Run `kubectl get pods -n dev`, then run this: ```bash kubectl get pods -n dev ```";
|
|
223
|
+
expect(remediationCommands(step)).toEqual([
|
|
224
|
+
"kubectl get pods -n dev",
|
|
225
|
+
"kubectl get pods -n dev",
|
|
226
|
+
]);
|
|
227
|
+
const html = renderToStaticMarkup(
|
|
228
|
+
<ResultCard
|
|
229
|
+
diagnosis={
|
|
230
|
+
{ rootCause: "Check", report: "", remediation: [step] } as Diagnosis
|
|
231
|
+
}
|
|
232
|
+
section="actions"
|
|
233
|
+
compactActions
|
|
234
|
+
/>,
|
|
235
|
+
);
|
|
236
|
+
expect(html.match(/aria-label="Copy command from step 1"/g)).toHaveLength(2);
|
|
237
|
+
expect(html).toMatch(
|
|
238
|
+
/<pre\b[^>]*>[\s\S]*kubectl get pods -n dev[\s\S]*Copy command from step 1[\s\S]*<\/pre>/,
|
|
239
|
+
);
|
|
240
|
+
expect(html).not.toContain("bash kubectl");
|
|
102
241
|
});
|
|
103
242
|
|
|
104
243
|
describe("explanation placement", () => {
|
|
@@ -1571,4 +1710,16 @@ describe("assessment provenance disclosure", () => {
|
|
|
1571
1710
|
expect(shown).not.toContain("WITHHELD_CLAIM");
|
|
1572
1711
|
expect(shown).toContain("1 agent note on an evidence card");
|
|
1573
1712
|
});
|
|
1713
|
+
|
|
1714
|
+
it("discloses entries a count cap left out even when nothing else was lost", () => {
|
|
1715
|
+
const only = renderToStaticMarkup(
|
|
1716
|
+
<AssessmentSources omittedEntries={2} onViewSource={noop} />,
|
|
1717
|
+
);
|
|
1718
|
+
expect(only).toContain(
|
|
1719
|
+
"2 next steps, open items or ruled-out hypotheses went past the page",
|
|
1720
|
+
);
|
|
1721
|
+
expect(
|
|
1722
|
+
renderToStaticMarkup(<AssessmentSources onViewSource={noop} />),
|
|
1723
|
+
).toBe("");
|
|
1724
|
+
});
|
|
1574
1725
|
});
|
|
@@ -256,6 +256,33 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
256
256
|
{ arg: 'limit', desc: 'max rules returned (default 50, max 200)' },
|
|
257
257
|
],
|
|
258
258
|
},
|
|
259
|
+
{
|
|
260
|
+
name: 'get_cost',
|
|
261
|
+
desc: 'Cluster spend from OpenCost or Kubecost — cluster and per-namespace totals, per-workload and per-node breakdowns, and spend over time. Hourly rates plus the same monthly projection the Costs page shows.',
|
|
262
|
+
params: [
|
|
263
|
+
{ arg: 'view', desc: 'summary (default), workloads, nodes (cluster-wide), or trend' },
|
|
264
|
+
{ arg: 'namespace', desc: 'required for view=workloads; filters summary and trend' },
|
|
265
|
+
{ arg: 'kind', desc: 'view=workloads: target one workload instead of the top spenders; needs name' },
|
|
266
|
+
{ arg: 'name', desc: 'workloads: exact workload name (needs kind); nodes: exact node name' },
|
|
267
|
+
{ arg: 'range', desc: 'trend only: 6h, 24h (default), or 7d' },
|
|
268
|
+
{ arg: 'include_points', desc: 'trend only: include raw points (default false); summaries are always returned' },
|
|
269
|
+
{ arg: 'limit', desc: 'max rows (default 20, max 100)' },
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: 'get_rightsizing',
|
|
274
|
+
desc: 'CPU/memory request recommendations, with current limits as context, from 7 days of observed usage, with a fit verdict and confidence tier per container. Scans can take 45 seconds; inspect confidence and missing evidence. Request reductions do not directly imply bill savings.',
|
|
275
|
+
params: [
|
|
276
|
+
{ arg: 'scope', required: true, desc: 'workload (needs kind/name/namespace), namespace, or cluster' },
|
|
277
|
+
{ arg: 'kind', desc: 'scope=workload: Deployment, StatefulSet, or DaemonSet' },
|
|
278
|
+
{ arg: 'name', desc: 'scope=workload: the workload name' },
|
|
279
|
+
{ arg: 'namespace', desc: 'required for scope=workload; for scope=namespace unless namespaces is set' },
|
|
280
|
+
{ arg: 'namespaces', desc: 'scope=namespace: scan several namespaces in one call' },
|
|
281
|
+
{ arg: 'include_all', desc: 'include balanced and unevidenced rows (default false); workload scope always returns all rows' },
|
|
282
|
+
{ arg: 'classification', desc: 'scan only: reduction, increase, review, need_data, or in_range; returns all rows of matching workloads' },
|
|
283
|
+
{ arg: 'limit', desc: 'max workloads returned by a namespace or cluster scan (default 20, max 100)' },
|
|
284
|
+
],
|
|
285
|
+
},
|
|
259
286
|
{
|
|
260
287
|
name: 'get_workload_logs',
|
|
261
288
|
desc: 'Aggregated logs across all pods of a workload. Without grep, filters for diagnostic relevance; grep returns only matching timestamp-prefixed lines instead.',
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
PageHeader,
|
|
9
9
|
SearchBox,
|
|
10
10
|
SelectMenu,
|
|
11
|
+
pluralize,
|
|
12
|
+
useDisclosure,
|
|
11
13
|
} from '@skyhook-io/k8s-ui'
|
|
12
14
|
import { Badge } from '@skyhook-io/k8s-ui/components/ui/Badge'
|
|
13
15
|
import {
|
|
@@ -84,11 +86,6 @@ const ACTION_META: Record<
|
|
|
84
86
|
'increase' | 'reduction' | 'review',
|
|
85
87
|
{ label: string; severity: 'warning' | 'info' | 'neutral'; helper: string }
|
|
86
88
|
> = {
|
|
87
|
-
reduction: {
|
|
88
|
-
label: 'Reduce requests',
|
|
89
|
-
severity: RIGHTSIZING_ACTION_SEVERITY.reduction,
|
|
90
|
-
helper: 'Reclaim meaningful capacity',
|
|
91
|
-
},
|
|
92
89
|
increase: {
|
|
93
90
|
label: 'Increase or add',
|
|
94
91
|
severity: RIGHTSIZING_ACTION_SEVERITY.increase,
|
|
@@ -99,6 +96,11 @@ const ACTION_META: Record<
|
|
|
99
96
|
severity: RIGHTSIZING_ACTION_SEVERITY.review,
|
|
100
97
|
helper: 'Check safety signals or workloads with no replicas',
|
|
101
98
|
},
|
|
99
|
+
reduction: {
|
|
100
|
+
label: 'Reduce requests',
|
|
101
|
+
severity: RIGHTSIZING_ACTION_SEVERITY.reduction,
|
|
102
|
+
helper: 'Reclaim meaningful capacity',
|
|
103
|
+
},
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
@@ -329,13 +331,20 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
|
329
331
|
<Notice text="Scanning the current scope. Previous results remain visible until the scan completes." />
|
|
330
332
|
)}
|
|
331
333
|
<ScanSummary
|
|
334
|
+
namespaces={namespaces}
|
|
332
335
|
result={result}
|
|
333
336
|
counts={counts}
|
|
334
337
|
selected={classFilter}
|
|
335
338
|
onSelect={(value) => setFilter('rfClass', value === 'actions' ? undefined : value)}
|
|
336
339
|
/>
|
|
337
|
-
<ScanNotices
|
|
338
|
-
{result.
|
|
340
|
+
<ScanNotices key={result.scannedAt} result={result} />
|
|
341
|
+
{result.reason === 'only_daemonsets_without_nodes' ? (
|
|
342
|
+
<EmptyState
|
|
343
|
+
variant="card"
|
|
344
|
+
headline="Only DaemonSets with no nodes in this scope"
|
|
345
|
+
body="Every workload here is a DaemonSet that runs on no node right now, so none was scanned. Open one from Resources to see recommendations from its retained history."
|
|
346
|
+
/>
|
|
347
|
+
) : result.coverage.workloadsDiscovered === 0 || rows.length === 0 ? (
|
|
339
348
|
<EmptyState
|
|
340
349
|
variant="card"
|
|
341
350
|
headline="No supported workloads in this scope"
|
|
@@ -361,31 +370,17 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
|
361
370
|
active={activeFilters}
|
|
362
371
|
/>
|
|
363
372
|
{filteredRows.length === 0 ? (
|
|
364
|
-
<
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
action={
|
|
377
|
-
<button
|
|
378
|
-
type="button"
|
|
379
|
-
onClick={() =>
|
|
380
|
-
onlySystemRowsAreHidden ? setFilter('rfScope', 'all') : clearFilters()
|
|
381
|
-
}
|
|
382
|
-
className="badge badge-sm border border-theme-border bg-theme-elevated text-theme-text-primary"
|
|
383
|
-
>
|
|
384
|
-
{onlySystemRowsAreHidden
|
|
385
|
-
? 'Include system workloads'
|
|
386
|
-
: 'Show recommended actions'}
|
|
387
|
-
</button>
|
|
388
|
-
}
|
|
373
|
+
<ScanEmptyState
|
|
374
|
+
counts={counts}
|
|
375
|
+
classFilter={classFilter}
|
|
376
|
+
hasResultFilters={Boolean(search || kindFilter || namespaceFilter)}
|
|
377
|
+
onlySystemRowsAreHidden={onlySystemRowsAreHidden}
|
|
378
|
+
hasQueryErrors={scopeRows.some(
|
|
379
|
+
(row) => row.cpu?.queryError || row.memory?.queryError,
|
|
380
|
+
)}
|
|
381
|
+
onSelect={(value) => setFilter('rfClass', value)}
|
|
382
|
+
onClear={clearFilters}
|
|
383
|
+
onIncludeSystem={() => setFilter('rfScope', 'all')}
|
|
389
384
|
/>
|
|
390
385
|
) : (
|
|
391
386
|
<div>
|
|
@@ -430,6 +425,77 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
|
430
425
|
)
|
|
431
426
|
}
|
|
432
427
|
|
|
428
|
+
export function ScanEmptyState({
|
|
429
|
+
counts,
|
|
430
|
+
classFilter,
|
|
431
|
+
hasResultFilters,
|
|
432
|
+
onlySystemRowsAreHidden,
|
|
433
|
+
hasQueryErrors,
|
|
434
|
+
onSelect,
|
|
435
|
+
onClear,
|
|
436
|
+
onIncludeSystem,
|
|
437
|
+
}: {
|
|
438
|
+
counts: ReturnType<typeof scanClassCounts>
|
|
439
|
+
classFilter: ClassFilter
|
|
440
|
+
hasResultFilters: boolean
|
|
441
|
+
onlySystemRowsAreHidden: boolean
|
|
442
|
+
hasQueryErrors: boolean
|
|
443
|
+
onSelect: (value: ScanClass) => void
|
|
444
|
+
onClear: () => void
|
|
445
|
+
onIncludeSystem: () => void
|
|
446
|
+
}) {
|
|
447
|
+
let headline = 'No results match the current filters'
|
|
448
|
+
let body = 'Choose another result type or clear a filter.'
|
|
449
|
+
let actionLabel = 'Clear filters'
|
|
450
|
+
let onAction = onClear
|
|
451
|
+
if (onlySystemRowsAreHidden) {
|
|
452
|
+
headline = 'System workloads are hidden'
|
|
453
|
+
body =
|
|
454
|
+
'This scope contains only Kubernetes system workloads. Include them to review platform requests.'
|
|
455
|
+
actionLabel = 'Include system workloads'
|
|
456
|
+
onAction = onIncludeSystem
|
|
457
|
+
} else if (
|
|
458
|
+
classFilter === 'actions' &&
|
|
459
|
+
!hasResultFilters &&
|
|
460
|
+
counts.increase + counts.reduction + counts.review === 0
|
|
461
|
+
) {
|
|
462
|
+
if (counts.need_data > 0) {
|
|
463
|
+
headline =
|
|
464
|
+
counts.in_range > 0
|
|
465
|
+
? 'No actionable changes in available evidence'
|
|
466
|
+
: 'Not enough evidence for recommendations'
|
|
467
|
+
body =
|
|
468
|
+
counts.in_range > 0
|
|
469
|
+
? `${pluralize(counts.in_range, 'container')} had no meaningful request changes; ${pluralize(counts.need_data, 'container')} still ${counts.need_data === 1 ? 'needs' : 'need'} evidence. This is not a complete assessment.`
|
|
470
|
+
: hasQueryErrors
|
|
471
|
+
? 'Some metrics queries failed. Inspect affected containers, then retry the scan or select fewer namespaces in the top bar.'
|
|
472
|
+
: 'There is not enough usable CPU and memory history to recommend changes yet. Inspect the containers to see which resource needs more history.'
|
|
473
|
+
actionLabel = 'View containers needing evidence'
|
|
474
|
+
onAction = () => onSelect('need_data')
|
|
475
|
+
} else {
|
|
476
|
+
headline = 'No actionable changes'
|
|
477
|
+
body = `No meaningful request changes were found among the ${pluralize(counts.in_range, 'evaluated container')}. This does not assess workloads outside the scan coverage.`
|
|
478
|
+
actionLabel = 'View evaluated containers'
|
|
479
|
+
onAction = () => onSelect('in_range')
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return (
|
|
483
|
+
<EmptyState
|
|
484
|
+
headline={headline}
|
|
485
|
+
body={body}
|
|
486
|
+
action={
|
|
487
|
+
<button
|
|
488
|
+
type="button"
|
|
489
|
+
onClick={onAction}
|
|
490
|
+
className="badge badge-sm border border-theme-border bg-theme-elevated text-theme-text-primary"
|
|
491
|
+
>
|
|
492
|
+
{actionLabel}
|
|
493
|
+
</button>
|
|
494
|
+
}
|
|
495
|
+
/>
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
433
499
|
function FirstRunState({ namespaces, onRun }: { namespaces: string[]; onRun: () => void }) {
|
|
434
500
|
const scope =
|
|
435
501
|
namespaces.length === 0
|
|
@@ -459,21 +525,28 @@ function FirstRunState({ namespaces, onRun }: { namespaces: string[]; onRun: ()
|
|
|
459
525
|
)
|
|
460
526
|
}
|
|
461
527
|
|
|
462
|
-
function ScanSummary({
|
|
528
|
+
export function ScanSummary({
|
|
529
|
+
namespaces,
|
|
463
530
|
result,
|
|
464
531
|
counts,
|
|
465
532
|
selected,
|
|
466
533
|
onSelect,
|
|
467
534
|
}: {
|
|
535
|
+
namespaces: string[]
|
|
468
536
|
result: ScanResult
|
|
469
537
|
counts: ReturnType<typeof scanClassCounts>
|
|
470
538
|
selected: ClassFilter
|
|
471
539
|
onSelect: (value: ClassFilter) => void
|
|
472
540
|
}) {
|
|
473
|
-
const evaluated = result.coverage.workloadsEvaluated ?? result.workloads.length
|
|
474
|
-
const discovered = result.coverage.workloadsDiscovered ?? result.workloads.length
|
|
475
541
|
return (
|
|
476
542
|
<section className="rounded-xl border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
|
|
543
|
+
<p className="mb-3 break-words text-xs text-theme-text-secondary">
|
|
544
|
+
Scanned scope:{' '}
|
|
545
|
+
<span className="font-medium text-theme-text-primary">
|
|
546
|
+
{namespaces.length ? namespaces.join(', ') : 'All visible namespaces'}
|
|
547
|
+
</span>
|
|
548
|
+
<span className="text-theme-text-tertiary"> · Change scan scope in the top bar</span>
|
|
549
|
+
</p>
|
|
477
550
|
<div className="grid gap-2 md:grid-cols-3">
|
|
478
551
|
{(Object.keys(ACTION_META) as Array<keyof typeof ACTION_META>).map((key) => {
|
|
479
552
|
const meta = ACTION_META[key]
|
|
@@ -487,7 +560,7 @@ function ScanSummary({
|
|
|
487
560
|
<div className="flex items-center justify-between gap-2">
|
|
488
561
|
<span className="text-sm font-medium text-theme-text-primary">{meta.label}</span>
|
|
489
562
|
<Badge severity={meta.severity} size="sm">
|
|
490
|
-
{counts[key]}
|
|
563
|
+
{pluralize(counts[key], 'container')}
|
|
491
564
|
</Badge>
|
|
492
565
|
</div>
|
|
493
566
|
<p className="mt-1 text-xs text-theme-text-tertiary">{meta.helper}</p>
|
|
@@ -497,7 +570,10 @@ function ScanSummary({
|
|
|
497
570
|
</div>
|
|
498
571
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-theme-text-tertiary">
|
|
499
572
|
<span>
|
|
500
|
-
{
|
|
573
|
+
{result.coverage.workloadsEvaluated} of {result.coverage.workloadsDiscovered} workloads
|
|
574
|
+
attempted
|
|
575
|
+
{' · '}
|
|
576
|
+
{result.coverage.workloadsWithData} with usage data · {result.window} window
|
|
501
577
|
</span>
|
|
502
578
|
<span className="flex items-center gap-3">
|
|
503
579
|
<button
|
|
@@ -505,14 +581,14 @@ function ScanSummary({
|
|
|
505
581
|
onClick={() => onSelect('in_range')}
|
|
506
582
|
className="hover:text-theme-text-primary"
|
|
507
583
|
>
|
|
508
|
-
{counts.in_range}
|
|
584
|
+
{pluralize(counts.in_range, 'container')} · no meaningful change
|
|
509
585
|
</button>
|
|
510
586
|
<button
|
|
511
587
|
type="button"
|
|
512
588
|
onClick={() => onSelect('need_data')}
|
|
513
589
|
className="hover:text-theme-text-primary"
|
|
514
590
|
>
|
|
515
|
-
{counts.need_data}
|
|
591
|
+
{pluralize(counts.need_data, 'container')} · needs evidence
|
|
516
592
|
</button>
|
|
517
593
|
{selected !== 'actions' && (
|
|
518
594
|
<button
|
|
@@ -529,24 +605,76 @@ function ScanSummary({
|
|
|
529
605
|
)
|
|
530
606
|
}
|
|
531
607
|
|
|
532
|
-
function ScanNotices({ result
|
|
608
|
+
export function ScanNotices({ result }: { result: ScanResult }) {
|
|
609
|
+
const [open, setOpen] = useState(false)
|
|
610
|
+
const disclosure = useDisclosure(open)
|
|
533
611
|
const notices: string[] = []
|
|
534
|
-
if (result.state === 'partial')
|
|
535
|
-
notices.push('Some workloads could not be fully analyzed. Completed recommendations are shown.')
|
|
536
612
|
if ((result.coverage.restrictedKinds?.length ?? 0) > 0)
|
|
537
613
|
notices.push('Some workload kinds or namespaces were excluded by your Kubernetes access.')
|
|
538
614
|
if ((result.coverage.unavailableKinds?.length ?? 0) > 0)
|
|
539
615
|
notices.push('Some workload kinds could not be evaluated with the available ownership data.')
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
616
|
+
if ((result.coverage.partiallyCachedKinds?.length ?? 0) > 0)
|
|
617
|
+
notices.push(
|
|
618
|
+
'Radar is caching only some namespaces, so this scan covered a narrower scope than the whole cluster.',
|
|
619
|
+
)
|
|
620
|
+
const daemonSetsWithoutNodes = result.coverage.daemonSetsWithoutNodes ?? 0
|
|
621
|
+
// The DaemonSet-only empty state already says this.
|
|
622
|
+
if (daemonSetsWithoutNodes > 0 && result.reason !== 'only_daemonsets_without_nodes')
|
|
623
|
+
notices.push(
|
|
624
|
+
`${pluralize(daemonSetsWithoutNodes, 'DaemonSet')} ${daemonSetsWithoutNodes === 1 ? 'runs' : 'run'} on no node right now and ${daemonSetsWithoutNodes === 1 ? 'is' : 'are'} not listed. Open one from Resources to see recommendations from its retained history.`,
|
|
625
|
+
)
|
|
626
|
+
const warnings = result.warnings ?? []
|
|
627
|
+
const deadlineExceeded = warnings.some((warning) => warning.code === 'scan_deadline_exceeded')
|
|
628
|
+
for (const warning of warnings) {
|
|
629
|
+
if (warning.code !== 'scan_deadline_exceeded') notices.push(warningMessage(warning.code))
|
|
630
|
+
}
|
|
631
|
+
const partial = result.state === 'partial'
|
|
632
|
+
if (!partial && notices.length === 0 && !deadlineExceeded) return null
|
|
633
|
+
const details = [...new Set(notices)]
|
|
634
|
+
const singleNote = details.length === 1 && !deadlineExceeded
|
|
635
|
+
const hasDetails = details.length > 0 && !singleNote
|
|
636
|
+
const summary = singleNote
|
|
637
|
+
? details[0]
|
|
638
|
+
: deadlineExceeded
|
|
639
|
+
? 'The scan reached its time limit. Available recommendations are shown. For a narrower scan, select namespaces in the top bar and run again.'
|
|
640
|
+
: partial
|
|
641
|
+
? 'Some workloads or metrics could not be fully evaluated. Available recommendations are shown.'
|
|
642
|
+
: 'Some workloads were excluded from this scan.'
|
|
643
|
+
return (
|
|
644
|
+
<section
|
|
645
|
+
aria-label="Scan coverage"
|
|
646
|
+
className="rounded-lg border border-theme-border bg-theme-surface text-xs text-theme-text-secondary"
|
|
647
|
+
>
|
|
648
|
+
<div className="flex items-start gap-2 px-3 py-2">
|
|
649
|
+
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
650
|
+
<div className="min-w-0 flex-1">
|
|
651
|
+
<p className="font-medium text-theme-text-primary">
|
|
652
|
+
{partial ? 'Partial results' : 'Scan notes'}
|
|
653
|
+
</p>
|
|
654
|
+
<p className="mt-1">{summary}</p>
|
|
655
|
+
</div>
|
|
656
|
+
{hasDetails && (
|
|
657
|
+
<button
|
|
658
|
+
type="button"
|
|
659
|
+
{...disclosure.buttonProps}
|
|
660
|
+
onClick={() => setOpen(!open)}
|
|
661
|
+
className="inline-flex shrink-0 items-center gap-1 rounded px-1 py-0.5 text-accent-text hover:bg-theme-hover"
|
|
662
|
+
>
|
|
663
|
+
Details <CollapseChevron open={open} className="h-3.5 w-3.5" />
|
|
664
|
+
</button>
|
|
665
|
+
)}
|
|
666
|
+
</div>
|
|
667
|
+
{hasDetails && (
|
|
668
|
+
<Collapse open={open} id={disclosure.panelId}>
|
|
669
|
+
<ul className="list-disc space-y-1 border-t border-theme-border py-2 pl-9 pr-3">
|
|
670
|
+
{details.map((text) => (
|
|
671
|
+
<li key={text}>{text}</li>
|
|
672
|
+
))}
|
|
673
|
+
</ul>
|
|
674
|
+
</Collapse>
|
|
675
|
+
)}
|
|
676
|
+
</section>
|
|
677
|
+
)
|
|
550
678
|
}
|
|
551
679
|
|
|
552
680
|
function Notice({ text, tone }: { text: string; tone?: 'warning' }) {
|
|
@@ -579,6 +707,7 @@ function ScanFilters(props: {
|
|
|
579
707
|
}) {
|
|
580
708
|
return (
|
|
581
709
|
<div className="flex flex-wrap items-center gap-2 border-b border-theme-border p-3">
|
|
710
|
+
<span className="text-xs font-medium text-theme-text-secondary">Filter results</span>
|
|
582
711
|
<SearchBox
|
|
583
712
|
value={props.search}
|
|
584
713
|
onChange={props.onSearch}
|
|
@@ -588,12 +717,12 @@ function ScanFilters(props: {
|
|
|
588
717
|
className="mr-auto w-60 2xl:w-72"
|
|
589
718
|
/>
|
|
590
719
|
<SelectMenu
|
|
591
|
-
ariaLabel="Filter by namespace"
|
|
720
|
+
ariaLabel="Filter results by namespace"
|
|
592
721
|
value={props.namespace}
|
|
593
722
|
onChange={props.onNamespace}
|
|
594
|
-
className="w-
|
|
723
|
+
className="w-52"
|
|
595
724
|
options={[
|
|
596
|
-
{ value: '', label: 'All namespaces' },
|
|
725
|
+
{ value: '', label: 'All scanned namespaces' },
|
|
597
726
|
...props.namespaces.map((value) => ({ value, label: value })),
|
|
598
727
|
]}
|
|
599
728
|
/>
|
|
@@ -765,7 +894,7 @@ function SignalBadge({ signal }: { signal: ResourceSignal }) {
|
|
|
765
894
|
)
|
|
766
895
|
}
|
|
767
896
|
|
|
768
|
-
function FitWhy({ label, row }: { label: string; row?: RightsizingRow }) {
|
|
897
|
+
export function FitWhy({ label, row }: { label: string; row?: RightsizingRow }) {
|
|
769
898
|
if (!row)
|
|
770
899
|
return (
|
|
771
900
|
<div>
|
|
@@ -790,8 +919,9 @@ function FitWhy({ label, row }: { label: string; row?: RightsizingRow }) {
|
|
|
790
919
|
Why this {label.toLowerCase()} guidance
|
|
791
920
|
</div>
|
|
792
921
|
<p className="mt-1 text-xs text-theme-text-secondary">
|
|
793
|
-
{
|
|
794
|
-
|
|
922
|
+
{row.queryError
|
|
923
|
+
? 'Metrics could not be queried, so usable history could not be determined. Retry the scan, or select fewer namespaces in the top bar.'
|
|
924
|
+
: `${observation}${history}`}
|
|
795
925
|
</p>
|
|
796
926
|
<EvidenceNote row={row} />
|
|
797
927
|
</div>
|
|
@@ -851,7 +981,7 @@ function primaryEvidenceNote(row: RightsizingRow) {
|
|
|
851
981
|
The calculated request would exceed the current limit.
|
|
852
982
|
</p>
|
|
853
983
|
)
|
|
854
|
-
if (row.fit === 'insufficient_history')
|
|
984
|
+
if (row.fit === 'insufficient_history' && !row.queryError)
|
|
855
985
|
return (
|
|
856
986
|
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
857
987
|
Wait for more runtime history before changing this request.
|
|
@@ -965,14 +1095,15 @@ function unavailableMessage(reason?: string): string {
|
|
|
965
1095
|
}
|
|
966
1096
|
|
|
967
1097
|
function warningMessage(code: string): string {
|
|
968
|
-
if (code === 'scan_deadline_exceeded')
|
|
969
|
-
return 'The scan reached its time limit. Results from completed batches are shown.'
|
|
970
1098
|
if (code === 'owner_metrics_query_failed') return 'Workload ownership data could not be queried.'
|
|
971
1099
|
if (code === 'oom_evidence_unavailable')
|
|
972
1100
|
return 'Radar could not verify restart history for some containers, so it withheld their memory reductions.'
|
|
973
1101
|
if (code.endsWith('_query_failed'))
|
|
974
|
-
return `Some ${code
|
|
975
|
-
|
|
1102
|
+
return `Some ${code
|
|
1103
|
+
.replace('_query_failed', '')
|
|
1104
|
+
.replaceAll('_', ' ')
|
|
1105
|
+
.replace(/\bcpu\b/g, 'CPU')} data could not be queried.`
|
|
1106
|
+
return 'Some rightsizing data was unavailable.'
|
|
976
1107
|
}
|
|
977
1108
|
|
|
978
1109
|
function formatScanTime(value: string): string {
|