@skyhook-io/radar-app 1.8.7 → 1.8.8

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 (48) hide show
  1. package/package.json +4 -4
  2. package/src/App.tsx +58 -50
  3. package/src/api/client.argoResourceSync.test.ts +69 -0
  4. package/src/api/client.rightsizing.test.ts +32 -0
  5. package/src/api/client.ts +1222 -234
  6. package/src/api/timelineSource.ts +4 -2
  7. package/src/components/applications/ApplicationsView.tsx +613 -219
  8. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  9. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  10. package/src/components/cost/CostTrendChart.tsx +103 -72
  11. package/src/components/cost/CostView.test.ts +12 -0
  12. package/src/components/cost/CostView.tsx +494 -229
  13. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  14. package/src/components/cost/CostViewTabs.tsx +40 -0
  15. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  16. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  17. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  19. package/src/components/cost/cloud-console.test.ts +39 -0
  20. package/src/components/cost/cloud-console.ts +81 -0
  21. package/src/components/cost/errors.ts +8 -0
  22. package/src/components/cost/format.test.ts +27 -0
  23. package/src/components/cost/format.ts +46 -0
  24. package/src/components/cost/kinds.ts +5 -0
  25. package/src/components/diagnose/AISettings.tsx +7 -12
  26. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  27. package/src/components/gitops/GitOpsView.tsx +81 -14
  28. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  29. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  30. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  31. package/src/components/helm/ValuesDiffPreview.tsx +2 -3
  32. package/src/components/home/CostCard.tsx +21 -36
  33. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  34. package/src/components/resource/RightsizingStrip.tsx +319 -123
  35. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  36. package/src/components/rightsizing/copy.test.ts +56 -0
  37. package/src/components/rightsizing/model.test.ts +227 -0
  38. package/src/components/rightsizing/model.ts +158 -0
  39. package/src/components/rightsizing/presentation.test.ts +104 -0
  40. package/src/components/rightsizing/presentation.ts +94 -0
  41. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  42. package/src/components/settings/SettingsDialog.tsx +1268 -318
  43. package/src/components/timeline/TimelineList.tsx +35 -8
  44. package/src/components/timeline/TimelineView.tsx +156 -26
  45. package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
  46. package/src/components/workload/WorkloadView.tsx +711 -328
  47. package/src/index.css +5 -1
  48. package/src/main.tsx +1 -1
@@ -19,6 +19,7 @@ import {
19
19
  formatGitOpsSourceUrl,
20
20
  getGitOpsResourceStatus,
21
21
  getGitOpsTool,
22
+ isArgoOperationInProgress,
22
23
  isArgoSuspendedByRadar,
23
24
  gitOpsInsightChangeKey,
24
25
  initNavigationMap,
@@ -46,8 +47,10 @@ import { useToast } from '../ui/Toast'
46
47
 
47
48
  import {
48
49
  fetchJSON,
50
+ buildArgoResourceSyncVars,
49
51
  useApplyResource,
50
52
  useArgoRefresh,
53
+ useArgoResourceValidation,
51
54
  useArgoResume,
52
55
  useArgoRollback,
53
56
  useArgoSuspend,
@@ -66,6 +69,8 @@ import { useConnection } from '../../context/ConnectionContext'
66
69
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
67
70
  import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
68
71
  import { CodeViewer } from '../ui/CodeViewer'
72
+ import { ArgoResourceDiffLoader } from './ArgoResourceDiffLoader'
73
+ import { RevisionMetaChip } from './RevisionMetaChip'
69
74
  import type { GitOpsHistoryItem } from '@skyhook-io/k8s-ui'
70
75
 
71
76
  const GITOPS_KINDS: APIResource[] = [
@@ -80,6 +85,10 @@ const GITOPS_KINDS: APIResource[] = [
80
85
  { name: 'alerts', kind: 'Alert', group: 'notification.toolkit.fluxcd.io', version: 'v1beta3', namespaced: true, verbs: ['list', 'get'], isCrd: true },
81
86
  ]
82
87
 
88
+ type ArgoSyncDialogTarget =
89
+ | { scope: 'application' }
90
+ | { scope: 'resource'; resource: GitOpsInsightRef }
91
+
83
92
  const KIND_BY_NAME = new Map(GITOPS_KINDS.map((k) => [k.name, k]))
84
93
 
85
94
  // Rows are the table's primary content; their poll cadence is what the toolbar
@@ -97,12 +106,15 @@ interface GitOpsViewProps {
97
106
  namespaces: string[]
98
107
  onOpenResource: (resource: SelectedResource) => void
99
108
  onClearNamespaces?: () => void
109
+ // Opens the global Settings dialog — backs the "Connect Argo CD" hint on the
110
+ // Changes tab of an Argo Application detail page.
111
+ onOpenSettings?: () => void
100
112
  }
101
113
 
102
- export function GitOpsView({ namespaces, onOpenResource, onClearNamespaces }: GitOpsViewProps) {
114
+ export function GitOpsView({ namespaces, onOpenResource, onClearNamespaces, onOpenSettings }: GitOpsViewProps) {
103
115
  const location = useLocation()
104
116
  if (location.pathname.startsWith('/gitops/detail/')) {
105
- return <GitOpsDetailView namespaces={namespaces} onOpenResource={onOpenResource} />
117
+ return <GitOpsDetailView namespaces={namespaces} onOpenResource={onOpenResource} onOpenSettings={onOpenSettings} />
106
118
  }
107
119
  return <GitOpsTableView namespaces={namespaces} onClearNamespaces={onClearNamespaces} />
108
120
  }
@@ -329,7 +341,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
329
341
  )
330
342
  }
331
343
 
332
- function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
344
+ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOpsViewProps) {
333
345
  const location = useLocation()
334
346
  const navigate = useNavigate()
335
347
  const { showError, showSuccess } = useToast()
@@ -403,6 +415,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
403
415
  const [helmValuesOpen, setHelmValuesOpen] = useState(false)
404
416
 
405
417
  const argoSync = useArgoSync()
418
+ const argoResourceValidation = useArgoResourceValidation()
406
419
  const argoRefresh = useArgoRefresh()
407
420
  const argoTerminate = useArgoTerminate()
408
421
  const argoSuspend = useArgoSuspend()
@@ -414,13 +427,23 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
414
427
  const fluxSuspend = useFluxSuspend()
415
428
  const fluxResume = useFluxResume()
416
429
 
417
- const [syncDialogOpen, setSyncDialogOpen] = useState(false)
430
+ const [syncDialogTarget, setSyncDialogTarget] = useState<ArgoSyncDialogTarget | null>(null)
418
431
  // Doubles as the "open" flag (truthy = dialog open) and the data carrier
419
432
  // for which history entry to roll back to.
420
433
  const [rollbackTarget, setRollbackTarget] = useState<GitOpsHistoryItem | null>(null)
421
434
  // Disambiguates which refresh button is in flight (both share argoRefresh).
422
435
  const [refreshKind, setRefreshKind] = useState<'normal' | 'hard'>('normal')
423
436
 
437
+ function openArgoSyncDialog(target: ArgoSyncDialogTarget) {
438
+ argoResourceValidation.reset()
439
+ setSyncDialogTarget(target)
440
+ }
441
+
442
+ function closeArgoSyncDialog() {
443
+ argoResourceValidation.reset()
444
+ setSyncDialogTarget(null)
445
+ }
446
+
424
447
  const detailRow = resourceQ.data ? normalizeDetailResource(kind, group, resourceQ.data) : null
425
448
  const tree = treeQ.data ?? null
426
449
  const helmValues = useMemo(() => extractHelmValues(kind, resourceQ.data), [kind, resourceQ.data])
@@ -436,6 +459,13 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
436
459
  function openResourceFromTree(ref: GitOpsTreeRef | GitOpsInsightRef) {
437
460
  if (isGitOpsDetailRef(ref) && isValidKubernetesName(ref.name)) {
438
461
  const detailKind = kindToPlural(ref.kind)
462
+ // The tree's root node is this page's own subject — clicking it must not
463
+ // open a nested copy of the same detail page (which stacks an identical
464
+ // "GitOps / X / X" breadcrumb, and again, ad infinitum). A self-reference
465
+ // is a no-op; the header already represents this resource.
466
+ if (detailKind === kind && (ref.namespace || '') === (namespace || '') && ref.name === name) {
467
+ return
468
+ }
439
469
  const params = new URLSearchParams()
440
470
  if (ref.group) params.set('apiGroup', ref.group)
441
471
  // Lineage breadcrumb support: when the user opens a child GitOps CR
@@ -457,13 +487,14 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
457
487
  }
458
488
 
459
489
  const isRunning = resourceQ.data?.status?.operationState?.phase === 'Running'
490
+ const operationInProgress = isArgoOperationInProgress(resourceQ.data)
460
491
  const isFluxWorkload = kind === 'kustomizations' || kind === 'helmreleases'
461
492
  const isFlux = tool === 'flux'
462
493
  const isArgoApp = kind === 'applications'
463
494
 
464
495
  // Detail-page shortcuts. Skip when a modal is already open so a stray "s"
465
496
  // in an input field doesn't pop another sync dialog.
466
- const shortcutsEnabled = !syncDialogOpen && !rollbackTarget
497
+ const shortcutsEnabled = !syncDialogTarget && !rollbackTarget
467
498
  useRegisterShortcut({
468
499
  id: 'gitops-detail-sync',
469
500
  keys: 's',
@@ -471,11 +502,11 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
471
502
  category: 'GitOps',
472
503
  scope: 'gitops',
473
504
  handler: () => {
474
- if (effectiveSuspended || terminating) return
475
- if (isArgoApp) setSyncDialogOpen(true)
505
+ if (effectiveSuspended || terminating || operationInProgress) return
506
+ if (isArgoApp) openArgoSyncDialog({ scope: 'application' })
476
507
  else if (isFlux) fluxReconcile.mutate({ kind, namespace, name })
477
508
  },
478
- enabled: shortcutsEnabled && (isArgoApp || isFlux) && !effectiveSuspended && !terminating,
509
+ enabled: shortcutsEnabled && (isArgoApp || isFlux) && !effectiveSuspended && !terminating && !(isArgoApp && operationInProgress),
479
510
  })
480
511
  useRegisterShortcut({
481
512
  id: 'gitops-detail-refresh',
@@ -529,7 +560,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
529
560
  }
530
561
 
531
562
  const argoHandlers: ArgoActionHandlers | undefined = isArgoApp ? {
532
- onSyncRequested: () => setSyncDialogOpen(true),
563
+ onSyncRequested: () => openArgoSyncDialog({ scope: 'application' }),
533
564
  onRefresh: (refreshType) => {
534
565
  setRefreshKind(refreshType)
535
566
  argoRefresh.mutate({ namespace, name, hard: refreshType === 'hard' })
@@ -545,6 +576,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
545
576
  resuming: argoResume.isPending,
546
577
  autoSyncEnabled: argoAutoSyncEnabled,
547
578
  isRunning,
579
+ operationInProgress,
548
580
  } : undefined
549
581
 
550
582
  const fluxHandlers: FluxActionHandlers | undefined = isFlux ? {
@@ -576,6 +608,13 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
576
608
  detail={detail}
577
609
  insight={insightsQ.data ?? null}
578
610
  insightLoading={insightsQ.isLoading}
611
+ renderRevisionMeta={
612
+ isArgoApp && insightsQ.data?.capabilities?.revisionMetadataAvailable
613
+ ? (revision) => (
614
+ <RevisionMetaChip appNamespace={namespace} appName={name} revision={revision} />
615
+ )
616
+ : undefined
617
+ }
579
618
  onSelectIssue={(issue) => {
580
619
  const ref = issue.refs?.[0]
581
620
  if (!ref) return
@@ -679,7 +718,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
679
718
  <GitOpsActivityInsightView
680
719
  insight={insightsQ.data}
681
720
  error={insightsQ.error as Error | null}
682
- onRollback={isArgoApp ? (item) => {
721
+ onRollback={isArgoApp && !operationInProgress ? (item) => {
683
722
  if (parseArgoRollbackID(item.id) == null) return
684
723
  setRollbackTarget(item)
685
724
  } : undefined}
@@ -692,8 +731,22 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
692
731
  insight={insightsQ.data}
693
732
  error={insightsQ.error as Error | null}
694
733
  onOpenResource={openResourceFromTree}
734
+ onSyncResource={isArgoApp ? (resource) => openArgoSyncDialog({ scope: 'resource', resource }) : undefined}
735
+ syncResourceDisabledReason={isArgoApp ? (
736
+ terminating
737
+ ? terminatingActionTooltip
738
+ : effectiveSuspended
739
+ ? 'Resume the Application before syncing a resource.'
740
+ : operationInProgress || argoSync.isPending
741
+ ? 'Wait for the current sync operation to finish.'
742
+ : undefined
743
+ ) : undefined}
695
744
  focusKey={changesFocusKey}
696
745
  tree={tree}
746
+ renderResourceDiff={isArgoApp ? (ref) => (
747
+ <ArgoResourceDiffLoader appNamespace={namespace} appName={name} resourceRef={ref} />
748
+ ) : undefined}
749
+ onOpenSettings={onOpenSettings}
697
750
  />
698
751
  )
699
752
  }
@@ -739,13 +792,27 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
739
792
  {isArgoApp && (
740
793
  <>
741
794
  <SyncOptionsDialog
742
- open={syncDialogOpen}
795
+ open={!!syncDialogTarget}
743
796
  appLabel={`${namespace}/${name}`}
797
+ resource={syncDialogTarget?.scope === 'resource' ? syncDialogTarget.resource : undefined}
744
798
  pending={argoSync.isPending}
745
- onCancel={() => setSyncDialogOpen(false)}
799
+ autoSyncEnabled={argoAutoSyncEnabled}
800
+ validationPending={argoResourceValidation.isPending}
801
+ operationInProgress={operationInProgress}
802
+ validationResult={argoResourceValidation.data}
803
+ validationError={argoResourceValidation.error?.message}
804
+ onCancel={closeArgoSyncDialog}
805
+ onValidationReset={() => argoResourceValidation.reset()}
806
+ onValidate={syncDialogTarget?.scope === 'resource' ? (opts) => {
807
+ argoResourceValidation.mutate(buildArgoResourceSyncVars(namespace, name, syncDialogTarget.resource, opts))
808
+ } : undefined}
746
809
  onConfirm={(opts) => {
747
- argoSync.mutate({ namespace, name, ...opts }, {
748
- onSettled: () => setSyncDialogOpen(false),
810
+ if (!syncDialogTarget) return
811
+ const variables = syncDialogTarget.scope === 'resource'
812
+ ? buildArgoResourceSyncVars(namespace, name, syncDialogTarget.resource, opts)
813
+ : { namespace, name, ...opts }
814
+ argoSync.mutate(variables, {
815
+ onSettled: closeArgoSyncDialog,
749
816
  })
750
817
  }}
751
818
  />
@@ -0,0 +1,63 @@
1
+ import { ShieldCheck, ShieldAlert } from 'lucide-react'
2
+ import type { ReactNode } from 'react'
3
+ import { useArgoRevisionMetadata } from '../../api/client'
4
+ import { Tooltip } from '../ui/Tooltip'
5
+
6
+ // Argo returns the author as "Name <email>"; show just the name.
7
+ function authorName(author?: string): string {
8
+ if (!author) return ''
9
+ const lt = author.indexOf('<')
10
+ return (lt >= 0 ? author.slice(0, lt) : author).trim()
11
+ }
12
+
13
+ // signatureInfo is a raw GPG verification line. "Good signature" → verified;
14
+ // any other non-empty value → a check ran but didn't verify; empty → unsigned.
15
+ function signatureState(sig?: string): 'good' | 'bad' | null {
16
+ if (!sig) return null
17
+ return /good signature/i.test(sig) ? 'good' : 'bad'
18
+ }
19
+
20
+ // RevisionMetaChip hydrates the Argo status-strip revision with Git commit
21
+ // detail (author, subject, signature) fetched from the Argo CD API. Renders
22
+ // nothing until data arrives, so the bare SHA is never blocked; the whole thing
23
+ // is gated by the host on capabilities.revisionMetadataAvailable.
24
+ export function RevisionMetaChip({
25
+ appNamespace,
26
+ appName,
27
+ revision,
28
+ }: {
29
+ appNamespace: string
30
+ appName: string
31
+ revision: string
32
+ }): ReactNode {
33
+ // The host only renders this chip when revision metadata is available, so no
34
+ // extra enabled gate is needed — the hook's own appName/revision guard suffices.
35
+ const { data } = useArgoRevisionMetadata(appNamespace, appName, revision)
36
+ if (!data) return null
37
+
38
+ const author = authorName(data.author)
39
+ const sig = signatureState(data.signatureInfo)
40
+ const subject = data.message?.split('\n')[0]?.trim()
41
+ if (!author && !sig && !subject) return null
42
+
43
+ return (
44
+ <>
45
+ {author && <span className="shrink-0 text-theme-text-secondary">· {author}</span>}
46
+ {subject && (
47
+ <Tooltip content={data.message ?? subject} delay={300} wrapperClassName="min-w-0">
48
+ <span className="max-w-[32ch] truncate text-theme-text-tertiary">“{subject}”</span>
49
+ </Tooltip>
50
+ )}
51
+ {sig === 'good' && (
52
+ <Tooltip content={data.signatureInfo ?? 'Signed commit'} delay={300} wrapperClassName="inline-flex shrink-0">
53
+ <ShieldCheck className="h-3 w-3 text-green-600 dark:text-green-400/80" />
54
+ </Tooltip>
55
+ )}
56
+ {sig === 'bad' && (
57
+ <Tooltip content={data.signatureInfo ?? 'Signature not verified'} delay={300} wrapperClassName="inline-flex shrink-0">
58
+ <ShieldAlert className="h-3 w-3 text-amber-600 dark:text-amber-400/80" />
59
+ </Tooltip>
60
+ )}
61
+ </>
62
+ )
63
+ }
@@ -17,7 +17,7 @@ import {
17
17
  Package,
18
18
  Settings,
19
19
  } from 'lucide-react'
20
- import { PaneLoader } from '@skyhook-io/k8s-ui'
20
+ import { PaneLoader, DiffLine, hasDiffBodyChange } from '@skyhook-io/k8s-ui'
21
21
  import {
22
22
  useCloudRole,
23
23
  useHelmHooksDiff,
@@ -30,7 +30,6 @@ import {
30
30
  import type { HelmHook, HelmRevision, HooksDiff, ResourceDiff } from '../../types'
31
31
  import { getHelmStatusColor, getKindBadgeColor, SEVERITY_BADGE } from '../../utils/badge-colors'
32
32
  import { formatDate } from './helm-utils'
33
- import { DiffLine, hasDiffBodyChange } from './ManifestDiffViewer'
34
33
  import { RoleGatedPanel } from './RoleGatedPanel'
35
34
  import { Tooltip } from '../ui/Tooltip'
36
35
 
@@ -1,6 +1,5 @@
1
1
  import { X, GitCompare } from 'lucide-react'
2
- import { PaneLoader } from '@skyhook-io/k8s-ui'
3
- import { clsx } from 'clsx'
2
+ import { PaneLoader, DiffLine, hasDiffBodyChange } from '@skyhook-io/k8s-ui'
4
3
 
5
4
  interface ManifestDiffViewerProps {
6
5
  diff: string
@@ -68,32 +67,3 @@ export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onCl
68
67
  </div>
69
68
  )
70
69
  }
71
-
72
- export function hasDiffBodyChange(diff: string): boolean {
73
- return diff.split('\n').some((line) => {
74
- if (!line || line.startsWith('---') || line.startsWith('+++') || line.startsWith('@@')) {
75
- return false
76
- }
77
- return line.startsWith('+') || line.startsWith('-')
78
- })
79
- }
80
-
81
- export function DiffLine({ line }: { line: string }) {
82
- const isAddition = line.startsWith('+') && !line.startsWith('+++')
83
- const isRemoval = line.startsWith('-') && !line.startsWith('---')
84
- const isHeader = line.startsWith('---') || line.startsWith('+++') || line.startsWith('@@')
85
-
86
- return (
87
- <div
88
- className={clsx(
89
- 'whitespace-pre',
90
- isAddition && 'bg-green-500/10 text-green-700 dark:text-green-400',
91
- isRemoval && 'bg-red-500/10 text-red-700 dark:text-red-400',
92
- isHeader && 'text-theme-text-tertiary font-bold',
93
- !isAddition && !isRemoval && !isHeader && 'text-theme-text-secondary'
94
- )}
95
- >
96
- {line || ' '}
97
- </div>
98
- )
99
- }
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { X, Play, Loader2, FileText, AlertTriangle } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
+ import { classifyDiffLine } from '@skyhook-io/k8s-ui'
5
6
  import type { ValuesPreviewResponse } from '../../types'
6
7
 
7
8
  interface ValuesDiffPreviewProps {
@@ -169,9 +170,7 @@ interface DiffLineProps {
169
170
  }
170
171
 
171
172
  function DiffLine({ line, lineNumber }: DiffLineProps) {
172
- const isAddition = line.startsWith('+') && !line.startsWith('+++')
173
- const isDeletion = line.startsWith('-') && !line.startsWith('---')
174
- const isHeader = line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++')
173
+ const { isAddition, isRemoval: isDeletion, isHeader } = classifyDiffLine(line)
175
174
 
176
175
  return (
177
176
  <div
@@ -1,6 +1,12 @@
1
1
  import type { OpenCostSummary } from '../../api/client'
2
2
  import { useOpenCostSummary } from '../../api/client'
3
3
  import { DollarSign } from 'lucide-react'
4
+ import {
5
+ formatCostPerHour,
6
+ formatProjectedDailyRate,
7
+ formatProjectedMonthlyCost,
8
+ formatProjectedMonthlyRate,
9
+ } from '../cost/format'
4
10
 
5
11
  export function CostCard({ onNavigate }: { onNavigate?: () => void }) {
6
12
  const { data } = useOpenCostSummary()
@@ -15,7 +21,6 @@ export function CostCard({ onNavigate }: { onNavigate?: () => void }) {
15
21
 
16
22
  function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNavigate?: () => void }) {
17
23
  const hourlyCost = data.totalHourlyCost ?? 0
18
- const monthlyCost = hourlyCost * 730
19
24
  const namespaces = data.namespaces ?? []
20
25
  const topNamespaces = namespaces.slice(0, 5)
21
26
 
@@ -30,10 +35,10 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
30
35
  <div className="flex flex-col h-full w-full">
31
36
  <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
32
37
  <div className="flex items-center gap-2">
33
- <DollarSign className="w-4 h-4 text-indigo-500" />
34
- <span className="text-xs font-semibold uppercase tracking-wider text-indigo-500">Cost Insights</span>
38
+ <DollarSign className="w-4 h-4 text-accent-text" />
39
+ <span className="text-xs font-semibold uppercase tracking-wider text-accent-text">Cost Insights</span>
35
40
  {namespaces.length > 0 && (
36
- <span className="badge-sm bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40">
41
+ <span className="badge-sm border border-theme-border bg-accent-muted text-accent-text">
37
42
  {namespaces.length} ns
38
43
  </span>
39
44
  )}
@@ -45,13 +50,14 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
45
50
  <div className="flex items-baseline gap-3 mb-3">
46
51
  <div className="flex items-baseline gap-1">
47
52
  <span className="text-2xl font-bold text-theme-text-primary tabular-nums">
48
- {formatCost(hourlyCost)}
53
+ {formatProjectedMonthlyCost(hourlyCost)}
49
54
  </span>
50
- <span className="text-xs text-theme-text-tertiary">/hr</span>
55
+ <span className="text-xs text-theme-text-tertiary">/mo</span>
51
56
  </div>
52
- <div className="flex items-baseline gap-1 text-theme-text-secondary">
53
- <span className="text-sm font-medium tabular-nums">~{formatCost(monthlyCost)}</span>
54
- <span className="text-[10px] text-theme-text-tertiary">/mo</span>
57
+ <div className="flex items-baseline gap-1.5 text-theme-text-secondary">
58
+ <span className="text-xs font-medium tabular-nums">{formatProjectedDailyRate(hourlyCost)}</span>
59
+ <span className="text-[10px] text-theme-text-quaternary">·</span>
60
+ <span className="text-xs font-medium tabular-nums">{formatCostPerHour(hourlyCost)}</span>
55
61
  </div>
56
62
  </div>
57
63
 
@@ -63,30 +69,25 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
63
69
  <div key={ns.name} className="flex items-center gap-2">
64
70
  <span className="text-[11px] text-theme-text-secondary truncate w-24 shrink-0">{ns.name}</span>
65
71
  <div className="flex-1 h-2 rounded-full overflow-hidden bg-theme-hover">
66
- <div
67
- className="h-full rounded-full bg-indigo-500/60"
68
- style={{ width: `${Math.max(pct, 2)}%` }}
69
- />
72
+ <div className="h-full rounded-full bg-indigo-500/60" style={{ width: `${Math.max(pct, 2)}%` }} />
70
73
  </div>
71
- <span className="text-[10px] text-theme-text-tertiary tabular-nums w-14 text-right shrink-0">
72
- {formatCost(ns.hourlyCost)}/h
74
+ <span className="text-[10px] text-theme-text-tertiary tabular-nums w-20 text-right shrink-0">
75
+ {formatProjectedMonthlyRate(ns.hourlyCost)}
73
76
  </span>
74
77
  </div>
75
78
  )
76
79
  })}
77
80
  {namespaces.length > 5 && (
78
- <span className="text-[10px] text-theme-text-tertiary">
79
- +{namespaces.length - 5} more namespaces
80
- </span>
81
+ <span className="text-[10px] text-theme-text-tertiary">+{namespaces.length - 5} more namespaces</span>
81
82
  )}
82
83
  </div>
83
84
  </div>
84
85
 
85
86
  <div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-between">
86
87
  <span className="text-[10px] text-theme-text-tertiary">
87
- {data.currency ?? 'USD'} &middot; {data.window ?? '1h'} window
88
+ {data.currency ?? 'USD'} &middot; projected monthly from {data.window ?? '1h'} window
88
89
  </span>
89
- <span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-indigo-500">
90
+ <span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-accent-text">
90
91
  OpenCost
91
92
  </span>
92
93
  </div>
@@ -94,19 +95,3 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
94
95
  </div>
95
96
  )
96
97
  }
97
-
98
- function formatCost(value: number): string {
99
- if (value >= 1000) {
100
- return `$${(value / 1000).toFixed(1)}k`
101
- }
102
- if (value >= 1) {
103
- return `$${value.toFixed(2)}`
104
- }
105
- if (value >= 0.01) {
106
- return `$${value.toFixed(3)}`
107
- }
108
- if (value > 0) {
109
- return `$${value.toFixed(4)}`
110
- }
111
- return '$0.00'
112
- }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { RightsizingRow } from '../../api/client'
3
+ import {
4
+ getRightsizingExplanation,
5
+ getRightsizingPresentation,
6
+ RIGHTSIZING_METHODOLOGY,
7
+ RIGHTSIZING_DOCS_URL,
8
+ RIGHTSIZING_SUMMARY,
9
+ } from './RightsizingStrip'
10
+
11
+ const row = (overrides: Partial<RightsizingRow> = {}): RightsizingRow => ({
12
+ container: 'server',
13
+ resource: 'cpu',
14
+ fit: 'balanced',
15
+ confidence: 'high',
16
+ sampleCount: 2016,
17
+ expectedSamples: 2016,
18
+ coverage: 1,
19
+ hpaManaged: false,
20
+ hpaEvidenceAvailable: true,
21
+ oomEvidenceAvailable: true,
22
+ throttleAvailable: true,
23
+ ...overrides,
24
+ })
25
+
26
+ describe('rightsizing presentation', () => {
27
+ it('keeps fit, confidence, and runtime risk as separate concepts', () => {
28
+ expect(getRightsizingPresentation('oversized')).toEqual({
29
+ label: 'Oversized',
30
+ severity: 'info',
31
+ })
32
+ expect(getRightsizingPresentation('under_requested')).toEqual({
33
+ label: 'Under-requested',
34
+ severity: 'warning',
35
+ })
36
+ expect(getRightsizingPresentation('insufficient_history')).toEqual({
37
+ label: 'Insufficient history',
38
+ severity: 'neutral',
39
+ })
40
+ })
41
+
42
+ it('labels query failures independently from insufficient history', () => {
43
+ expect(getRightsizingPresentation('insufficient_history', 'usage query failed')).toEqual({
44
+ label: 'Query failed',
45
+ severity: 'error',
46
+ })
47
+ })
48
+
49
+ it('explains why recommendations are withheld without inventing a zero-risk result', () => {
50
+ expect(getRightsizingExplanation(row({ recommendationReason: 'hpa_managed' }))).toContain(
51
+ 'HPA manages cpu',
52
+ )
53
+ expect(
54
+ getRightsizingExplanation(row({ resource: 'memory', recommendationReason: 'oom_evidence' })),
55
+ ).toContain('OOM evidence')
56
+ expect(
57
+ getRightsizingExplanation(row({ recommendationReason: 'hpa_evidence_unavailable' })),
58
+ ).toContain('could not verify HPA')
59
+ expect(
60
+ getRightsizingExplanation(
61
+ row({
62
+ resource: 'memory',
63
+ recommendationReason: 'oom_evidence_unavailable',
64
+ }),
65
+ ),
66
+ ).toContain('could not verify recent OOM')
67
+ expect(getRightsizingExplanation(row({ throttleAvailable: false }))).toContain(
68
+ 'throttling metrics are unavailable',
69
+ )
70
+ })
71
+
72
+ it('separates the demand target from a conservative reduction step', () => {
73
+ const explanation = getRightsizingExplanation(
74
+ row({
75
+ fit: 'oversized',
76
+ currentRequest: '1',
77
+ recommendedRequest: '500m',
78
+ calculatedRequest: '10m',
79
+ reductionLimited: true,
80
+ bursty: true,
81
+ peak: { name: 'P99', value: 0.2, formatted: '200m' },
82
+ }),
83
+ )
84
+ expect(explanation).toContain('Demand-based target: 10m')
85
+ expect(explanation).toContain('conservative next step')
86
+ expect(explanation).toContain('CPU P99 reached 200m')
87
+ })
88
+
89
+ it('does not revive the misleading efficiency vocabulary', () => {
90
+ const copy = [
91
+ getRightsizingPresentation('balanced').label,
92
+ getRightsizingPresentation('oversized').label,
93
+ getRightsizingExplanation(row({ recommendationReason: 'request_within_fit_range' })),
94
+ ]
95
+ .join(' ')
96
+ .toLowerCase()
97
+ expect(copy).not.toContain('efficien')
98
+ expect(copy).not.toContain('waste')
99
+ })
100
+
101
+ it('sets the workload-level scope and methodology without promising savings or changes', () => {
102
+ expect(RIGHTSIZING_SUMMARY).toContain('this workload')
103
+ expect(RIGHTSIZING_SUMMARY).toContain('not a savings estimate or automatic change')
104
+ expect(RIGHTSIZING_METHODOLOGY).toContain('CPU P95 and memory maximum')
105
+ expect(RIGHTSIZING_METHODOLOGY).toContain('Reductions are staged')
106
+ expect(RIGHTSIZING_METHODOLOGY).toContain('Radar does not change requests')
107
+ expect(RIGHTSIZING_DOCS_URL).toContain('/features/rightsizing')
108
+ })
109
+ })