@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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/api/client.ts +2 -0
  3. package/src/api/diagnose.ts +7 -1
  4. package/src/components/diagnose/AIMarkdown.tsx +8 -3
  5. package/src/components/diagnose/ActivityTurn.tsx +1 -1
  6. package/src/components/diagnose/AssessmentCard.tsx +269 -238
  7. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +47 -0
  8. package/src/components/diagnose/InvestigationEvidencePane.tsx +15 -2
  9. package/src/components/diagnose/InvestigationEvidenceSections.tsx +1 -1
  10. package/src/components/diagnose/InvestigationView.tsx +32 -24
  11. package/src/components/diagnose/investigationCase.test.tsx +83 -0
  12. package/src/components/diagnose/investigationCase.ts +3 -2
  13. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +22 -0
  14. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
  15. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
  16. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +2 -0
  17. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +60 -0
  18. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +7 -3
  19. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +1 -1
  20. package/src/components/diagnose/investigationEvidence/observations.ts +10 -5
  21. package/src/components/diagnose/investigationEvidence/types.ts +1 -1
  22. package/src/components/diagnose/investigationState.test.ts +2 -2
  23. package/src/components/diagnose/investigationState.ts +2 -2
  24. package/src/components/diagnose/investigationStory.ts +1 -1
  25. package/src/components/diagnose/parts.test.tsx +153 -2
  26. package/src/components/home/mcpToolCatalog.ts +27 -0
  27. package/src/components/rightsizing/RightsizingScanView.tsx +194 -63
  28. package/src/components/rightsizing/empty-state.test.tsx +67 -0
  29. package/src/components/rightsizing/model.test.ts +118 -1
  30. package/src/components/rightsizing/model.ts +36 -13
  31. package/src/components/rightsizing/notices.test.tsx +175 -0
  32. package/src/components/ui/Markdown.tsx +33 -6
  33. package/src/components/ui/Omnibar.tsx +82 -69
  34. 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
- /** The agent asked for the header only: the reader needs the fact, not the detail. */
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).toContain("Copy command 1 of step 1");
99
- expect(html).toContain("Copy command 2 of step 1");
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 result={result} rows={rows} />
338
- {result.coverage.workloadsDiscovered === 0 || rows.length === 0 ? (
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
- <EmptyState
365
- tone="filtered"
366
- headline={
367
- onlySystemRowsAreHidden
368
- ? 'System workloads are hidden'
369
- : 'No results match the current filters'
370
- }
371
- body={
372
- onlySystemRowsAreHidden
373
- ? 'This scope contains only Kubernetes system workloads. Include them to review platform requests.'
374
- : 'Choose another result type or clear a filter.'
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]} containers
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
- {evaluated} of {discovered} visible workloads evaluated · {result.window || '7d'} history
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} containers · no meaningful change
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} containers · not analyzed
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, rows }: { result: ScanResult; rows: RightsizingScanRow[] }) {
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
- for (const warning of result.warnings ?? []) notices.push(warningMessage(warning.code))
541
- if (rows.length > 0 && rows.every((row) => row.classification === 'need_data'))
542
- notices.push('There is not enough recent history to recommend request changes yet.')
543
- return notices.length > 0 ? (
544
- <div className="flex flex-col gap-2">
545
- {notices.map((text) => (
546
- <Notice key={text} text={text} />
547
- ))}
548
- </div>
549
- ) : null
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-40 2xl:w-48"
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
- {observation}
794
- {history}
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.replace('_query_failed', '').replaceAll('_', ' ')} data could not be queried.`
975
- return 'Some rightsizing data was unavailable. Completed recommendations are shown.'
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 {