@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.
@@ -24,11 +24,11 @@ export interface RightsizingScanRow {
24
24
  }
25
25
 
26
26
  const CLASS_RANK: Record<ScanClass, number> = {
27
- reduction: 0,
28
27
  increase: 1,
29
- review: 2,
30
- need_data: 3,
31
- in_range: 4,
28
+ reduction: 2,
29
+ review: 3,
30
+ need_data: 4,
31
+ in_range: 5,
32
32
  }
33
33
 
34
34
  const MIN_CPU_REDUCTION = 0.05
@@ -39,32 +39,55 @@ export function classifyRows(
39
39
  replicas = 1,
40
40
  scaledToZero = false,
41
41
  ): ScanClass {
42
- if (rows.some((row) => row.queryError || row.fit === 'insufficient_history')) return 'need_data'
42
+ const evidenced = rows.filter((row) => !row.queryError && row.fit !== 'insufficient_history')
43
+ if (evidenced.length === 0) return 'need_data'
43
44
  if (scaledToZero) return 'review'
44
45
  if (
45
- rows.some(
46
+ evidenced.some(
46
47
  (row) =>
47
48
  (row.fit === 'under_requested' || row.fit === 'missing_request') && row.recommendedRequest,
48
49
  )
49
50
  )
50
51
  return 'increase'
51
- if (rows.some(needsManualReview)) return 'review'
52
- const impact = calculateImpact(rows, replicas)
52
+ if (evidenced.some(needsManualReview)) return 'review'
53
+ const impact = calculateImpact(evidenced, replicas)
53
54
  if (-impact.cpuChange >= MIN_CPU_REDUCTION || -impact.memoryChange >= MIN_MEMORY_REDUCTION)
54
55
  return 'reduction'
55
- return 'in_range'
56
+ return evidenced.length === rows.length ? 'in_range' : 'need_data'
56
57
  }
57
58
 
58
59
  function needsManualReview(row: RightsizingRow): boolean {
59
60
  return (
60
61
  row.hpaManaged ||
62
+ needsSafetyReview(row) ||
63
+ (isReduction(row) && row.bursty === true) ||
64
+ row.recommendationReason === 'hpa_evidence_unavailable' ||
65
+ row.recommendationReason === 'oom_evidence_unavailable'
66
+ )
67
+ }
68
+
69
+ function needsSafetyReview(row: RightsizingRow): boolean {
70
+ return (
61
71
  row.currentPodOOM === true ||
62
72
  row.windowOomEvidence === true ||
63
73
  row.limitConflict === true ||
64
- row.recommendationReason === 'hpa_evidence_unavailable' ||
65
- row.recommendationReason === 'oom_evidence_unavailable' ||
66
- (isReduction(row) && (row.bursty || (row.throttleRatio ?? 0) >= 0.1))
74
+ (isReduction(row) && (row.throttleRatio ?? 0) >= 0.1)
75
+ )
76
+ }
77
+
78
+ function scanPriority(row: RightsizingScanRow): number {
79
+ if (
80
+ !row.scaledToZero &&
81
+ [row.cpu, row.memory].some(
82
+ (resource) =>
83
+ resource &&
84
+ !resource.queryError &&
85
+ resource.fit !== 'insufficient_history' &&
86
+ needsSafetyReview(resource),
87
+ )
67
88
  )
89
+ return 0
90
+ return CLASS_RANK[row.classification]
68
91
  }
69
92
 
70
93
  function isReduction(row: RightsizingRow): boolean {
@@ -117,7 +140,7 @@ export function flattenScanResults(scan: RightsizingScanResponse): RightsizingSc
117
140
  }
118
141
  return result.sort(
119
142
  (a, b) =>
120
- CLASS_RANK[a.classification] - CLASS_RANK[b.classification] ||
143
+ scanPriority(a) - scanPriority(b) ||
121
144
  impactScore(b) - impactScore(a) ||
122
145
  a.namespace.localeCompare(b.namespace) ||
123
146
  a.name.localeCompare(b.name) ||
@@ -0,0 +1,175 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { RightsizingRow, RightsizingScanResponse } from '../../api/client'
4
+ import { FitWhy, ScanNotices, ScanSummary } from './RightsizingScanView'
5
+
6
+ const scan: RightsizingScanResponse = {
7
+ state: 'complete',
8
+ scannedAt: '2026-09-19T06:52:54Z',
9
+ window: '7d',
10
+ source: 'radar',
11
+ coverage: {
12
+ workloadsDiscovered: 120,
13
+ workloadsEvaluated: 120,
14
+ workloadsWithData: 47,
15
+ batches: 3,
16
+ completedBatches: 3,
17
+ },
18
+ workloads: [],
19
+ }
20
+
21
+ function render(result: RightsizingScanResponse) {
22
+ return renderToStaticMarkup(<ScanNotices result={result} />)
23
+ }
24
+
25
+ describe('rightsizing scan notices', () => {
26
+ it('keeps timeout coverage visible with query failures behind one collapsed disclosure', () => {
27
+ const html = render({
28
+ ...scan,
29
+ state: 'partial',
30
+ coverage: {
31
+ ...scan.coverage,
32
+ workloadsEvaluated: 100,
33
+ completedBatches: 0,
34
+ daemonSetsWithoutNodes: 27,
35
+ },
36
+ warnings: ['cpu_query_failed', 'memory_query_failed', 'scan_deadline_exceeded'].map(
37
+ (code) => ({ code, message: 'backend-specific details' }),
38
+ ),
39
+ })
40
+ expect(html.match(/<section/g)).toHaveLength(1)
41
+ expect(html.match(/<button/g)).toHaveLength(1)
42
+ expect(html).toContain('Partial results')
43
+ expect(html).not.toContain('workloads evaluated')
44
+ expect(html).toContain('select namespaces in the top bar')
45
+ expect(html).toContain('Available recommendations are shown')
46
+ expect(html).toContain('aria-expanded="false"')
47
+ expect(html).toContain('inert=""')
48
+ expect(html).toContain('Some CPU data could not be queried')
49
+ expect(html).toContain('Some memory data could not be queried')
50
+ expect(html).toContain('27 DaemonSets')
51
+ expect(html).not.toContain('completed batches')
52
+ expect(html).not.toContain('backend-specific details')
53
+ })
54
+
55
+ it('does not label a complete scan with excluded DaemonSets as a query failure', () => {
56
+ const html = render({
57
+ ...scan,
58
+ coverage: { ...scan.coverage, daemonSetsWithoutNodes: 1 },
59
+ })
60
+ expect(html).toContain('Scan notes')
61
+ expect(html).toContain('1 DaemonSet runs on no node right now and is not listed')
62
+ expect(html).not.toContain('<button')
63
+ expect(html).not.toContain('Partial results')
64
+ expect(html).not.toContain('time limit')
65
+ })
66
+
67
+ it('preserves access and cache limitations without claiming a timeout', () => {
68
+ const html = render({
69
+ ...scan,
70
+ state: 'partial',
71
+ coverage: {
72
+ ...scan.coverage,
73
+ restrictedKinds: ['Deployment'],
74
+ partiallyCachedKinds: ['StatefulSet'],
75
+ unavailableKinds: ['DaemonSet'],
76
+ },
77
+ })
78
+ expect(html).toContain('Kubernetes access')
79
+ expect(html).toContain('available ownership data')
80
+ expect(html).toContain('caching only some namespaces')
81
+ expect(html).not.toContain('time limit')
82
+ })
83
+
84
+ it('does not infer a timeout or short history from a query failure', () => {
85
+ const html = render({
86
+ ...scan,
87
+ state: 'partial',
88
+ warnings: [{ code: 'cpu_query_failed', message: 'unknown error' }],
89
+ })
90
+ expect(html).toContain('Some CPU data could not be queried')
91
+ expect(html).not.toContain('time limit')
92
+ expect(html).not.toContain('not enough recent history')
93
+ })
94
+
95
+ it('shows no notice for a clean scan and no empty Details button for deadline-only partial results', () => {
96
+ expect(render(scan)).toBe('')
97
+ const html = render({
98
+ ...scan,
99
+ state: 'partial',
100
+ warnings: [{ code: 'scan_deadline_exceeded', message: 'deadline' }],
101
+ })
102
+ expect(html).toContain('time limit')
103
+ expect(html).not.toContain('<button')
104
+ })
105
+ })
106
+
107
+ describe('rightsizing evidence explanation', () => {
108
+ const row: RightsizingRow = {
109
+ container: 'app',
110
+ resource: 'cpu',
111
+ fit: 'insufficient_history',
112
+ confidence: 'low',
113
+ coverage: 0,
114
+ sampleCount: 0,
115
+ expectedSamples: 2017,
116
+ hpaManaged: false,
117
+ hpaEvidenceAvailable: true,
118
+ oomEvidenceAvailable: false,
119
+ }
120
+
121
+ it('distinguishes failed queries from successfully measured short history', () => {
122
+ const failed = renderToStaticMarkup(
123
+ <FitWhy label="CPU" row={{ ...row, queryError: 'usage query failed' }} />,
124
+ )
125
+ expect(failed).toContain('Metrics could not be queried')
126
+ expect(failed).not.toContain('0.0 days')
127
+ expect(failed).not.toContain('Wait for more runtime history')
128
+ const short = renderToStaticMarkup(<FitWhy label="CPU" row={row} />)
129
+ expect(short).toContain('0.0 days')
130
+ expect(short).toContain('Wait for more runtime history')
131
+ })
132
+ })
133
+
134
+ describe('rightsizing scope and coverage summary', () => {
135
+ const counts = {
136
+ increase: 1,
137
+ reduction: 4,
138
+ review: 0,
139
+ in_range: 2,
140
+ need_data: 3,
141
+ }
142
+ it('separates attempted workloads from usage evidence and container counts', () => {
143
+ const html = renderToStaticMarkup(
144
+ <ScanSummary
145
+ namespaces={['dev', 'staging']}
146
+ result={{
147
+ ...scan,
148
+ coverage: { ...scan.coverage, workloadsEvaluated: 100 },
149
+ }}
150
+ counts={counts}
151
+ selected="actions"
152
+ onSelect={() => {}}
153
+ />,
154
+ )
155
+ expect(html).toContain('dev, staging')
156
+ expect(html).toContain('Change scan scope in the top bar')
157
+ expect(html).toContain('100 of 120 workloads attempted')
158
+ expect(html).toContain('47 with usage data')
159
+ expect(html).toContain('7d window')
160
+ expect(html).toContain('1 container<')
161
+ expect(html).not.toContain('workloads evaluated')
162
+ })
163
+ it('qualifies the all-namespace scope by visibility', () => {
164
+ const html = renderToStaticMarkup(
165
+ <ScanSummary
166
+ namespaces={[]}
167
+ result={scan}
168
+ counts={counts}
169
+ selected="actions"
170
+ onSelect={() => {}}
171
+ />,
172
+ )
173
+ expect(html).toContain('All visible namespaces')
174
+ })
175
+ })
@@ -13,9 +13,10 @@ interface MarkdownProps {
13
13
  * reference, say) into a control instead of an external link.
14
14
  */
15
15
  linkRenderer?: (href: string | undefined, children: ReactNode) => ReactNode | null
16
+ codeActions?: (code: string) => ReactNode
16
17
  }
17
18
 
18
- export function Markdown({ children, className, linkRenderer }: MarkdownProps) {
19
+ export function Markdown({ children, className, linkRenderer, codeActions }: MarkdownProps) {
19
20
  return (
20
21
  <div className={clsx('markdown-content', className)}>
21
22
  <ReactMarkdown
@@ -62,6 +63,15 @@ export function Markdown({ children, className, linkRenderer }: MarkdownProps) {
62
63
  code: ({ className, children }) => {
63
64
  const isInline = !className
64
65
  if (isInline) {
66
+ const actions = codeActions?.(String(children))
67
+ if (actions) {
68
+ return (
69
+ <span className="inline-flex max-w-full items-start gap-1 align-middle">
70
+ <code className="inline-code min-w-0">{children}</code>
71
+ {actions}
72
+ </span>
73
+ )
74
+ }
65
75
  return (
66
76
  <code className="inline-code">
67
77
  {children}
@@ -72,11 +82,28 @@ export function Markdown({ children, className, linkRenderer }: MarkdownProps) {
72
82
  <code className="block font-mono text-theme-text-secondary">{children}</code>
73
83
  )
74
84
  },
75
- pre: ({ children }) => (
76
- <pre className="my-3 p-3 bg-theme-elevated rounded-lg overflow-x-auto text-xs font-mono text-theme-text-secondary">
77
- {children}
78
- </pre>
79
- ),
85
+ pre: ({ children, node }) => {
86
+ const code = node?.children[0]
87
+ const text = code?.type === 'element'
88
+ ? code.children.filter(child => child.type === 'text').map(child => child.value).join('')
89
+ : ''
90
+ const actions = codeActions?.(text)
91
+ if (actions) {
92
+ // Render the code directly so an unlabelled fence does not also
93
+ // receive the inline-code action. Only the code scrolls, not Copy.
94
+ return (
95
+ <pre className="my-3 flex items-start gap-2 rounded-lg bg-theme-elevated p-3 text-xs font-mono text-theme-text-secondary">
96
+ <code className="min-w-0 flex-1 overflow-x-auto">{text}</code>
97
+ {actions}
98
+ </pre>
99
+ )
100
+ }
101
+ return (
102
+ <pre className="my-3 p-3 bg-theme-elevated rounded-lg overflow-x-auto text-xs font-mono text-theme-text-secondary">
103
+ {children}
104
+ </pre>
105
+ )
106
+ },
80
107
  blockquote: ({ children }) => (
81
108
  <blockquote className="border-l-2 border-theme-border pl-3 my-2 text-theme-text-tertiary italic">
82
109
  {children}