@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.
Files changed (98) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +91 -328
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.jobset.test.ts +18 -0
  6. package/src/api/client.resource-identity.test.ts +44 -0
  7. package/src/api/client.rightsizing.test.ts +7 -3
  8. package/src/api/client.ts +233 -78
  9. package/src/api/preferences.test.ts +54 -0
  10. package/src/api/preferences.ts +38 -0
  11. package/src/components/CloudFunnelButton.test.tsx +155 -0
  12. package/src/components/CloudFunnelButton.tsx +14 -8
  13. package/src/components/ContextSwitcher.tsx +13 -132
  14. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  15. package/src/components/applications/ApplicationsView.tsx +5 -3
  16. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  17. package/src/components/diagnose/AIMarkdown.tsx +8 -3
  18. package/src/components/diagnose/AgentControls.tsx +26 -3
  19. package/src/components/diagnose/AssessmentCard.tsx +249 -237
  20. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  21. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +47 -0
  22. package/src/components/diagnose/InvestigationEvidencePane.tsx +15 -2
  23. package/src/components/diagnose/InvestigationEvidenceSections.tsx +1 -1
  24. package/src/components/diagnose/InvestigationView.tsx +4 -1
  25. package/src/components/diagnose/agentCatalog.ts +6 -0
  26. package/src/components/diagnose/investigationCase.test.tsx +56 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +60 -0
  28. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +7 -3
  29. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +1 -1
  30. package/src/components/diagnose/investigationEvidence/observations.ts +8 -5
  31. package/src/components/diagnose/investigationEvidence/types.ts +1 -1
  32. package/src/components/diagnose/launch.test.ts +23 -0
  33. package/src/components/diagnose/launch.ts +4 -0
  34. package/src/components/diagnose/parts.test.tsx +159 -2
  35. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  36. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  37. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  38. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  39. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  40. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  41. package/src/components/execution/BatchExecutionView.tsx +324 -65
  42. package/src/components/execution/JobSetAdmission.test.tsx +42 -0
  43. package/src/components/execution/JobSetAdmission.tsx +17 -0
  44. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  45. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  46. package/src/components/execution/batch-run-actions.test.ts +1 -0
  47. package/src/components/execution/batch-timeline.test.ts +3 -0
  48. package/src/components/execution/execution-definition.test.ts +15 -1
  49. package/src/components/execution/execution-definition.ts +4 -0
  50. package/src/components/execution/member-collection.render.test.tsx +142 -0
  51. package/src/components/gitops/GitOpsView.tsx +33 -2
  52. package/src/components/gitops/destination-toast.test.ts +33 -0
  53. package/src/components/gitops/destination-toast.ts +46 -0
  54. package/src/components/gitops/useDestinationCluster.ts +30 -0
  55. package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
  56. package/src/components/home/MCPSetupDialog.tsx +10 -0
  57. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  58. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  59. package/src/components/nav/PrimaryNavRail.tsx +2 -5
  60. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  61. package/src/components/resource/PVCUsageBar.tsx +62 -16
  62. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  63. package/src/components/resources/ResourcesView.tsx +3 -3
  64. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  65. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  66. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  67. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  68. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  69. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  70. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  71. package/src/components/rightsizing/RightsizingScanView.tsx +207 -83
  72. package/src/components/rightsizing/copy.test.ts +7 -0
  73. package/src/components/rightsizing/empty-state.test.tsx +67 -0
  74. package/src/components/rightsizing/model.test.ts +120 -2
  75. package/src/components/rightsizing/model.ts +36 -13
  76. package/src/components/rightsizing/notices.test.tsx +176 -0
  77. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  78. package/src/components/settings/SettingsDialog.tsx +157 -42
  79. package/src/components/settings/settings-state.test.ts +18 -0
  80. package/src/components/settings/settings-state.ts +18 -0
  81. package/src/components/timeline/TimelineView.tsx +9 -3
  82. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  83. package/src/components/ui/Markdown.tsx +33 -6
  84. package/src/components/ui/command-items.ts +0 -3
  85. package/src/components/useContextSwitchFlow.tsx +147 -0
  86. package/src/components/workload/WorkloadView.test.ts +17 -1
  87. package/src/components/workload/WorkloadView.tsx +36 -18
  88. package/src/context/ContextSwitchContext.tsx +18 -2
  89. package/src/context/NavCustomization.tsx +4 -58
  90. package/src/context/ThemeContext.tsx +3 -11
  91. package/src/hooks/useClusterLoadState.ts +2 -2
  92. package/src/hooks/useFavorites.ts +3 -5
  93. package/src/utils/auditBadges.ts +1 -1
  94. package/src/utils/navigation.test.ts +18 -1
  95. package/src/utils/navigation.ts +12 -5
  96. package/src/utils/topology-selection.test.ts +56 -0
  97. package/src/utils/topology-selection.ts +9 -9
  98. 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.isPending}
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.isPending ? 'Scanning…' : 'Run again'}
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. Larger clusters can take up to a minute."
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={`flex flex-col gap-4 transition-opacity ${scan.isPending ? 'opacity-70' : ''}`}
327
+ className="flex flex-col gap-4"
322
328
  >
323
329
  {scan.error && (
324
- <Notice
325
- tone="warning"
326
- text={`Refresh failed; showing results from ${formatScanTime(result.scannedAt)}. ${errorMessage(scan.error)}`}
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="Scanning the current scope. Previous results remain visible until the scan completes." />
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 result={result} rows={rows} />
339
- {result.reason === 'only_daemonsets_without_nodes' ? (
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
- 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
- }
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]} containers
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
- {evaluated} of {discovered} visible workloads evaluated · {result.window || '7d'} history
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} containers · no meaningful change
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} containers · not analyzed
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, rows }: { result: ScanResult; rows: RightsizingScanRow[] }) {
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
- 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
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-40 2xl:w-48"
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
- {observation}
813
- {history}
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.replace('_query_failed', '').replaceAll('_', ' ')} data could not be queried.`
994
- return 'Some rightsizing data was unavailable. Completed recommendations are shown.'
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
+ })