@skyhook-io/radar-app 1.14.4 → 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.
@@ -9,6 +9,7 @@ import {
9
9
  SearchBox,
10
10
  SelectMenu,
11
11
  pluralize,
12
+ useDisclosure,
12
13
  } from '@skyhook-io/k8s-ui'
13
14
  import { Badge } from '@skyhook-io/k8s-ui/components/ui/Badge'
14
15
  import {
@@ -85,11 +86,6 @@ const ACTION_META: Record<
85
86
  'increase' | 'reduction' | 'review',
86
87
  { label: string; severity: 'warning' | 'info' | 'neutral'; helper: string }
87
88
  > = {
88
- reduction: {
89
- label: 'Reduce requests',
90
- severity: RIGHTSIZING_ACTION_SEVERITY.reduction,
91
- helper: 'Reclaim meaningful capacity',
92
- },
93
89
  increase: {
94
90
  label: 'Increase or add',
95
91
  severity: RIGHTSIZING_ACTION_SEVERITY.increase,
@@ -100,6 +96,11 @@ const ACTION_META: Record<
100
96
  severity: RIGHTSIZING_ACTION_SEVERITY.review,
101
97
  helper: 'Check safety signals or workloads with no replicas',
102
98
  },
99
+ reduction: {
100
+ label: 'Reduce requests',
101
+ severity: RIGHTSIZING_ACTION_SEVERITY.reduction,
102
+ helper: 'Reclaim meaningful capacity',
103
+ },
103
104
  }
104
105
 
105
106
  export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
@@ -330,12 +331,13 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
330
331
  <Notice text="Scanning the current scope. Previous results remain visible until the scan completes." />
331
332
  )}
332
333
  <ScanSummary
334
+ namespaces={namespaces}
333
335
  result={result}
334
336
  counts={counts}
335
337
  selected={classFilter}
336
338
  onSelect={(value) => setFilter('rfClass', value === 'actions' ? undefined : value)}
337
339
  />
338
- <ScanNotices result={result} rows={rows} />
340
+ <ScanNotices key={result.scannedAt} result={result} />
339
341
  {result.reason === 'only_daemonsets_without_nodes' ? (
340
342
  <EmptyState
341
343
  variant="card"
@@ -368,31 +370,17 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
368
370
  active={activeFilters}
369
371
  />
370
372
  {filteredRows.length === 0 ? (
371
- <EmptyState
372
- tone="filtered"
373
- headline={
374
- onlySystemRowsAreHidden
375
- ? 'System workloads are hidden'
376
- : 'No results match the current filters'
377
- }
378
- body={
379
- onlySystemRowsAreHidden
380
- ? 'This scope contains only Kubernetes system workloads. Include them to review platform requests.'
381
- : 'Choose another result type or clear a filter.'
382
- }
383
- action={
384
- <button
385
- type="button"
386
- onClick={() =>
387
- onlySystemRowsAreHidden ? setFilter('rfScope', 'all') : clearFilters()
388
- }
389
- className="badge badge-sm border border-theme-border bg-theme-elevated text-theme-text-primary"
390
- >
391
- {onlySystemRowsAreHidden
392
- ? 'Include system workloads'
393
- : 'Show recommended actions'}
394
- </button>
395
- }
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')}
396
384
  />
397
385
  ) : (
398
386
  <div>
@@ -437,6 +425,77 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
437
425
  )
438
426
  }
439
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
+
440
499
  function FirstRunState({ namespaces, onRun }: { namespaces: string[]; onRun: () => void }) {
441
500
  const scope =
442
501
  namespaces.length === 0
@@ -466,21 +525,28 @@ function FirstRunState({ namespaces, onRun }: { namespaces: string[]; onRun: ()
466
525
  )
467
526
  }
468
527
 
469
- function ScanSummary({
528
+ export function ScanSummary({
529
+ namespaces,
470
530
  result,
471
531
  counts,
472
532
  selected,
473
533
  onSelect,
474
534
  }: {
535
+ namespaces: string[]
475
536
  result: ScanResult
476
537
  counts: ReturnType<typeof scanClassCounts>
477
538
  selected: ClassFilter
478
539
  onSelect: (value: ClassFilter) => void
479
540
  }) {
480
- const evaluated = result.coverage.workloadsEvaluated ?? result.workloads.length
481
- const discovered = result.coverage.workloadsDiscovered ?? result.workloads.length
482
541
  return (
483
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>
484
550
  <div className="grid gap-2 md:grid-cols-3">
485
551
  {(Object.keys(ACTION_META) as Array<keyof typeof ACTION_META>).map((key) => {
486
552
  const meta = ACTION_META[key]
@@ -494,7 +560,7 @@ function ScanSummary({
494
560
  <div className="flex items-center justify-between gap-2">
495
561
  <span className="text-sm font-medium text-theme-text-primary">{meta.label}</span>
496
562
  <Badge severity={meta.severity} size="sm">
497
- {counts[key]} containers
563
+ {pluralize(counts[key], 'container')}
498
564
  </Badge>
499
565
  </div>
500
566
  <p className="mt-1 text-xs text-theme-text-tertiary">{meta.helper}</p>
@@ -504,7 +570,10 @@ function ScanSummary({
504
570
  </div>
505
571
  <div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-theme-text-tertiary">
506
572
  <span>
507
- {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
508
577
  </span>
509
578
  <span className="flex items-center gap-3">
510
579
  <button
@@ -512,14 +581,14 @@ function ScanSummary({
512
581
  onClick={() => onSelect('in_range')}
513
582
  className="hover:text-theme-text-primary"
514
583
  >
515
- {counts.in_range} containers · no meaningful change
584
+ {pluralize(counts.in_range, 'container')} · no meaningful change
516
585
  </button>
517
586
  <button
518
587
  type="button"
519
588
  onClick={() => onSelect('need_data')}
520
589
  className="hover:text-theme-text-primary"
521
590
  >
522
- {counts.need_data} containers · not analyzed
591
+ {pluralize(counts.need_data, 'container')} · needs evidence
523
592
  </button>
524
593
  {selected !== 'actions' && (
525
594
  <button
@@ -536,16 +605,14 @@ function ScanSummary({
536
605
  )
537
606
  }
538
607
 
539
- 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)
540
611
  const notices: string[] = []
541
- if (result.state === 'partial')
542
- notices.push('Some workloads could not be fully analyzed. Completed recommendations are shown.')
543
612
  if ((result.coverage.restrictedKinds?.length ?? 0) > 0)
544
613
  notices.push('Some workload kinds or namespaces were excluded by your Kubernetes access.')
545
614
  if ((result.coverage.unavailableKinds?.length ?? 0) > 0)
546
615
  notices.push('Some workload kinds could not be evaluated with the available ownership data.')
547
- // Distinct from the partial notice above: these workloads were analyzed
548
- // completely, the cache simply never held the other namespaces.
549
616
  if ((result.coverage.partiallyCachedKinds?.length ?? 0) > 0)
550
617
  notices.push(
551
618
  'Radar is caching only some namespaces, so this scan covered a narrower scope than the whole cluster.',
@@ -554,18 +621,60 @@ function ScanNotices({ result, rows }: { result: ScanResult; rows: RightsizingSc
554
621
  // The DaemonSet-only empty state already says this.
555
622
  if (daemonSetsWithoutNodes > 0 && result.reason !== 'only_daemonsets_without_nodes')
556
623
  notices.push(
557
- `${pluralize(daemonSetsWithoutNodes, 'DaemonSet')} run on no node right now and ${daemonSetsWithoutNodes === 1 ? 'is' : 'are'} not listed. Open one from Resources to see recommendations from its retained history.`,
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.`,
558
625
  )
559
- for (const warning of result.warnings ?? []) notices.push(warningMessage(warning.code))
560
- if (rows.length > 0 && rows.every((row) => row.classification === 'need_data'))
561
- notices.push('There is not enough recent history to recommend request changes yet.')
562
- return notices.length > 0 ? (
563
- <div className="flex flex-col gap-2">
564
- {notices.map((text) => (
565
- <Notice key={text} text={text} />
566
- ))}
567
- </div>
568
- ) : null
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
+ )
569
678
  }
570
679
 
571
680
  function Notice({ text, tone }: { text: string; tone?: 'warning' }) {
@@ -598,6 +707,7 @@ function ScanFilters(props: {
598
707
  }) {
599
708
  return (
600
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>
601
711
  <SearchBox
602
712
  value={props.search}
603
713
  onChange={props.onSearch}
@@ -607,12 +717,12 @@ function ScanFilters(props: {
607
717
  className="mr-auto w-60 2xl:w-72"
608
718
  />
609
719
  <SelectMenu
610
- ariaLabel="Filter by namespace"
720
+ ariaLabel="Filter results by namespace"
611
721
  value={props.namespace}
612
722
  onChange={props.onNamespace}
613
- className="w-40 2xl:w-48"
723
+ className="w-52"
614
724
  options={[
615
- { value: '', label: 'All namespaces' },
725
+ { value: '', label: 'All scanned namespaces' },
616
726
  ...props.namespaces.map((value) => ({ value, label: value })),
617
727
  ]}
618
728
  />
@@ -784,7 +894,7 @@ function SignalBadge({ signal }: { signal: ResourceSignal }) {
784
894
  )
785
895
  }
786
896
 
787
- function FitWhy({ label, row }: { label: string; row?: RightsizingRow }) {
897
+ export function FitWhy({ label, row }: { label: string; row?: RightsizingRow }) {
788
898
  if (!row)
789
899
  return (
790
900
  <div>
@@ -809,8 +919,9 @@ function FitWhy({ label, row }: { label: string; row?: RightsizingRow }) {
809
919
  Why this {label.toLowerCase()} guidance
810
920
  </div>
811
921
  <p className="mt-1 text-xs text-theme-text-secondary">
812
- {observation}
813
- {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}`}
814
925
  </p>
815
926
  <EvidenceNote row={row} />
816
927
  </div>
@@ -870,7 +981,7 @@ function primaryEvidenceNote(row: RightsizingRow) {
870
981
  The calculated request would exceed the current limit.
871
982
  </p>
872
983
  )
873
- if (row.fit === 'insufficient_history')
984
+ if (row.fit === 'insufficient_history' && !row.queryError)
874
985
  return (
875
986
  <p className="mt-1 text-xs text-theme-text-tertiary">
876
987
  Wait for more runtime history before changing this request.
@@ -984,14 +1095,15 @@ function unavailableMessage(reason?: string): string {
984
1095
  }
985
1096
 
986
1097
  function warningMessage(code: string): string {
987
- if (code === 'scan_deadline_exceeded')
988
- return 'The scan reached its time limit. Results from completed batches are shown.'
989
1098
  if (code === 'owner_metrics_query_failed') return 'Workload ownership data could not be queried.'
990
1099
  if (code === 'oom_evidence_unavailable')
991
1100
  return 'Radar could not verify restart history for some containers, so it withheld their memory reductions.'
992
1101
  if (code.endsWith('_query_failed'))
993
- return `Some ${code.replace('_query_failed', '').replaceAll('_', ' ')} data could not be queried.`
994
- 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.'
995
1107
  }
996
1108
 
997
1109
  function formatScanTime(value: string): string {
@@ -0,0 +1,67 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { ScanEmptyState } from './RightsizingScanView'
4
+
5
+ const props = {
6
+ counts: { increase: 0, reduction: 0, review: 0, need_data: 0, in_range: 2 },
7
+ classFilter: 'actions' as const,
8
+ hasResultFilters: false,
9
+ onlySystemRowsAreHidden: false,
10
+ hasQueryErrors: false,
11
+ onSelect: () => {},
12
+ onClear: () => {},
13
+ onIncludeSystem: () => {},
14
+ }
15
+
16
+ describe('rightsizing empty journeys', () => {
17
+ it('affirms no actionable changes only for evaluated evidence, with an inspection action', () => {
18
+ const html = renderToStaticMarkup(<ScanEmptyState {...props} />)
19
+ expect(html).toContain('No actionable changes')
20
+ expect(html).toContain('2 evaluated containers')
21
+ expect(html).toContain('outside the scan coverage')
22
+ expect(html).toContain('View evaluated containers')
23
+ expect(html).not.toContain('Show recommended actions')
24
+ })
25
+
26
+ it.each([false, true])(
27
+ 'explains unknown evidence (query errors: %s) without claiming health',
28
+ (hasQueryErrors) => {
29
+ const html = renderToStaticMarkup(
30
+ <ScanEmptyState
31
+ {...props}
32
+ counts={{ ...props.counts, in_range: 0, need_data: 2 }}
33
+ hasQueryErrors={hasQueryErrors}
34
+ />,
35
+ )
36
+ expect(html).toContain('Not enough evidence for recommendations')
37
+ expect(html).toContain('View containers needing evidence')
38
+ expect(html).toContain(
39
+ hasQueryErrors ? 'Some metrics queries failed' : 'not enough usable CPU and memory history',
40
+ )
41
+ expect(html).not.toContain('No actionable changes')
42
+ },
43
+ )
44
+
45
+ it('qualifies a mixture of in-range and unknown containers', () => {
46
+ const html = renderToStaticMarkup(
47
+ <ScanEmptyState {...props} counts={{ ...props.counts, need_data: 1 }} />,
48
+ )
49
+ expect(html).toContain('No actionable changes in available evidence')
50
+ expect(html).toContain('not a complete assessment')
51
+ expect(html).toContain('View containers needing evidence')
52
+ })
53
+
54
+ it('does not blame missing history when a search or category filter hides results', () => {
55
+ for (const override of [{ hasResultFilters: true }, { classFilter: 'increase' as const }]) {
56
+ const html = renderToStaticMarkup(<ScanEmptyState {...props} {...override} />)
57
+ expect(html).toContain('No results match the current filters')
58
+ expect(html).toContain('Clear filters')
59
+ }
60
+ })
61
+
62
+ it('retains the system-workload recovery action', () => {
63
+ const html = renderToStaticMarkup(<ScanEmptyState {...props} onlySystemRowsAreHidden />)
64
+ expect(html).toContain('System workloads are hidden')
65
+ expect(html).toContain('Include system workloads')
66
+ })
67
+ })
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import type { RightsizingScanResponse, RightsizingRow } from '../../api/client'
3
- import { calculateImpact, classifyRows, flattenScanResults, scanClassCounts } from './model'
3
+ import {
4
+ calculateImpact,
5
+ classifyRows,
6
+ flattenScanResults,
7
+ isActionableClass,
8
+ scanClassCounts,
9
+ } from './model'
4
10
 
5
11
  function metric(overrides: Partial<RightsizingRow> = {}): RightsizingRow {
6
12
  return {
@@ -152,6 +158,77 @@ describe('rightsizing scan model', () => {
152
158
  expect(classifyRows([metric({ queryError: 'query failed' })])).toBe('need_data')
153
159
  })
154
160
 
161
+ describe.each([
162
+ ['failed query', { queryError: 'usage query failed' }],
163
+ ['short history', { fit: 'insufficient_history' as const }],
164
+ ])('with %s on one resource', (_label, missing) => {
165
+ it.each([
166
+ [
167
+ 'increase',
168
+ {
169
+ fit: 'under_requested' as const,
170
+ recommendedRequest: '1Gi',
171
+ recommendedRequestValue: 1024 ** 3,
172
+ },
173
+ ],
174
+ ['review', { currentPodOOM: true }],
175
+ ['review', { hpaManaged: true, recommendationReason: 'hpa_managed' }],
176
+ [
177
+ 'reduction',
178
+ {
179
+ fit: 'oversized' as const,
180
+ currentRequestValue: 1024 ** 3,
181
+ recommendedRequest: '512Mi',
182
+ recommendedRequestValue: 512 * 1024 ** 2,
183
+ },
184
+ ],
185
+ ['need_data', {}],
186
+ ])('retains %s from the other resource', (classification, known) => {
187
+ const cpu = metric(missing)
188
+ const memory = metric({ resource: 'memory', ...known })
189
+ const rows = flattenScanResults(response([cpu, memory]))
190
+ expect(rows).toHaveLength(1)
191
+ expect(rows[0].classification).toBe(classification)
192
+ expect(rows[0].cpu).toBe(cpu)
193
+ expect(rows[0].memory).toBe(memory)
194
+ expect(scanClassCounts(rows)[rows[0].classification]).toBe(1)
195
+ const actions = rows.filter((row) => isActionableClass(row.classification))
196
+ expect(actions).toHaveLength(classification === 'need_data' ? 0 : 1)
197
+ })
198
+ })
199
+
200
+ it('retains a known CPU increase when memory has no evidence', () => {
201
+ const cpu = metric({
202
+ fit: 'under_requested',
203
+ recommendedRequest: '200m',
204
+ recommendedRequestValue: 0.2,
205
+ currentRequestValue: 0.1,
206
+ })
207
+ const memory = metric({ resource: 'memory', fit: 'insufficient_history' })
208
+ const [row] = flattenScanResults(response([cpu, memory], 3))
209
+ expect(row.classification).toBe('increase')
210
+ expect(row.impact.cpuChange).toBeCloseTo(0.3)
211
+ expect(row.impact.memoryChange).toBe(0)
212
+ expect(row.memory).toBe(memory)
213
+ })
214
+
215
+ it('keeps empty and entirely unevidenced containers in need_data', () => {
216
+ expect(classifyRows([])).toBe('need_data')
217
+ expect(
218
+ classifyRows([
219
+ metric({ queryError: 'usage query failed' }),
220
+ metric({ resource: 'memory', fit: 'insufficient_history' }),
221
+ ]),
222
+ ).toBe('need_data')
223
+ expect(
224
+ classifyRows(
225
+ [metric({ fit: 'insufficient_history' }), metric({ resource: 'memory' })],
226
+ 0,
227
+ true,
228
+ ),
229
+ ).toBe('review')
230
+ })
231
+
155
232
  it('keeps workloads with no current replicas in review', () => {
156
233
  const oversized = flattenScanResults(
157
234
  response(
@@ -225,3 +302,43 @@ describe('rightsizing scan model', () => {
225
302
  expect(scanClassCounts(rows).increase).toBe(1)
226
303
  })
227
304
  })
305
+
306
+ it('ranks known safety risks and increases above savings, while routine reviews stay lower', () => {
307
+ const make = (name: string, rows: RightsizingRow[], scaledToZero = false) => ({
308
+ ...response(rows, scaledToZero ? 0 : 1, 'shop', scaledToZero).workloads[0],
309
+ name,
310
+ })
311
+ const cut = metric({
312
+ fit: 'oversized',
313
+ currentRequestValue: 10,
314
+ recommendedRequestValue: 1,
315
+ recommendedRequest: '1',
316
+ })
317
+ const grow = metric({
318
+ fit: 'under_requested',
319
+ currentRequestValue: 0.1,
320
+ recommendedRequestValue: 0.2,
321
+ recommendedRequest: '200m',
322
+ })
323
+ const risk = metric({ resource: 'memory', currentPodOOM: true })
324
+ const workloads = [
325
+ make('cut', [cut]),
326
+ make('grow', [grow]),
327
+ make('hpa', [metric({ hpaManaged: true })]),
328
+ make('oom', [risk]),
329
+ make('limit', [metric({ limitConflict: true })]),
330
+ make('bursty', [{ ...cut, bursty: true }]),
331
+ make('throttled', [{ ...cut, throttleRatio: 0.2 }]),
332
+ make('idle', [risk], true),
333
+ make('failed-risk', [metric({ queryError: 'failed', currentPodOOM: true })]),
334
+ make('partial-grow', [grow, metric({ resource: 'memory', queryError: 'failed' })]),
335
+ make('steady', [metric()]),
336
+ ]
337
+ const rows = flattenScanResults({ ...response([]), workloads })
338
+ const names = rows.map((row) => row.name)
339
+ expect(new Set(names.slice(0, 3))).toEqual(new Set(['oom', 'limit', 'throttled']))
340
+ expect(names.slice(3, 5)).toEqual(['grow', 'partial-grow'])
341
+ expect(names[5]).toBe('cut')
342
+ expect(names.slice(6, 9)).toEqual(['bursty', 'hpa', 'idle'])
343
+ expect(names.slice(9)).toEqual(['failed-risk', 'steady'])
344
+ })