- 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>
97 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|