@skyhook-io/radar-app 1.14.4 → 1.14.6
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/README.md +2 -9
- package/package.json +6 -6
- package/src/App.tsx +91 -328
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +233 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +14 -8
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- package/src/components/diagnose/AIMarkdown.tsx +8 -3
- package/src/components/diagnose/AgentControls.tsx +26 -3
- package/src/components/diagnose/AssessmentCard.tsx +249 -237
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- 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/agentCatalog.ts +6 -0
- 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/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +159 -2
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +42 -0
- package/src/components/execution/JobSetAdmission.tsx +17 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.tsx +2 -5
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +207 -83
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/empty-state.test.tsx +67 -0
- package/src/components/rightsizing/model.test.ts +120 -2
- package/src/components/rightsizing/model.ts +36 -13
- package/src/components/rightsizing/notices.test.tsx +176 -0
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/SettingsDialog.tsx +157 -42
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +18 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/Markdown.tsx +33 -6
- package/src/components/ui/command-items.ts +0 -3
- package/src/components/useContextSwitchFlow.tsx +147 -0
- package/src/components/workload/WorkloadView.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +36 -18
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +18 -1
- package/src/utils/navigation.ts +12 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/components/ui/CommandPalette.tsx +0 -261
|
@@ -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 {
|
|
@@ -47,6 +48,7 @@ export const RIGHTSIZING_EMBEDDED_METRICS_REQUIRED_BODY =
|
|
|
47
48
|
`${RIGHTSIZING_METRICS_REQUIRED_BODY}\nConfigure metrics for this cluster in the host application or Radar deployment.`
|
|
48
49
|
|
|
49
50
|
export type RightsizingScanSurfaceState =
|
|
51
|
+
| 'loading_scan'
|
|
50
52
|
| 'discovering'
|
|
51
53
|
| 'prometheus_required'
|
|
52
54
|
| 'first_run'
|
|
@@ -56,6 +58,7 @@ export type RightsizingScanSurfaceState =
|
|
|
56
58
|
| 'results'
|
|
57
59
|
|
|
58
60
|
export function getRightsizingScanSurfaceState(input: {
|
|
61
|
+
scanLoading?: boolean
|
|
59
62
|
statusLoading: boolean
|
|
60
63
|
hasStatus: boolean
|
|
61
64
|
connected: boolean
|
|
@@ -66,6 +69,7 @@ export function getRightsizingScanSurfaceState(input: {
|
|
|
66
69
|
}): RightsizingScanSurfaceState {
|
|
67
70
|
if (input.statusLoading && !input.hasStatus) return 'discovering'
|
|
68
71
|
if (!input.connected) return 'prometheus_required'
|
|
72
|
+
if (input.scanLoading && !input.hasResult) return 'loading_scan'
|
|
69
73
|
if (input.pending && !input.hasResult) return 'scanning'
|
|
70
74
|
if (input.hasError && !input.hasResult) return 'fatal_error'
|
|
71
75
|
if (!input.hasResult) return 'first_run'
|
|
@@ -85,11 +89,6 @@ const ACTION_META: Record<
|
|
|
85
89
|
'increase' | 'reduction' | 'review',
|
|
86
90
|
{ label: string; severity: 'warning' | 'info' | 'neutral'; helper: string }
|
|
87
91
|
> = {
|
|
88
|
-
reduction: {
|
|
89
|
-
label: 'Reduce requests',
|
|
90
|
-
severity: RIGHTSIZING_ACTION_SEVERITY.reduction,
|
|
91
|
-
helper: 'Reclaim meaningful capacity',
|
|
92
|
-
},
|
|
93
92
|
increase: {
|
|
94
93
|
label: 'Increase or add',
|
|
95
94
|
severity: RIGHTSIZING_ACTION_SEVERITY.increase,
|
|
@@ -100,6 +99,11 @@ const ACTION_META: Record<
|
|
|
100
99
|
severity: RIGHTSIZING_ACTION_SEVERITY.review,
|
|
101
100
|
helper: 'Check safety signals or workloads with no replicas',
|
|
102
101
|
},
|
|
102
|
+
reduction: {
|
|
103
|
+
label: 'Reduce requests',
|
|
104
|
+
severity: RIGHTSIZING_ACTION_SEVERITY.reduction,
|
|
105
|
+
helper: 'Reclaim meaningful capacity',
|
|
106
|
+
},
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
@@ -184,6 +188,7 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
|
184
188
|
const onlySystemRowsAreHidden = !includeSystem && rows.length > 0 && scopeRows.length === 0
|
|
185
189
|
const surfaceState = getRightsizingScanSurfaceState({
|
|
186
190
|
statusLoading,
|
|
191
|
+
scanLoading: scan.isLoading,
|
|
187
192
|
hasStatus: Boolean(promStatus),
|
|
188
193
|
connected: promStatus?.connected === true,
|
|
189
194
|
pending: scan.isPending,
|
|
@@ -231,12 +236,12 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
|
231
236
|
{result && (
|
|
232
237
|
<button
|
|
233
238
|
type="button"
|
|
234
|
-
onClick={runScan}
|
|
235
|
-
disabled={scan.
|
|
239
|
+
onClick={scan.isPending ? scan.stop : runScan}
|
|
240
|
+
disabled={scan.isStarting || scan.isStopping}
|
|
236
241
|
className="btn-brand inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium"
|
|
237
242
|
>
|
|
238
243
|
<RefreshCw className={`h-3.5 w-3.5 ${scan.isPending ? 'animate-spin' : ''}`} />
|
|
239
|
-
{scan.
|
|
244
|
+
{scan.isStarting ? 'Starting…' : scan.isStopping ? 'Stopping…' : scan.isPending ? 'Stop scan' : 'Run again'}
|
|
240
245
|
</button>
|
|
241
246
|
)}
|
|
242
247
|
</div>
|
|
@@ -279,25 +284,27 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
|
279
284
|
</div>
|
|
280
285
|
}
|
|
281
286
|
/>
|
|
287
|
+
) : surfaceState === 'loading_scan' ? (
|
|
288
|
+
<CenteredState loading title="Loading scan…" body="Checking for an existing scan in this cluster and namespace scope." />
|
|
282
289
|
) : surfaceState === 'first_run' ? (
|
|
283
290
|
<FirstRunState namespaces={namespaces} onRun={runScan} />
|
|
284
291
|
) : surfaceState === 'scanning' ? (
|
|
285
292
|
<CenteredState
|
|
286
293
|
loading
|
|
287
294
|
title="Analyzing CPU and memory requests…"
|
|
288
|
-
body="Comparing 7 days of CPU and memory usage with configured requests.
|
|
295
|
+
body="Comparing 7 days of CPU and memory usage with configured requests. Results appear as batches finish; scans run for up to three minutes."
|
|
289
296
|
/>
|
|
290
297
|
) : surfaceState === 'fatal_error' ? (
|
|
291
298
|
<CenteredState
|
|
292
|
-
title="Rightsizing scan failed"
|
|
299
|
+
title={scan.statusError ? "Could not retrieve scan status" : "Rightsizing scan failed"}
|
|
293
300
|
body={errorMessage(scan.error)}
|
|
294
301
|
action={
|
|
295
302
|
<button
|
|
296
303
|
type="button"
|
|
297
|
-
onClick={runScan}
|
|
304
|
+
onClick={scan.statusError ? () => void scan.retryStatus() : runScan}
|
|
298
305
|
className="btn-brand px-3 py-1.5 text-xs font-medium"
|
|
299
306
|
>
|
|
300
|
-
Try again
|
|
307
|
+
{scan.statusError ? 'Retry status' : 'Try again'}
|
|
301
308
|
</button>
|
|
302
309
|
}
|
|
303
310
|
/>
|
|
@@ -309,7 +316,6 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
|
309
316
|
<button
|
|
310
317
|
type="button"
|
|
311
318
|
onClick={runScan}
|
|
312
|
-
disabled={scan.isPending}
|
|
313
319
|
className="btn-brand px-3 py-1.5 text-xs font-medium"
|
|
314
320
|
>
|
|
315
321
|
Try again
|
|
@@ -318,25 +324,28 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
|
318
324
|
/>
|
|
319
325
|
) : result ? (
|
|
320
326
|
<div
|
|
321
|
-
className=
|
|
327
|
+
className="flex flex-col gap-4"
|
|
322
328
|
>
|
|
323
329
|
{scan.error && (
|
|
324
|
-
<
|
|
325
|
-
tone="warning"
|
|
326
|
-
|
|
327
|
-
|
|
330
|
+
<div className="flex items-center gap-3">
|
|
331
|
+
<Notice tone="warning" text={`${scan.statusError ? 'Could not retrieve scan status. The scan may still be running.' : 'Could not start or stop the scan.'} ${errorMessage(scan.error)}`} />
|
|
332
|
+
<button type="button" className="btn-brand-muted shrink-0 px-3 py-1.5 text-xs" onClick={() => void scan.retryStatus()}>Retry status</button>
|
|
333
|
+
</div>
|
|
328
334
|
)}
|
|
329
335
|
{scan.isPending && (
|
|
330
|
-
<Notice text=
|
|
336
|
+
<Notice loading text={`${scan.progress?.coverage.workloadsDiscovered ? `Scanning — ${scan.progress.coverage.workloadsEvaluated} of ${scan.progress.coverage.workloadsDiscovered} workloads attempted.` : 'Preparing scan…'} Results and rankings update as batches finish. You can leave this page and return.${scan.showingPrevious ? ' Showing previous results until the first batch is ready.' : ''}`} />
|
|
331
337
|
)}
|
|
332
338
|
<ScanSummary
|
|
339
|
+
namespaces={namespaces}
|
|
333
340
|
result={result}
|
|
334
341
|
counts={counts}
|
|
335
342
|
selected={classFilter}
|
|
336
343
|
onSelect={(value) => setFilter('rfClass', value === 'actions' ? undefined : value)}
|
|
337
344
|
/>
|
|
338
|
-
<ScanNotices
|
|
339
|
-
{
|
|
345
|
+
{!scan.isPending && <ScanNotices key={result.scannedAt} result={result} />}
|
|
346
|
+
{scan.isPending && rows.length === 0 ? (
|
|
347
|
+
<CenteredState loading title="Preparing recommendations…" body="The scan is running. Recommendations appear after each batch finishes." />
|
|
348
|
+
) : result.reason === 'only_daemonsets_without_nodes' ? (
|
|
340
349
|
<EmptyState
|
|
341
350
|
variant="card"
|
|
342
351
|
headline="Only DaemonSets with no nodes in this scope"
|
|
@@ -367,32 +376,20 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
|
367
376
|
onClear={clearFilters}
|
|
368
377
|
active={activeFilters}
|
|
369
378
|
/>
|
|
370
|
-
{filteredRows.length === 0 ? (
|
|
371
|
-
<EmptyState
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
383
|
-
|
|
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
|
-
}
|
|
379
|
+
{filteredRows.length === 0 && scan.isPending ? (
|
|
380
|
+
<EmptyState variant="card" headline="No matching results yet" body="The scan is still running. More workloads may match these filters as batches finish." />
|
|
381
|
+
) : filteredRows.length === 0 ? (
|
|
382
|
+
<ScanEmptyState
|
|
383
|
+
counts={counts}
|
|
384
|
+
classFilter={classFilter}
|
|
385
|
+
hasResultFilters={Boolean(search || kindFilter || namespaceFilter)}
|
|
386
|
+
onlySystemRowsAreHidden={onlySystemRowsAreHidden}
|
|
387
|
+
hasQueryErrors={scopeRows.some(
|
|
388
|
+
(row) => row.cpu?.queryError || row.memory?.queryError,
|
|
389
|
+
)}
|
|
390
|
+
onSelect={(value) => setFilter('rfClass', value)}
|
|
391
|
+
onClear={clearFilters}
|
|
392
|
+
onIncludeSystem={() => setFilter('rfScope', 'all')}
|
|
396
393
|
/>
|
|
397
394
|
) : (
|
|
398
395
|
<div>
|
|
@@ -437,6 +434,77 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
|
437
434
|
)
|
|
438
435
|
}
|
|
439
436
|
|
|
437
|
+
export function ScanEmptyState({
|
|
438
|
+
counts,
|
|
439
|
+
classFilter,
|
|
440
|
+
hasResultFilters,
|
|
441
|
+
onlySystemRowsAreHidden,
|
|
442
|
+
hasQueryErrors,
|
|
443
|
+
onSelect,
|
|
444
|
+
onClear,
|
|
445
|
+
onIncludeSystem,
|
|
446
|
+
}: {
|
|
447
|
+
counts: ReturnType<typeof scanClassCounts>
|
|
448
|
+
classFilter: ClassFilter
|
|
449
|
+
hasResultFilters: boolean
|
|
450
|
+
onlySystemRowsAreHidden: boolean
|
|
451
|
+
hasQueryErrors: boolean
|
|
452
|
+
onSelect: (value: ScanClass) => void
|
|
453
|
+
onClear: () => void
|
|
454
|
+
onIncludeSystem: () => void
|
|
455
|
+
}) {
|
|
456
|
+
let headline = 'No results match the current filters'
|
|
457
|
+
let body = 'Choose another result type or clear a filter.'
|
|
458
|
+
let actionLabel = 'Clear filters'
|
|
459
|
+
let onAction = onClear
|
|
460
|
+
if (onlySystemRowsAreHidden) {
|
|
461
|
+
headline = 'System workloads are hidden'
|
|
462
|
+
body =
|
|
463
|
+
'This scope contains only Kubernetes system workloads. Include them to review platform requests.'
|
|
464
|
+
actionLabel = 'Include system workloads'
|
|
465
|
+
onAction = onIncludeSystem
|
|
466
|
+
} else if (
|
|
467
|
+
classFilter === 'actions' &&
|
|
468
|
+
!hasResultFilters &&
|
|
469
|
+
counts.increase + counts.reduction + counts.review === 0
|
|
470
|
+
) {
|
|
471
|
+
if (counts.need_data > 0) {
|
|
472
|
+
headline =
|
|
473
|
+
counts.in_range > 0
|
|
474
|
+
? 'No actionable changes in available evidence'
|
|
475
|
+
: 'Not enough evidence for recommendations'
|
|
476
|
+
body =
|
|
477
|
+
counts.in_range > 0
|
|
478
|
+
? `${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.`
|
|
479
|
+
: hasQueryErrors
|
|
480
|
+
? 'Some metrics queries failed. Inspect affected containers, then retry the scan or select fewer namespaces in the top bar.'
|
|
481
|
+
: 'There is not enough usable CPU and memory history to recommend changes yet. Inspect the containers to see which resource needs more history.'
|
|
482
|
+
actionLabel = 'View containers needing evidence'
|
|
483
|
+
onAction = () => onSelect('need_data')
|
|
484
|
+
} else {
|
|
485
|
+
headline = 'No actionable changes'
|
|
486
|
+
body = `No meaningful request changes were found among the ${pluralize(counts.in_range, 'evaluated container')}. This does not assess workloads outside the scan coverage.`
|
|
487
|
+
actionLabel = 'View evaluated containers'
|
|
488
|
+
onAction = () => onSelect('in_range')
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return (
|
|
492
|
+
<EmptyState
|
|
493
|
+
headline={headline}
|
|
494
|
+
body={body}
|
|
495
|
+
action={
|
|
496
|
+
<button
|
|
497
|
+
type="button"
|
|
498
|
+
onClick={onAction}
|
|
499
|
+
className="badge badge-sm border border-theme-border bg-theme-elevated text-theme-text-primary"
|
|
500
|
+
>
|
|
501
|
+
{actionLabel}
|
|
502
|
+
</button>
|
|
503
|
+
}
|
|
504
|
+
/>
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
440
508
|
function FirstRunState({ namespaces, onRun }: { namespaces: string[]; onRun: () => void }) {
|
|
441
509
|
const scope =
|
|
442
510
|
namespaces.length === 0
|
|
@@ -466,21 +534,28 @@ function FirstRunState({ namespaces, onRun }: { namespaces: string[]; onRun: ()
|
|
|
466
534
|
)
|
|
467
535
|
}
|
|
468
536
|
|
|
469
|
-
function ScanSummary({
|
|
537
|
+
export function ScanSummary({
|
|
538
|
+
namespaces,
|
|
470
539
|
result,
|
|
471
540
|
counts,
|
|
472
541
|
selected,
|
|
473
542
|
onSelect,
|
|
474
543
|
}: {
|
|
544
|
+
namespaces: string[]
|
|
475
545
|
result: ScanResult
|
|
476
546
|
counts: ReturnType<typeof scanClassCounts>
|
|
477
547
|
selected: ClassFilter
|
|
478
548
|
onSelect: (value: ClassFilter) => void
|
|
479
549
|
}) {
|
|
480
|
-
const evaluated = result.coverage.workloadsEvaluated ?? result.workloads.length
|
|
481
|
-
const discovered = result.coverage.workloadsDiscovered ?? result.workloads.length
|
|
482
550
|
return (
|
|
483
551
|
<section className="rounded-xl border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
|
|
552
|
+
<p className="mb-3 break-words text-xs text-theme-text-secondary">
|
|
553
|
+
Scanned scope:{' '}
|
|
554
|
+
<span className="font-medium text-theme-text-primary">
|
|
555
|
+
{namespaces.length ? namespaces.join(', ') : 'All visible namespaces'}
|
|
556
|
+
</span>
|
|
557
|
+
<span className="text-theme-text-tertiary"> · Change scan scope in the top bar</span>
|
|
558
|
+
</p>
|
|
484
559
|
<div className="grid gap-2 md:grid-cols-3">
|
|
485
560
|
{(Object.keys(ACTION_META) as Array<keyof typeof ACTION_META>).map((key) => {
|
|
486
561
|
const meta = ACTION_META[key]
|
|
@@ -494,7 +569,7 @@ function ScanSummary({
|
|
|
494
569
|
<div className="flex items-center justify-between gap-2">
|
|
495
570
|
<span className="text-sm font-medium text-theme-text-primary">{meta.label}</span>
|
|
496
571
|
<Badge severity={meta.severity} size="sm">
|
|
497
|
-
{counts[key]}
|
|
572
|
+
{pluralize(counts[key], 'container')}
|
|
498
573
|
</Badge>
|
|
499
574
|
</div>
|
|
500
575
|
<p className="mt-1 text-xs text-theme-text-tertiary">{meta.helper}</p>
|
|
@@ -504,7 +579,10 @@ function ScanSummary({
|
|
|
504
579
|
</div>
|
|
505
580
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-theme-text-tertiary">
|
|
506
581
|
<span>
|
|
507
|
-
{
|
|
582
|
+
{result.coverage.workloadsEvaluated} of {result.coverage.workloadsDiscovered} workloads
|
|
583
|
+
attempted{result.scanStatus === 'running' ? ' so far' : ''}
|
|
584
|
+
{' · '}
|
|
585
|
+
{result.coverage.workloadsWithData} with usage data · {result.window} window
|
|
508
586
|
</span>
|
|
509
587
|
<span className="flex items-center gap-3">
|
|
510
588
|
<button
|
|
@@ -512,14 +590,14 @@ function ScanSummary({
|
|
|
512
590
|
onClick={() => onSelect('in_range')}
|
|
513
591
|
className="hover:text-theme-text-primary"
|
|
514
592
|
>
|
|
515
|
-
{counts.in_range}
|
|
593
|
+
{pluralize(counts.in_range, 'container')} · no meaningful change
|
|
516
594
|
</button>
|
|
517
595
|
<button
|
|
518
596
|
type="button"
|
|
519
597
|
onClick={() => onSelect('need_data')}
|
|
520
598
|
className="hover:text-theme-text-primary"
|
|
521
599
|
>
|
|
522
|
-
{counts.need_data}
|
|
600
|
+
{pluralize(counts.need_data, 'container')} · needs evidence
|
|
523
601
|
</button>
|
|
524
602
|
{selected !== 'actions' && (
|
|
525
603
|
<button
|
|
@@ -536,16 +614,14 @@ function ScanSummary({
|
|
|
536
614
|
)
|
|
537
615
|
}
|
|
538
616
|
|
|
539
|
-
function ScanNotices({ result
|
|
617
|
+
export function ScanNotices({ result }: { result: ScanResult }) {
|
|
618
|
+
const [open, setOpen] = useState(false)
|
|
619
|
+
const disclosure = useDisclosure(open)
|
|
540
620
|
const notices: string[] = []
|
|
541
|
-
if (result.state === 'partial')
|
|
542
|
-
notices.push('Some workloads could not be fully analyzed. Completed recommendations are shown.')
|
|
543
621
|
if ((result.coverage.restrictedKinds?.length ?? 0) > 0)
|
|
544
622
|
notices.push('Some workload kinds or namespaces were excluded by your Kubernetes access.')
|
|
545
623
|
if ((result.coverage.unavailableKinds?.length ?? 0) > 0)
|
|
546
624
|
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
625
|
if ((result.coverage.partiallyCachedKinds?.length ?? 0) > 0)
|
|
550
626
|
notices.push(
|
|
551
627
|
'Radar is caching only some namespaces, so this scan covered a narrower scope than the whole cluster.',
|
|
@@ -554,26 +630,68 @@ function ScanNotices({ result, rows }: { result: ScanResult; rows: RightsizingSc
|
|
|
554
630
|
// The DaemonSet-only empty state already says this.
|
|
555
631
|
if (daemonSetsWithoutNodes > 0 && result.reason !== 'only_daemonsets_without_nodes')
|
|
556
632
|
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.`,
|
|
633
|
+
`${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
634
|
)
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
635
|
+
const warnings = result.warnings ?? []
|
|
636
|
+
const deadlineExceeded = result.scanStatus === 'timed_out' || warnings.some((warning) => warning.code === 'scan_deadline_exceeded')
|
|
637
|
+
for (const warning of warnings) {
|
|
638
|
+
if (warning.code !== 'scan_deadline_exceeded') notices.push(warningMessage(warning.code))
|
|
639
|
+
}
|
|
640
|
+
const partial = result.state === 'partial'
|
|
641
|
+
if (!partial && notices.length === 0 && !deadlineExceeded) return null
|
|
642
|
+
const details = [...new Set(notices)]
|
|
643
|
+
const singleNote = details.length === 1 && !deadlineExceeded
|
|
644
|
+
const hasDetails = details.length > 0 && !singleNote
|
|
645
|
+
const summary = result.scanStatus === 'cancelled' ? 'Scan stopped. Available recommendations are shown. Run again to start a new scan.' : singleNote
|
|
646
|
+
? details[0]
|
|
647
|
+
: deadlineExceeded
|
|
648
|
+
? 'The scan reached its time limit. Available recommendations are shown. For a narrower scan, select namespaces in the top bar and run again.'
|
|
649
|
+
: partial
|
|
650
|
+
? 'Some workloads or metrics could not be fully evaluated. Available recommendations are shown.'
|
|
651
|
+
: 'Some workloads were excluded from this scan.'
|
|
652
|
+
return (
|
|
653
|
+
<section
|
|
654
|
+
aria-label="Scan coverage"
|
|
655
|
+
className="rounded-lg border border-theme-border bg-theme-surface text-xs text-theme-text-secondary"
|
|
656
|
+
>
|
|
657
|
+
<div className="flex items-start gap-2 px-3 py-2">
|
|
658
|
+
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
659
|
+
<div className="min-w-0 flex-1">
|
|
660
|
+
<p className="font-medium text-theme-text-primary">
|
|
661
|
+
{partial ? 'Partial results' : 'Scan notes'}
|
|
662
|
+
</p>
|
|
663
|
+
<p className="mt-1">{summary}</p>
|
|
664
|
+
</div>
|
|
665
|
+
{hasDetails && (
|
|
666
|
+
<button
|
|
667
|
+
type="button"
|
|
668
|
+
{...disclosure.buttonProps}
|
|
669
|
+
onClick={() => setOpen(!open)}
|
|
670
|
+
className="inline-flex shrink-0 items-center gap-1 rounded px-1 py-0.5 text-accent-text hover:bg-theme-hover"
|
|
671
|
+
>
|
|
672
|
+
Details <CollapseChevron open={open} className="h-3.5 w-3.5" />
|
|
673
|
+
</button>
|
|
674
|
+
)}
|
|
675
|
+
</div>
|
|
676
|
+
{hasDetails && (
|
|
677
|
+
<Collapse open={open} id={disclosure.panelId}>
|
|
678
|
+
<ul className="list-disc space-y-1 border-t border-theme-border py-2 pl-9 pr-3">
|
|
679
|
+
{details.map((text) => (
|
|
680
|
+
<li key={text}>{text}</li>
|
|
681
|
+
))}
|
|
682
|
+
</ul>
|
|
683
|
+
</Collapse>
|
|
684
|
+
)}
|
|
685
|
+
</section>
|
|
686
|
+
)
|
|
569
687
|
}
|
|
570
688
|
|
|
571
|
-
function Notice({ text, tone }: { text: string; tone?: 'warning' }) {
|
|
689
|
+
function Notice({ text, tone, loading }: { text: string; tone?: 'warning'; loading?: boolean }) {
|
|
572
690
|
return (
|
|
573
691
|
<div
|
|
574
692
|
className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-xs ${tone === 'warning' ? 'status-degraded' : 'border-theme-border bg-theme-surface text-theme-text-secondary'}`}
|
|
575
693
|
>
|
|
576
|
-
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
694
|
+
{loading ? <Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" /> : <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />}
|
|
577
695
|
{text}
|
|
578
696
|
</div>
|
|
579
697
|
)
|
|
@@ -598,6 +716,7 @@ function ScanFilters(props: {
|
|
|
598
716
|
}) {
|
|
599
717
|
return (
|
|
600
718
|
<div className="flex flex-wrap items-center gap-2 border-b border-theme-border p-3">
|
|
719
|
+
<span className="text-xs font-medium text-theme-text-secondary">Filter results</span>
|
|
601
720
|
<SearchBox
|
|
602
721
|
value={props.search}
|
|
603
722
|
onChange={props.onSearch}
|
|
@@ -607,12 +726,12 @@ function ScanFilters(props: {
|
|
|
607
726
|
className="mr-auto w-60 2xl:w-72"
|
|
608
727
|
/>
|
|
609
728
|
<SelectMenu
|
|
610
|
-
ariaLabel="Filter by namespace"
|
|
729
|
+
ariaLabel="Filter results by namespace"
|
|
611
730
|
value={props.namespace}
|
|
612
731
|
onChange={props.onNamespace}
|
|
613
|
-
className="w-
|
|
732
|
+
className="w-52"
|
|
614
733
|
options={[
|
|
615
|
-
{ value: '', label: 'All namespaces' },
|
|
734
|
+
{ value: '', label: 'All scanned namespaces' },
|
|
616
735
|
...props.namespaces.map((value) => ({ value, label: value })),
|
|
617
736
|
]}
|
|
618
737
|
/>
|
|
@@ -784,7 +903,7 @@ function SignalBadge({ signal }: { signal: ResourceSignal }) {
|
|
|
784
903
|
)
|
|
785
904
|
}
|
|
786
905
|
|
|
787
|
-
function FitWhy({ label, row }: { label: string; row?: RightsizingRow }) {
|
|
906
|
+
export function FitWhy({ label, row }: { label: string; row?: RightsizingRow }) {
|
|
788
907
|
if (!row)
|
|
789
908
|
return (
|
|
790
909
|
<div>
|
|
@@ -809,8 +928,9 @@ function FitWhy({ label, row }: { label: string; row?: RightsizingRow }) {
|
|
|
809
928
|
Why this {label.toLowerCase()} guidance
|
|
810
929
|
</div>
|
|
811
930
|
<p className="mt-1 text-xs text-theme-text-secondary">
|
|
812
|
-
{
|
|
813
|
-
|
|
931
|
+
{row.queryError
|
|
932
|
+
? 'Metrics could not be queried, so usable history could not be determined. Retry the scan, or select fewer namespaces in the top bar.'
|
|
933
|
+
: `${observation}${history}`}
|
|
814
934
|
</p>
|
|
815
935
|
<EvidenceNote row={row} />
|
|
816
936
|
</div>
|
|
@@ -870,7 +990,7 @@ function primaryEvidenceNote(row: RightsizingRow) {
|
|
|
870
990
|
The calculated request would exceed the current limit.
|
|
871
991
|
</p>
|
|
872
992
|
)
|
|
873
|
-
if (row.fit === 'insufficient_history')
|
|
993
|
+
if (row.fit === 'insufficient_history' && !row.queryError)
|
|
874
994
|
return (
|
|
875
995
|
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
876
996
|
Wait for more runtime history before changing this request.
|
|
@@ -978,20 +1098,24 @@ function unavailableMessage(reason?: string): string {
|
|
|
978
1098
|
return 'Deployment history cannot be mapped because kube_replicaset_owner is unavailable from kube-state-metrics.'
|
|
979
1099
|
if (reason === 'owner_metrics_query_failed')
|
|
980
1100
|
return 'Radar found Prometheus, but could not query kube-state-metrics ownership data.'
|
|
1101
|
+
if (reason === 'scan_capacity_exceeded') return 'This scope is too large for one scan. Select fewer namespaces in the top bar and run again.'
|
|
1102
|
+
if (reason === 'scan_cancelled') return 'The scan was stopped before any workloads were evaluated. Run again to start a new scan.'
|
|
1103
|
+
if (reason === 'scan_deadline_exceeded') return 'The scan reached its three-minute time limit. Select fewer namespaces in the top bar and run again.'
|
|
981
1104
|
if (reason === 'scan_incomplete')
|
|
982
1105
|
return 'The scan could not evaluate any workloads in the current scope.'
|
|
983
1106
|
return 'Prometheus or kube-state-metrics does not expose the metrics required for this scan.'
|
|
984
1107
|
}
|
|
985
1108
|
|
|
986
1109
|
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
1110
|
if (code === 'owner_metrics_query_failed') return 'Workload ownership data could not be queried.'
|
|
990
1111
|
if (code === 'oom_evidence_unavailable')
|
|
991
1112
|
return 'Radar could not verify restart history for some containers, so it withheld their memory reductions.'
|
|
992
1113
|
if (code.endsWith('_query_failed'))
|
|
993
|
-
return `Some ${code
|
|
994
|
-
|
|
1114
|
+
return `Some ${code
|
|
1115
|
+
.replace('_query_failed', '')
|
|
1116
|
+
.replaceAll('_', ' ')
|
|
1117
|
+
.replace(/\bcpu\b/g, 'CPU')} data could not be queried.`
|
|
1118
|
+
return 'Some rightsizing data was unavailable.'
|
|
995
1119
|
}
|
|
996
1120
|
|
|
997
1121
|
function formatScanTime(value: string): string {
|
|
@@ -60,6 +60,13 @@ describe('rightsizing scan copy', () => {
|
|
|
60
60
|
).toBe('fatal_error')
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
+
it('waits for the retained scan lookup before offering a first run', () => {
|
|
64
|
+
expect(getRightsizingScanSurfaceState({
|
|
65
|
+
statusLoading: false, hasStatus: true, connected: true, scanLoading: true,
|
|
66
|
+
pending: false, hasResult: false, hasError: false,
|
|
67
|
+
})).toBe('loading_scan')
|
|
68
|
+
})
|
|
69
|
+
|
|
63
70
|
it('does not auto-scan a connected first visit', () => {
|
|
64
71
|
expect(
|
|
65
72
|
getRightsizingScanSurfaceState({
|
|
@@ -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
|
+
})
|