11import { Tooltip , TooltipContent , TooltipProvider , TooltipTrigger } from '@/components/ui/tooltip' ;
2+ import { CheckCircle2 , AlertCircle , AlertTriangle , XCircle } from 'lucide-react' ;
23import { cn } from '@/lib/utils' ;
34
4- export type GpuFitStatus = 'fits' | 'warning' | 'exceeds' | 'unknown' ;
5+ /**
6+ * Fit levels inspired by llmfit — four tiers from perfect to too-tight.
7+ *
8+ * Perfect – recommended memory met with headroom (< 60% utilization)
9+ * Good – fits comfortably (60-80% utilization)
10+ * Marginal – fits but tight, may leave no headroom (80-100%)
11+ * TooLarge – exceeds cluster GPU capacity (> 100%)
12+ */
13+ export type GpuFitLevel = 'perfect' | 'good' | 'marginal' | 'too-large' | 'unknown' ;
514
615interface GpuFitIndicatorProps {
716 estimatedGpuMemoryGb ?: number ;
@@ -10,114 +19,178 @@ interface GpuFitIndicatorProps {
1019}
1120
1221/**
13- * Determine GPU fit status based on estimated memory vs cluster capacity
22+ * Determine GPU fit level based on estimated memory vs cluster capacity
1423 */
1524// eslint-disable-next-line react-refresh/only-export-components
16- export function getGpuFitStatus (
25+ export function getGpuFitLevel (
1726 estimatedGpuMemoryGb ?: number ,
1827 clusterCapacityGb ?: number
19- ) : GpuFitStatus {
20- if ( estimatedGpuMemoryGb === undefined ) {
28+ ) : GpuFitLevel {
29+ if ( estimatedGpuMemoryGb === undefined || clusterCapacityGb === undefined || clusterCapacityGb <= 0 ) {
2130 return 'unknown' ;
2231 }
23-
24- if ( clusterCapacityGb === undefined ) {
25- return 'unknown' ;
26- }
27-
28- // If estimated memory exceeds capacity, it won't fit
29- if ( estimatedGpuMemoryGb > clusterCapacityGb ) {
30- return 'exceeds' ;
31- }
32-
33- // If within 80% of capacity, show warning (tight fit)
34- if ( estimatedGpuMemoryGb > clusterCapacityGb * 0.8 ) {
35- return 'warning' ;
36- }
37-
38- return 'fits' ;
32+
33+ const utilization = estimatedGpuMemoryGb / clusterCapacityGb ;
34+
35+ if ( utilization > 1 ) return 'too-large' ;
36+ if ( utilization > 0.8 ) return 'marginal' ;
37+ if ( utilization > 0.6 ) return 'good' ;
38+ return 'perfect' ;
3939}
4040
41+ // Keep the old export name/type for backward compat with tests
42+ export type GpuFitStatus = GpuFitLevel ;
43+ // eslint-disable-next-line react-refresh/only-export-components
44+ export function getGpuFitStatus (
45+ estimatedGpuMemoryGb ?: number ,
46+ clusterCapacityGb ?: number
47+ ) : GpuFitStatus {
48+ return getGpuFitLevel ( estimatedGpuMemoryGb , clusterCapacityGb ) ;
49+ }
50+
51+ const fitConfig : Record < GpuFitLevel , {
52+ label : string ;
53+ bar : string ;
54+ text : string ;
55+ icon : typeof CheckCircle2 ;
56+ detail : ( estGb : number , capGb : number , pct : number ) => string ;
57+ } > = {
58+ perfect : {
59+ label : 'Perfect fit' ,
60+ bar : 'from-green-400 to-emerald-500' ,
61+ text : 'text-green-400' ,
62+ icon : CheckCircle2 ,
63+ detail : ( est , cap , pct ) =>
64+ `${ est . toFixed ( 1 ) } GB of ${ cap } GB VRAM (${ pct } % utilization) — plenty of headroom` ,
65+ } ,
66+ good : {
67+ label : 'Good fit' ,
68+ bar : 'from-cyan-400 to-green-400' ,
69+ text : 'text-cyan-400' ,
70+ icon : CheckCircle2 ,
71+ detail : ( est , cap , pct ) =>
72+ `${ est . toFixed ( 1 ) } GB of ${ cap } GB VRAM (${ pct } % utilization) — fits comfortably` ,
73+ } ,
74+ marginal : {
75+ label : 'Tight fit' ,
76+ bar : 'from-amber-400 to-orange-400' ,
77+ text : 'text-amber-400' ,
78+ icon : AlertCircle ,
79+ detail : ( est , cap , pct ) =>
80+ `${ est . toFixed ( 1 ) } GB of ${ cap } GB VRAM (${ pct } % utilization) — may not leave headroom for KV cache` ,
81+ } ,
82+ 'too-large' : {
83+ label : 'Won\u2019t fit' ,
84+ bar : 'from-red-400 to-red-500' ,
85+ text : 'text-red-400' ,
86+ icon : XCircle ,
87+ detail : ( est , cap , pct ) =>
88+ `Needs ${ est . toFixed ( 1 ) } GB but cluster only has ${ cap } GB VRAM (${ pct } % — exceeds capacity)` ,
89+ } ,
90+ unknown : {
91+ label : 'Unknown' ,
92+ bar : 'from-slate-600 to-slate-500' ,
93+ text : 'text-slate-400' ,
94+ icon : AlertTriangle ,
95+ detail : ( ) => 'Cluster GPU capacity unknown — cannot determine fit' ,
96+ } ,
97+ } ;
98+
4199/**
42- * Get tooltip message for GPU fit status
100+ * Compute upgrade delta — how much more VRAM is needed, inspired by llmfit's plan mode.
101+ * Returns null when the model already fits comfortably.
43102 */
44- function getTooltipMessage (
45- status : GpuFitStatus ,
103+ // eslint-disable-next-line react-refresh/only-export-components
104+ export function getUpgradeDelta (
46105 estimatedGpuMemoryGb ?: number ,
47106 clusterCapacityGb ?: number
48- ) : string {
49- switch ( status ) {
50- case 'fits' :
51- return `Estimated ${ estimatedGpuMemoryGb } GB VRAM fits within cluster capacity (${ clusterCapacityGb } GB available)` ;
52- case 'warning' :
53- return `Estimated ${ estimatedGpuMemoryGb } GB VRAM is close to cluster capacity (${ clusterCapacityGb } GB available). Deployment may be tight.` ;
54- case 'exceeds' :
55- return `Estimated ${ estimatedGpuMemoryGb } GB VRAM exceeds cluster capacity (${ clusterCapacityGb } GB available). Deployment may fail.` ;
56- case 'unknown' :
57- if ( estimatedGpuMemoryGb === undefined ) {
58- return 'Model size unknown. Deploy with caution.' ;
59- }
60- return 'Cluster GPU capacity unknown. Cannot determine fit.' ;
107+ ) : { additionalGb : number ; targetGb : number } | null {
108+ if (
109+ estimatedGpuMemoryGb === undefined ||
110+ clusterCapacityGb === undefined ||
111+ clusterCapacityGb <= 0
112+ ) {
113+ return null ;
61114 }
62- }
115+ // Only show delta when model exceeds or is marginal (>80%)
116+ if ( estimatedGpuMemoryGb <= clusterCapacityGb * 0.8 ) return null ;
63117
64- const gradientMap : Record < GpuFitStatus , string > = {
65- fits : 'from-cyan-400 to-green-400' ,
66- warning : 'from-cyan-400 to-amber-400' ,
67- exceeds : 'from-cyan-400 to-red-400' ,
68- unknown : 'from-slate-600 to-slate-500' ,
69- } ;
118+ // Target: 20% headroom beyond what the model needs (mirrors llmfit's 1.2x recommended)
119+ const targetGb = Math . ceil ( estimatedGpuMemoryGb * 1.2 ) ;
120+ const additionalGb = Math . max ( targetGb - clusterCapacityGb , 0 ) ;
121+ if ( additionalGb === 0 ) return null ;
122+ return { additionalGb , targetGb } ;
123+ }
70124
71125/**
72- * GPU Fit Indicator component — bar-based
73- * Shows a gradient progress bar indicating whether model fits cluster GPU capacity
126+ * GPU Fit Indicator — shows whether a model fits cluster GPU capacity.
127+ * Inspired by llmfit's fit-level approach: Perfect / Good / Marginal / Won't Fit.
74128 */
75- export function GpuFitIndicator ( {
76- estimatedGpuMemoryGb,
129+ export function GpuFitIndicator ( {
130+ estimatedGpuMemoryGb,
77131 clusterCapacityGb,
78- className
132+ className
79133} : GpuFitIndicatorProps ) {
80- const status = getGpuFitStatus ( estimatedGpuMemoryGb , clusterCapacityGb ) ;
81- const message = getTooltipMessage ( status , estimatedGpuMemoryGb , clusterCapacityGb ) ;
134+ const level = getGpuFitLevel ( estimatedGpuMemoryGb , clusterCapacityGb ) ;
135+ const config = fitConfig [ level ] ;
136+ const Icon = config . icon ;
137+ const upgradeDelta = getUpgradeDelta ( estimatedGpuMemoryGb , clusterCapacityGb ) ;
82138
83- // Calculate fill percentage (cap at 100%)
84139 const fillPercent =
85140 estimatedGpuMemoryGb !== undefined && clusterCapacityGb !== undefined && clusterCapacityGb > 0
86141 ? Math . min ( ( estimatedGpuMemoryGb / clusterCapacityGb ) * 100 , 100 )
87142 : 0 ;
88143
89- const label =
90- estimatedGpuMemoryGb !== undefined && clusterCapacityGb !== undefined
91- ? `${ estimatedGpuMemoryGb . toFixed ( 1 ) } GB / ${ clusterCapacityGb } GB`
92- : estimatedGpuMemoryGb !== undefined
93- ? `~${ estimatedGpuMemoryGb . toFixed ( 1 ) } GB`
94- : undefined ;
144+ const utilizationPct = Math . round ( fillPercent ) ;
145+
146+ const tooltipDetail =
147+ estimatedGpuMemoryGb !== undefined && clusterCapacityGb !== undefined && clusterCapacityGb > 0
148+ ? config . detail ( estimatedGpuMemoryGb , clusterCapacityGb , utilizationPct )
149+ : estimatedGpuMemoryGb === undefined
150+ ? 'Model size unknown — deploy with caution'
151+ : config . detail ( estimatedGpuMemoryGb ?? 0 , clusterCapacityGb ?? 0 , utilizationPct ) ;
95152
96153 return (
97154 < TooltipProvider >
98155 < Tooltip >
99156 < TooltipTrigger asChild >
100157 < div className = { cn ( 'w-full' , className ) } >
101- { label && (
102- < div className = "flex justify-end mb-1" >
103- < span className = "text-xs text-slate-400" > { label } </ span >
104- </ div >
105- ) }
158+ { /* Fit label + utilization */ }
159+ < div className = "flex items-center justify-between mb-1" >
160+ < span className = { cn ( 'flex items-center gap-1 text-xs font-medium' , config . text ) } >
161+ < Icon className = "h-3 w-3" />
162+ { config . label }
163+ </ span >
164+ { estimatedGpuMemoryGb !== undefined && clusterCapacityGb !== undefined && (
165+ < span className = "text-xs text-slate-400 tabular-nums" >
166+ { estimatedGpuMemoryGb . toFixed ( 1 ) } / { clusterCapacityGb } GB
167+ </ span >
168+ ) }
169+ </ div >
170+
171+ { /* Progress bar */ }
106172 < div className = "w-full h-1.5 rounded-full bg-white/5" >
107173 { fillPercent > 0 && (
108174 < div
109175 className = { cn (
110176 'h-full rounded-full bg-gradient-to-r transition-all duration-300' ,
111- gradientMap [ status ]
177+ config . bar
112178 ) }
113179 style = { { width : `${ fillPercent } %` } }
114180 />
115181 ) }
116182 </ div >
183+
184+ { /* Upgrade delta — how much more VRAM is needed */ }
185+ { upgradeDelta && (
186+ < p className = "text-xs text-slate-500 mt-1" >
187+ +{ upgradeDelta . additionalGb } GB VRAM needed for comfortable fit
188+ </ p >
189+ ) }
117190 </ div >
118191 </ TooltipTrigger >
119192 < TooltipContent >
120- < p className = "max-w-xs" > { message } </ p >
193+ < p className = "max-w-xs" > { tooltipDetail } </ p >
121194 </ TooltipContent >
122195 </ Tooltip >
123196 </ TooltipProvider >
0 commit comments