Files
gpu-services/hub-web/src/pages/Dashboard.tsx
Hyungi Ahn 72dd6ac4d1 feat: Phase 2 웹 UI — Dashboard + Chat 페이지
- hub-web: Vite + React + Tailwind + React Router
- Dashboard: 백엔드 상태 카드, GPU 모니터, 모델 테이블 (15초 자동 갱신)
- Chat: 모델 선택 드롭다운 + SSE 스트리밍 + Markdown 렌더링
- Login: 비밀번호 인증 (httpOnly 쿠키)
- Docker: nginx 기반 정적 서빙 + Caddy 연동
- Caddyfile: flush_interval -1 (SSE 버퍼링 방지)
- proxy_ollama: embed usage 더미값 0→1 (SDK 호환)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:40:16 +09:00

97 lines
3.9 KiB
TypeScript

import { useState, useEffect } from 'react';
import type { BackendHealth, GpuInfo, Model } from '../lib/api';
import { getHealth, getModels } from '../lib/api';
export default function Dashboard() {
const [backends, setBackends] = useState<BackendHealth[]>([]);
const [gpu, setGpu] = useState<GpuInfo | null>(null);
const [models, setModels] = useState<Model[]>([]);
const refresh = async () => {
const [health, mdls] = await Promise.all([getHealth(), getModels()]);
setBackends(health.backends);
setGpu(health.gpu);
setModels(mdls);
};
useEffect(() => {
refresh();
const id = setInterval(refresh, 15000);
return () => clearInterval(id);
}, []);
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Backends</h2>
<button onClick={refresh} className="text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]">Refresh</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{backends.map(b => (
<div key={b.id} className="rounded-lg border border-[hsl(var(--border))] p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium">{b.id}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${b.status === 'healthy' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
{b.status}
</span>
</div>
<div className="text-sm text-[hsl(var(--muted-foreground))]">{b.type}</div>
<div className="text-sm">{b.models.join(', ')}</div>
{b.latency_ms > 0 && <div className="text-xs text-[hsl(var(--muted-foreground))]">{b.latency_ms}ms</div>}
</div>
))}
</div>
{gpu && (
<>
<h2 className="text-xl font-semibold">GPU</h2>
<div className="rounded-lg border border-[hsl(var(--border))] p-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<Stat label="Utilization" value={`${gpu.utilization}%`} />
<Stat label="Temperature" value={`${gpu.temperature}C`} />
<Stat label="VRAM" value={`${gpu.vram_used}/${gpu.vram_total} MB`} />
<Stat label="Power" value={`${gpu.power_draw}W`} />
</div>
</>
)}
<h2 className="text-xl font-semibold">Models</h2>
<div className="rounded-lg border border-[hsl(var(--border))] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[hsl(var(--muted))]">
<tr>
<th className="text-left px-4 py-2">Model</th>
<th className="text-left px-4 py-2">Backend</th>
<th className="text-left px-4 py-2">Capabilities</th>
<th className="text-left px-4 py-2">Status</th>
</tr>
</thead>
<tbody>
{models.map(m => (
<tr key={`${m.backend_id}-${m.id}`} className="border-t border-[hsl(var(--border))]">
<td className="px-4 py-2 font-mono">{m.id}</td>
<td className="px-4 py-2">{m.owned_by}</td>
<td className="px-4 py-2">{m.capabilities.join(', ')}</td>
<td className="px-4 py-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${m.backend_status === 'healthy' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
{m.backend_status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-xs text-[hsl(var(--muted-foreground))]">{label}</div>
<div className="text-lg font-semibold">{value}</div>
</div>
);
}