@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.
- package/package.json +1 -1
- package/src/components/diagnose/AIMarkdown.tsx +8 -3
- package/src/components/diagnose/AssessmentCard.tsx +249 -237
- 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 +4 -1
- package/src/components/diagnose/investigationCase.test.tsx +56 -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 +8 -5
- package/src/components/diagnose/investigationEvidence/types.ts +1 -1
- package/src/components/diagnose/parts.test.tsx +141 -2
- package/src/components/rightsizing/RightsizingScanView.tsx +177 -65
- 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
|
@@ -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
|
|
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
|
-
<
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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]}
|
|
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
|
-
{
|
|
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}
|
|
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}
|
|
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
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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-
|
|
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
|
-
{
|
|
813
|
-
|
|
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
|
|
994
|
-
|
|
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 {
|
|
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
|
+
})
|