Skip to content

Commit c3b7faa

Browse files
authored
fix(frontend): apply missing UI redesign changes from #132 (#140)
1 parent a6f4ea6 commit c3b7faa

17 files changed

Lines changed: 211 additions & 234 deletions

frontend/src/components/deployments/DeploymentList.tsx

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,6 @@ interface DeploymentListProps {
2121
isLoading?: boolean
2222
}
2323

24-
function getProviderBadgeClass(provider: string): string {
25-
switch (provider) {
26-
case 'kuberay': return 'bg-blue-500/10 text-blue-400 border-blue-500/20'
27-
case 'kaito': return 'bg-purple-500/10 text-purple-400 border-purple-500/20'
28-
case 'llmd': return 'bg-orange-500/10 text-orange-400 border-orange-500/20'
29-
case 'dynamo': return 'bg-teal-500/10 text-teal-400 border-teal-500/20'
30-
default: return 'bg-green-500/10 text-green-400 border-green-500/20'
31-
}
32-
}
33-
3424
function getStatusDotColor(phase: DeploymentStatus['phase']): string {
3525
switch (phase) {
3626
case 'Running': return 'bg-green-500'
@@ -51,16 +41,6 @@ function getReplicaColorClass(deployment: DeploymentStatus): string {
5141
return deployment.replicas.ready === deployment.replicas.desired ? 'text-green-400' : 'text-amber-400'
5242
}
5343

54-
function getProviderDisplayName(provider: string): string {
55-
switch (provider) {
56-
case 'kuberay': return 'KubeRay'
57-
case 'kaito': return 'KAITO'
58-
case 'llmd': return 'llm-d'
59-
case 'dynamo': return 'Dynamo'
60-
default: return provider
61-
}
62-
}
63-
6444
/**
6545
* Format replica status for display
6646
* For disaggregated mode, shows "P: x/y, D: x/y" format
@@ -134,8 +114,8 @@ export function DeploymentList({ deployments, isLoading }: DeploymentListProps)
134114
{deployments.map((deployment, index) => (
135115
<div
136116
key={deployment.name}
137-
className="glass-panel !p-4 flex items-center gap-4 group hover:bg-white/5 hover:border-white/10 transition-all duration-200"
138-
style={{ animationDelay: `${index * 50}ms` }}
117+
className="glass-panel !p-4 flex items-center gap-4 group hover:bg-white/5 hover:border-white/10 transition-all duration-200 animate-slide-up"
118+
style={{ animationDelay: `${Math.min(index, 12) * 50}ms`, animationFillMode: 'both' }}
139119
>
140120
{/* Status dot */}
141121
<div className="shrink-0">
@@ -159,9 +139,8 @@ export function DeploymentList({ deployments, isLoading }: DeploymentListProps)
159139
<div className="hidden md:flex items-center gap-2">
160140
<Badge
161141
variant="secondary"
162-
className={getProviderBadgeClass(deployment.provider)}
163142
>
164-
{getProviderDisplayName(deployment.provider)}
143+
{deployment.provider}
165144
</Badge>
166145
<Badge variant="outline">
167146
{deployment.engine ? (deployment.engine === 'llamacpp' ? 'Llama.cpp' : deployment.engine.toUpperCase()) : 'Pending'}

frontend/src/components/layout/MainLayout.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { ReactNode, useState, useCallback, useEffect, createContext, useContext } from 'react'
22
import { Sidebar } from './Sidebar'
33
import { Header } from './Header'
4-
import { useProviderTheme } from '@/hooks/useProviderTheme'
54

65
interface SidebarContextValue {
76
isOpen: boolean
@@ -25,8 +24,6 @@ interface MainLayoutProps {
2524
}
2625

2726
export function MainLayout({ children }: MainLayoutProps) {
28-
useProviderTheme()
29-
3027
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
3128

3229
const toggle = useCallback(() => {

frontend/src/components/layout/Sidebar.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
5757
)}
5858
>
5959
{/* Logo */}
60-
<div className="flex h-16 items-center border-b border-white/5 px-4 shrink-0">
60+
<div className="flex h-14 items-center border-b border-white/5 px-4 shrink-0">
6161
<Link
6262
to="/"
6363
className="flex items-center gap-2 min-w-0"
@@ -102,9 +102,14 @@ export function Sidebar({ onNavigate }: SidebarProps) {
102102
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground active:scale-[0.98]'
103103
)}
104104
>
105-
{isActive && (
106-
<span className="absolute left-0 w-1 h-8 rounded-full bg-primary" />
107-
)}
105+
<span
106+
className={cn(
107+
'absolute left-0 w-1 h-8 rounded-full bg-primary transition-all duration-200 ease-out origin-center',
108+
isActive
109+
? 'opacity-100 scale-y-100'
110+
: 'opacity-0 scale-y-0'
111+
)}
112+
/>
108113
<item.icon
109114
className={cn(
110115
'h-5 w-5 shrink-0 transition-transform duration-150',

frontend/src/components/metrics/MetricCard.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,14 @@ export function MetricGrid({ metrics, className }: MetricGridProps) {
6161

6262
return (
6363
<div className={cn("grid gap-4 sm:grid-cols-2 lg:grid-cols-3", className)}>
64-
{metrics.map((metric) => (
65-
<MetricCard key={metric.name} metric={metric} />
64+
{metrics.map((metric, index) => (
65+
<div
66+
key={metric.name}
67+
className="animate-slide-up"
68+
style={{ animationDelay: `${Math.min(index, 12) * 50}ms`, animationFillMode: 'both' }}
69+
>
70+
<MetricCard metric={metric} />
71+
</div>
6672
))}
6773
</div>
6874
)

frontend/src/components/models/GpuFitIndicator.tsx

Lines changed: 138 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
2+
import { CheckCircle2, AlertCircle, AlertTriangle, XCircle } from 'lucide-react';
23
import { 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

615
interface 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>

frontend/src/components/models/ModelGrid.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ export function ModelGrid({ models }: ModelGridProps) {
3131
{models.map((model, index) => (
3232
<div
3333
key={model.id}
34-
className="animate-fade-in"
35-
style={{ animationDelay: `${index * 50}ms` }}
34+
className="animate-slide-up"
35+
style={{ animationDelay: `${Math.min(index, 12) * 50}ms`, animationFillMode: 'both' }}
3636
>
3737
<ModelCard model={model} />
3838
</div>

frontend/src/hooks/useProviderTheme.ts

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)