Initial scaffold: LLM Trainer Dashboard

Full-stack app with FastAPI backend (SSH/paramiko, pipeline streaming,
GPU stats, xterm.js terminal, Ollama model manager) and React + Tailwind
frontend (8 panels: Connection, Documents, Pipeline, QA Pairs, Training,
Terminal, Models, Config). Docker Compose included.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tocmo0nlord
2026-03-21 17:13:32 -04:00
commit 90a6ee6fbf
26 changed files with 2688 additions and 0 deletions

141
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,141 @@
import React, { useState, useEffect, useCallback } from 'react'
import axios from 'axios'
import {
Wifi, WifiOff, Server, FileText, GitBranch,
Table2, Activity, TerminalSquare, Box, Settings, RefreshCw,
} from 'lucide-react'
import ConnectionPanel from './components/ConnectionPanel'
import DocumentManager from './components/DocumentManager'
import PipelineRunner from './components/PipelineRunner'
import QAPairViewer from './components/QAPairViewer'
import TrainingMonitor from './components/TrainingMonitor'
import Terminal from './components/Terminal'
import ModelManager from './components/ModelManager'
import ConfigEditor from './components/ConfigEditor'
const API = '' // vite proxy forwards /api → :8080
const NAV = [
{ id: 'connection', label: 'Connection', icon: Server },
{ id: 'documents', label: 'Documents', icon: FileText },
{ id: 'pipeline', label: 'Pipeline', icon: GitBranch },
{ id: 'pairs', label: 'QA Pairs', icon: Table2 },
{ id: 'training', label: 'Training', icon: Activity },
{ id: 'terminal', label: 'Terminal', icon: TerminalSquare },
{ id: 'models', label: 'Models', icon: Box },
{ id: 'config', label: 'Config', icon: Settings },
]
export default function App() {
const [active, setActive] = useState('connection')
const [connected, setConnected] = useState(false)
const [gpuInfo, setGpuInfo] = useState(null)
const [statusMsg, setStatusMsg] = useState('')
const fetchStatus = useCallback(async () => {
try {
const { data } = await axios.get(`${API}/api/status`)
setConnected(data.connected)
if (data.gpu?.gpus?.length) setGpuInfo(data.gpu.gpus[0])
} catch {
setConnected(false)
}
}, [])
useEffect(() => {
fetchStatus()
const id = setInterval(fetchStatus, 10000)
return () => clearInterval(id)
}, [fetchStatus])
const panels = {
connection: <ConnectionPanel onConnect={fetchStatus} setStatus={setStatusMsg} />,
documents: <DocumentManager connected={connected} />,
pipeline: <PipelineRunner connected={connected} />,
pairs: <QAPairViewer connected={connected} />,
training: <TrainingMonitor connected={connected} gpuInfo={gpuInfo} />,
terminal: <Terminal connected={connected} />,
models: <ModelManager connected={connected} />,
config: <ConfigEditor connected={connected} />,
}
return (
<div className="flex h-screen overflow-hidden bg-[#0f1117] text-slate-200">
{/* ── Sidebar ── */}
<aside className="w-52 flex-shrink-0 flex flex-col bg-[#161b27] border-r border-slate-700/50">
{/* Logo */}
<div className="px-4 py-5 border-b border-slate-700/50">
<h1 className="text-sm font-semibold tracking-widest text-blue-400 uppercase">
LLM Trainer
</h1>
<p className="text-xs text-slate-500 mt-0.5">Dashboard v1.0</p>
</div>
{/* Nav items */}
<nav className="flex-1 py-3 overflow-y-auto">
{NAV.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setActive(id)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors
${active === id
? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-500'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/30'}`}
>
<Icon size={16} />
{label}
</button>
))}
</nav>
{/* Status bar */}
<div className="px-4 py-3 border-t border-slate-700/50 space-y-2">
<div className={`flex items-center gap-2 text-xs font-medium
${connected ? 'text-green-400' : 'text-red-400'}`}>
{connected
? <><Wifi size={12}/> Connected</>
: <><WifiOff size={12}/> Disconnected</>
}
</div>
{gpuInfo && connected && (
<div className="text-xs text-slate-500 space-y-0.5">
<div className="truncate">{gpuInfo.name}</div>
<div className="flex gap-2">
<span className="text-purple-400">{gpuInfo.utilization}%</span>
<span className="text-blue-400">
{gpuInfo.memory_used}/{gpuInfo.memory_total}MB
</span>
<span className="text-orange-400">{gpuInfo.temperature}°C</span>
</div>
</div>
)}
<button
onClick={fetchStatus}
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
<RefreshCw size={11}/> Refresh
</button>
</div>
</aside>
{/* ── Main content ── */}
<main className="flex-1 flex flex-col overflow-hidden">
{/* Top bar */}
<header className="flex items-center justify-between px-6 py-3 border-b border-slate-700/50 bg-[#161b27]">
<h2 className="text-sm font-semibold text-slate-300 capitalize">
{NAV.find(n => n.id === active)?.label}
</h2>
{statusMsg && (
<span className="text-xs text-slate-500 italic">{statusMsg}</span>
)}
</header>
<div className="flex-1 overflow-auto p-6">
{panels[active]}
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,192 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import { Save, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react'
function Field({ label, value, onChange, type = 'text', options = null }) {
return (
<div>
<label className="block text-xs text-slate-500 mb-1">{label}</label>
{options ? (
<select
value={value ?? ''}
onChange={e => onChange(e.target.value)}
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-1.5 text-xs
text-slate-200 focus:outline-none focus:border-blue-500"
>
{options.map(o => <option key={o} value={o}>{o}</option>)}
</select>
) : (
<input
type={type}
value={value ?? ''}
onChange={e => onChange(type === 'number' ? Number(e.target.value) : e.target.value)}
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-1.5 text-xs
text-slate-200 focus:outline-none focus:border-blue-500"
/>
)}
</div>
)
}
function Section({ title, children, defaultOpen = true }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] overflow-hidden">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3 text-xs font-semibold
text-slate-300 hover:bg-slate-700/20 transition-colors"
>
{title}
{open ? <ChevronDown size={13}/> : <ChevronRight size={13}/>}
</button>
{open && <div className="px-4 pb-4 grid grid-cols-2 gap-3">{children}</div>}
</div>
)
}
export default function ConfigEditor({ connected }) {
const [config, setConfig] = useState(null)
const [raw, setRaw] = useState('')
const [mode, setMode] = useState('form') // 'form' | 'raw'
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const load = async () => {
if (!connected) return
setLoading(true); setMsg('')
try {
const { data } = await axios.get('/api/config')
setConfig(data.config || {})
setRaw(data.raw || '')
} catch (err) {
setMsg(`Load failed: ${err.response?.data?.detail || err.message}`)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [connected])
const set = (path, val) => {
setConfig(c => {
const updated = { ...c }
const keys = path.split('.')
let cur = updated
for (let i = 0; i < keys.length - 1; i++) {
cur[keys[i]] = { ...(cur[keys[i]] || {}) }
cur = cur[keys[i]]
}
cur[keys[keys.length - 1]] = val
return updated
})
}
const g = path => {
if (!config) return ''
return path.split('.').reduce((o, k) => (o || {})[k], config) ?? ''
}
const save = async () => {
setSaving(true); setMsg('')
try {
const payload = mode === 'raw'
? await axios.put('/api/config', config) // fallback
: await axios.put('/api/config', config)
setMsg('Configuration saved ✓')
} catch (err) {
setMsg(`Save failed: ${err.response?.data?.detail || err.message}`)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<div className="flex gap-1 bg-[#161b27] rounded-xl p-1">
{['form', 'raw'].map(m => (
<button key={m} onClick={() => setMode(m)}
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors capitalize
${mode === m ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-slate-200'}`}>
{m === 'form' ? 'Form Editor' : 'Raw YAML'}
</button>
))}
</div>
<div className="flex items-center gap-2">
<button onClick={load} disabled={!connected}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300 disabled:opacity-40 transition-colors">
<RefreshCw size={12}/> Reload
</button>
<button onClick={save} disabled={saving || !connected}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium
bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
<Save size={13}/>
{saving ? 'Saving…' : 'Save Config'}
</button>
</div>
</div>
{msg && (
<p className={`text-xs px-3 py-2 rounded-lg border ${
msg.includes('✓')
? 'text-green-400 bg-green-900/10 border-green-700/40'
: 'text-red-400 bg-red-900/10 border-red-700/40'
}`}>{msg}</p>
)}
{!connected ? (
<div className="rounded-xl border border-yellow-700/40 bg-yellow-900/10 px-4 py-3 text-xs text-yellow-400">
Connect to SSH server to edit config.
</div>
) : loading ? (
<p className="text-xs text-slate-500 text-center py-10">Loading config</p>
) : mode === 'raw' ? (
<div>
<p className="text-xs text-slate-500 mb-2">
Editing <code className="text-blue-400">/opt/synthetic/synthetic-data-kit/config.yaml</code> directly
</p>
<textarea
rows={30}
value={raw}
onChange={e => setRaw(e.target.value)}
className="w-full bg-[#0a0d14] border border-slate-700 rounded-xl px-4 py-3 text-xs
font-mono text-slate-200 focus:outline-none focus:border-blue-500 resize-none"
/>
</div>
) : config ? (
<div className="space-y-3">
<Section title="LLM Provider">
<Field label="Provider" value={g('llm.provider')} onChange={v => set('llm.provider', v)}
options={['openai', 'ollama', 'vllm']}/>
<Field label="Model" value={g('llm.model')} onChange={v => set('llm.model', v)}/>
<Field label="API Base URL" value={g('llm.api_base')} onChange={v => set('llm.api_base', v)}/>
<Field label="Max Tokens" value={g('llm.max_tokens')} onChange={v => set('llm.max_tokens', v)} type="number"/>
<Field label="Temperature" value={g('llm.temperature')} onChange={v => set('llm.temperature', v)} type="number"/>
</Section>
<Section title="Generation Settings">
<Field label="Default Pairs" value={g('generation.num_pairs')} onChange={v => set('generation.num_pairs', v)} type="number"/>
<Field label="Default Type" value={g('generation.pair_type')} onChange={v => set('generation.pair_type', v)}
options={['qa', 'summary', 'cot']}/>
<Field label="Quality Threshold" value={g('generation.threshold')} onChange={v => set('generation.threshold', v)} type="number"/>
<Field label="Batch Size" value={g('generation.batch_size')} onChange={v => set('generation.batch_size', v)} type="number"/>
</Section>
<Section title="Paths" defaultOpen={false}>
<Field label="Input Dir" value={g('paths.input')} onChange={v => set('paths.input', v)}/>
<Field label="Parsed Dir" value={g('paths.parsed')} onChange={v => set('paths.parsed', v)}/>
<Field label="Generated Dir" value={g('paths.generated')} onChange={v => set('paths.generated', v)}/>
<Field label="Curated Dir" value={g('paths.curated')} onChange={v => set('paths.curated', v)}/>
<Field label="Final Dir" value={g('paths.final')} onChange={v => set('paths.final', v)}/>
</Section>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,181 @@
import React, { useState } from 'react'
import axios from 'axios'
import { Server, LogIn, LogOut, Key, Eye, EyeOff } from 'lucide-react'
export default function ConnectionPanel({ onConnect, setStatus }) {
const [form, setForm] = useState({
host: '192.168.2.47',
username: 'tocmo0nlord',
password: '',
key_path: '',
port: 22,
})
const [showPass, setShowPass] = useState(false)
const [loading, setLoading] = useState(false)
const [connected, setConnected] = useState(false)
const [error, setError] = useState('')
const [info, setInfo] = useState(null)
const handleChange = e =>
setForm(f => ({ ...f, [e.target.name]: e.target.value }))
const connect = async () => {
setLoading(true); setError('')
try {
const { data } = await axios.post('/api/connect', {
...form,
port: Number(form.port),
password: form.password || undefined,
key_path: form.key_path || undefined,
})
setConnected(true)
setInfo(data)
setStatus(`Connected to ${data.host}`)
onConnect()
} catch (err) {
setError(err.response?.data?.detail || err.message)
} finally {
setLoading(false)
}
}
const disconnect = async () => {
await axios.post('/api/disconnect').catch(() => {})
setConnected(false)
setInfo(null)
setStatus('Disconnected')
onConnect()
}
return (
<div className="max-w-xl mx-auto space-y-6">
{/* Status card */}
<div className={`rounded-xl border p-4 flex items-center gap-3
${connected
? 'border-green-600/40 bg-green-900/10'
: 'border-slate-600/40 bg-slate-800/30'}`}>
<Server size={20} className={connected ? 'text-green-400' : 'text-slate-500'} />
<div>
<p className={`text-sm font-semibold ${connected ? 'text-green-400' : 'text-slate-400'}`}>
{connected ? 'SSH Connected' : 'Not Connected'}
</p>
{info && (
<p className="text-xs text-slate-400">
{info.username}@{info.host}
</p>
)}
</div>
</div>
{/* Form */}
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] p-6 space-y-4">
<h3 className="text-sm font-semibold text-slate-300 mb-1">SSH Credentials</h3>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="block text-xs text-slate-500 mb-1">Host / IP</label>
<input
name="host"
value={form.host}
onChange={handleChange}
disabled={connected}
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-2 text-sm
text-slate-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Port</label>
<input
name="port"
type="number"
value={form.port}
onChange={handleChange}
disabled={connected}
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-2 text-sm
text-slate-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Username</label>
<input
name="username"
value={form.username}
onChange={handleChange}
disabled={connected}
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-2 text-sm
text-slate-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Password</label>
<div className="relative">
<input
name="password"
type={showPass ? 'text' : 'password'}
value={form.password}
onChange={handleChange}
disabled={connected}
placeholder="Leave blank to use SSH key"
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-2 pr-10 text-sm
text-slate-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
<button
type="button"
onClick={() => setShowPass(s => !s)}
className="absolute right-3 top-2.5 text-slate-500 hover:text-slate-300"
>
{showPass ? <EyeOff size={15}/> : <Eye size={15}/>}
</button>
</div>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">
<Key size={11} className="inline mr-1"/>SSH Key Path (optional)
</label>
<input
name="key_path"
value={form.key_path}
onChange={handleChange}
disabled={connected}
placeholder="/home/user/.ssh/id_rsa"
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-2 text-sm
text-slate-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
</div>
{error && (
<p className="text-xs text-red-400 bg-red-900/20 border border-red-700/40 rounded-lg px-3 py-2">
{error}
</p>
)}
{connected ? (
<button
onClick={disconnect}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg
bg-red-600/20 border border-red-600/40 text-red-400 hover:bg-red-600/30
text-sm font-medium transition-colors"
>
<LogOut size={15}/> Disconnect
</button>
) : (
<button
onClick={connect}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg
bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed
text-sm font-medium transition-colors"
>
<LogIn size={15}/>
{loading ? 'Connecting…' : 'Connect'}
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,187 @@
import React, { useState, useEffect, useCallback } from 'react'
import axios from 'axios'
import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, Eye, RefreshCw, FileText, X } from 'lucide-react'
const STAGES = ['input', 'parsed', 'generated', 'curated', 'final']
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export default function DocumentManager({ connected }) {
const [stage, setStage] = useState('input')
const [files, setFiles] = useState([])
const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false)
const [preview, setPreview] = useState(null)
const [error, setError] = useState('')
const fetchFiles = useCallback(async () => {
if (!connected) return
setLoading(true); setError('')
try {
const { data } = await axios.get(`/api/files/${stage}`)
setFiles(data.files)
} catch (err) {
setError(err.response?.data?.detail || err.message)
} finally {
setLoading(false)
}
}, [stage, connected])
useEffect(() => { fetchFiles() }, [fetchFiles])
const onDrop = useCallback(async accepted => {
if (!accepted.length || !connected) return
setUploading(true)
const formData = new FormData()
formData.append('file', accepted[0])
try {
await axios.post('/api/upload', formData)
fetchFiles()
} catch (err) {
setError(err.response?.data?.detail || err.message)
} finally {
setUploading(false)
}
}, [connected, fetchFiles])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
disabled: !connected || stage !== 'input',
multiple: false,
})
const deleteFile = async name => {
if (!window.confirm(`Delete "${name}"?`)) return
await axios.delete(`/api/files/${stage}/${name}`)
fetchFiles()
}
const previewFile = async name => {
const { data } = await axios.get(`/api/files/${stage}/${name}/preview`)
setPreview({ name, content: data.content })
}
return (
<div className="space-y-4">
{/* Stage tabs */}
<div className="flex gap-1 bg-[#161b27] rounded-xl p-1 w-fit">
{STAGES.map(s => (
<button
key={s}
onClick={() => setStage(s)}
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors capitalize
${stage === s
? 'bg-blue-600 text-white'
: 'text-slate-400 hover:text-slate-200'}`}
>
{s}
</button>
))}
</div>
{/* Upload drop zone (input stage only) */}
{stage === 'input' && (
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors cursor-pointer
${isDragActive ? 'border-blue-500 bg-blue-900/10' : 'border-slate-600/50 hover:border-slate-500'}
${!connected ? 'opacity-40 cursor-not-allowed' : ''}`}
>
<input {...getInputProps()} />
<Upload size={24} className="mx-auto mb-2 text-slate-500" />
{uploading
? <p className="text-sm text-blue-400">Uploading</p>
: <p className="text-sm text-slate-400">
{isDragActive ? 'Drop file here' : 'Drag & drop or click to upload to /input'}
</p>
}
</div>
)}
{/* File table */}
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700/50">
<span className="text-xs text-slate-400">{files.length} file(s)</span>
<button
onClick={fetchFiles}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
<RefreshCw size={12}/> Refresh
</button>
</div>
{error && (
<p className="px-4 py-2 text-xs text-red-400">{error}</p>
)}
{loading ? (
<p className="px-4 py-6 text-xs text-slate-500 text-center">Loading</p>
) : files.length === 0 ? (
<p className="px-4 py-6 text-xs text-slate-500 text-center">No files in /{stage}</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500 border-b border-slate-700/40">
<th className="text-left px-4 py-2">Name</th>
<th className="text-right px-4 py-2">Size</th>
<th className="text-right px-4 py-2">Modified</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{files.map(f => (
<tr key={f.name} className="border-b border-slate-700/20 hover:bg-slate-700/10">
<td className="px-4 py-2 flex items-center gap-2">
<FileText size={13} className="text-slate-500 flex-shrink-0"/>
<span className="truncate max-w-xs text-slate-200">{f.name}</span>
</td>
<td className="px-4 py-2 text-right text-slate-400">{formatBytes(f.size)}</td>
<td className="px-4 py-2 text-right text-slate-500">{f.modified}</td>
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => previewFile(f.name)}
className="p-1 rounded hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 transition-colors"
>
<Eye size={13}/>
</button>
<button
onClick={() => deleteFile(f.name)}
className="p-1 rounded hover:bg-red-600/20 text-slate-500 hover:text-red-400 transition-colors"
>
<Trash2 size={13}/>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Preview modal */}
{preview && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6">
<div className="bg-[#161b27] border border-slate-700 rounded-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
<span className="text-sm font-medium text-slate-200">{preview.name}</span>
<button onClick={() => setPreview(null)} className="text-slate-500 hover:text-slate-200">
<X size={16}/>
</button>
</div>
<pre className="flex-1 overflow-auto p-4 text-xs text-slate-300 leading-relaxed whitespace-pre-wrap">
{preview.content}
</pre>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,170 @@
import React, { useState, useEffect, useRef } from 'react'
import axios from 'axios'
import { Box, Download, Trash2, RefreshCw, CheckCircle2, Loader2, XCircle } from 'lucide-react'
const WS_BASE = `ws://${window.location.host}`
function formatSize(bytes) {
if (!bytes) return '—'
const gb = bytes / (1024 ** 3)
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / (1024 ** 2)).toFixed(0)} MB`
}
function PullProgress({ progress }) {
const pct = progress.total > 0 ? Math.round((progress.completed / progress.total) * 100) : 0
return (
<div className="space-y-1">
<div className="flex justify-between text-xs text-slate-400">
<span className="truncate">{progress.status || 'Pulling…'}</span>
<span>{pct}%</span>
</div>
<div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full transition-all" style={{ width: `${pct}%` }}/>
</div>
</div>
)
}
export default function ModelManager({ connected }) {
const [models, setModels] = useState([])
const [loading, setLoading] = useState(false)
const [pullName, setPullName] = useState('')
const [pulling, setPulling] = useState(false)
const [pullStatus, setPullStatus] = useState(null) // null | { status, completed, total }
const [pullResult, setPullResult] = useState(null) // 'success' | 'error'
const [pullError, setPullError] = useState('')
const wsRef = useRef(null)
const fetchModels = async () => {
if (!connected) return
setLoading(true)
try {
const { data } = await axios.get('/api/models')
setModels(data.models)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchModels() }, [connected])
const pullModel = () => {
if (!pullName.trim()) return
if (wsRef.current) wsRef.current.close()
setPulling(true); setPullResult(null); setPullError(''); setPullStatus(null)
const ws = new WebSocket(`${WS_BASE}/api/models/pull?model_name=${encodeURIComponent(pullName)}`)
wsRef.current = ws
ws.onmessage = e => {
try {
const msg = JSON.parse(e.data)
if (msg.error) { setPullError(msg.error); setPullResult('error'); setPulling(false); return }
if (msg.status === 'success') { setPullResult('success'); setPulling(false); fetchModels(); return }
setPullStatus(msg)
} catch {}
}
ws.onerror = () => { setPullResult('error'); setPulling(false) }
}
const deleteModel = async name => {
if (!window.confirm(`Delete model "${name}"?`)) return
try {
await axios.delete(`/api/models/${encodeURIComponent(name)}`)
fetchModels()
} catch (err) {
alert(err.response?.data?.detail || err.message)
}
}
return (
<div className="space-y-5">
{/* Pull model */}
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] p-5 space-y-3">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Pull Model</h3>
<div className="flex gap-2">
<input
value={pullName}
onChange={e => setPullName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && pullModel()}
placeholder="llama3.1:8b, mistral:7b, qwen2:7b…"
className="flex-1 bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-2 text-xs
text-slate-200 focus:outline-none focus:border-blue-500"
/>
<button
onClick={pullModel}
disabled={pulling || !pullName.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium
bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{pulling
? <><Loader2 size={13} className="animate-spin"/> Pulling</>
: <><Download size={13}/> Pull</>
}
</button>
</div>
{pullStatus && pulling && <PullProgress progress={pullStatus}/>}
{pullResult === 'success' && (
<div className="flex items-center gap-2 text-xs text-green-400">
<CheckCircle2 size={13}/> Model pulled successfully
</div>
)}
{pullResult === 'error' && (
<div className="flex items-center gap-2 text-xs text-red-400">
<XCircle size={13}/> {pullError || 'Pull failed'}
</div>
)}
</div>
{/* Model list */}
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700/50">
<span className="text-xs text-slate-400">{models.length} model(s) on Ollama</span>
<button
onClick={fetchModels}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
<RefreshCw size={12}/> Refresh
</button>
</div>
{loading ? (
<p className="py-8 text-center text-xs text-slate-500">Loading</p>
) : models.length === 0 ? (
<p className="py-8 text-center text-xs text-slate-600">
{connected ? 'No models found on Ollama' : 'Connect to SSH server first'}
</p>
) : (
<div className="divide-y divide-slate-700/30">
{models.map(m => (
<div key={m.name} className="flex items-center justify-between px-4 py-3 hover:bg-slate-700/10">
<div className="flex items-center gap-3">
<Box size={15} className="text-blue-400 flex-shrink-0"/>
<div>
<p className="text-xs font-medium text-slate-200">{m.name}</p>
<p className="text-xs text-slate-500">
{formatSize(m.size)}
{m.details?.parameter_size && ` · ${m.details.parameter_size}`}
{m.details?.quantization_level && ` · ${m.details.quantization_level}`}
</p>
</div>
</div>
<button
onClick={() => deleteModel(m.name)}
className="p-1.5 rounded hover:bg-red-600/20 text-slate-500 hover:text-red-400 transition-colors"
>
<Trash2 size={14}/>
</button>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,218 @@
import React, { useState, useRef, useEffect } from 'react'
import axios from 'axios'
import { Play, CheckCircle2, XCircle, Loader2, ChevronDown } from 'lucide-react'
const WS_BASE = `ws://${window.location.host}`
const STAGES = [
{
id: 'ingest',
label: '1 · Ingest',
description: 'Parse raw document → text chunks',
sourceStage: 'input',
endpoint: '/api/pipeline/ingest',
params: [{ name: 'filename', label: 'Input file', type: 'select', stage: 'input' }],
},
{
id: 'create',
label: '2 · Create',
description: 'Generate QA / summary / CoT pairs',
sourceStage: 'parsed',
endpoint: '/api/pipeline/create',
params: [
{ name: 'filename', label: 'Parsed file', type: 'select', stage: 'parsed' },
{ name: 'num_pairs', label: 'Pair count', type: 'number', default: 50 },
{ name: 'pair_type', label: 'Pair type', type: 'select_static',
options: ['qa', 'summary', 'cot'] },
],
},
{
id: 'curate',
label: '3 · Curate',
description: 'Filter pairs by quality score',
sourceStage: 'generated',
endpoint: '/api/pipeline/curate',
params: [
{ name: 'filename', label: 'Generated file', type: 'select', stage: 'generated' },
{ name: 'output_filename', label: 'Output name', type: 'text', default: 'curated.jsonl' },
{ name: 'threshold', label: 'Min score (010)', type: 'number', default: 7 },
],
},
{
id: 'save',
label: '4 · Save As',
description: 'Export final dataset',
sourceStage: 'curated',
endpoint: '/api/pipeline/save',
params: [
{ name: 'filename', label: 'Curated file', type: 'select', stage: 'curated' },
{ name: 'output_filename', label: 'Output name', type: 'text', default: 'dataset.jsonl' },
{ name: 'fmt', label: 'Format', type: 'select_static',
options: ['jsonl', 'csv', 'alpaca'] },
],
},
]
function LogViewer({ lines }) {
const ref = useRef(null)
useEffect(() => { ref.current?.scrollTo(0, ref.current.scrollHeight) }, [lines])
return (
<div ref={ref} className="h-48 overflow-y-auto bg-[#0a0d14] rounded-lg p-3 font-mono text-xs leading-relaxed">
{lines.length === 0
? <span className="text-slate-600">No output yet</span>
: lines.map((l, i) => {
const cls = l.includes('Error') || l.includes('error') || l.includes('Traceback')
? 'text-red-400'
: l.includes('Warning') || l.includes('warning')
? 'text-yellow-400'
: l.includes('✓') || l.includes('done') || l.includes('Done')
? 'text-green-400'
: 'text-slate-300'
return <div key={i} className={cls}>{l}</div>
})
}
</div>
)
}
function StageCard({ stage, connected }) {
const [files, setFiles] = useState([])
const [values, setValues] = useState(() =>
Object.fromEntries(
stage.params.map(p => [p.name, p.default ?? ''])
)
)
const [logs, setLogs] = useState([])
const [status, setStatus] = useState('idle') // idle | running | done | error
const wsRef = useRef(null)
useEffect(() => {
if (!connected) return
const loadStage = async p => {
try {
const { data } = await axios.get(`/api/files/${p.stage}`)
return data.files.map(f => f.name)
} catch { return [] }
}
Promise.all(
stage.params
.filter(p => p.type === 'select')
.map(p => loadStage(p).then(names => ({ name: p.name, names })))
).then(results => {
results.forEach(({ name, names }) => {
setFiles(prev => ({ ...prev, [name]: names }))
setValues(prev => ({ ...prev, [name]: names[0] || '' }))
})
})
}, [connected, stage])
const run = () => {
if (wsRef.current) wsRef.current.close()
setLogs([])
setStatus('running')
const params = new URLSearchParams(values).toString()
const url = `${WS_BASE}${stage.endpoint}?${params}`
const ws = new WebSocket(url)
wsRef.current = ws
ws.onmessage = e => {
const msg = JSON.parse(e.data)
if (msg.type === 'log') setLogs(l => [...l, msg.data])
if (msg.type === 'done') { setStatus('done'); ws.close() }
if (msg.type === 'error') { setStatus('error'); setLogs(l => [...l, `ERROR: ${msg.data}`]); ws.close() }
}
ws.onerror = () => setStatus('error')
ws.onclose = () => { if (status === 'running') setStatus('done') }
}
const setValue = (name, val) => setValues(v => ({ ...v, [name]: val }))
const statusIcon = {
idle: null,
running: <Loader2 size={14} className="text-blue-400 animate-spin"/>,
done: <CheckCircle2 size={14} className="text-green-400"/>,
error: <XCircle size={14} className="text-red-400"/>,
}
return (
<div className={`rounded-xl border bg-[#161b27] p-5 space-y-4
${status === 'done' ? 'border-green-700/40' : status === 'error' ? 'border-red-700/40' : 'border-slate-700/50'}`}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-slate-200">{stage.label}</h3>
<p className="text-xs text-slate-500 mt-0.5">{stage.description}</p>
</div>
{statusIcon[status]}
</div>
{/* Params */}
<div className="grid grid-cols-2 gap-3">
{stage.params.map(p => (
<div key={p.name}>
<label className="block text-xs text-slate-500 mb-1">{p.label}</label>
{p.type === 'select' ? (
<select
value={values[p.name]}
onChange={e => setValue(p.name, e.target.value)}
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-1.5 text-xs
text-slate-200 focus:outline-none focus:border-blue-500"
>
{(files[p.name] || []).map(f => <option key={f} value={f}>{f}</option>)}
</select>
) : p.type === 'select_static' ? (
<select
value={values[p.name]}
onChange={e => setValue(p.name, e.target.value)}
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-1.5 text-xs
text-slate-200 focus:outline-none focus:border-blue-500"
>
{p.options.map(o => <option key={o} value={o}>{o}</option>)}
</select>
) : (
<input
type={p.type}
value={values[p.name]}
onChange={e => setValue(p.name, e.target.value)}
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-1.5 text-xs
text-slate-200 focus:outline-none focus:border-blue-500"
/>
)}
</div>
))}
</div>
<LogViewer lines={logs} />
<button
onClick={run}
disabled={!connected || status === 'running'}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium
bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{status === 'running'
? <><Loader2 size={13} className="animate-spin"/> Running</>
: <><Play size={13}/> Run {stage.label}</>
}
</button>
</div>
)
}
export default function PipelineRunner({ connected }) {
return (
<div className="space-y-4">
{!connected && (
<div className="rounded-xl border border-yellow-700/40 bg-yellow-900/10 px-4 py-3 text-xs text-yellow-400">
Connect to the SSH server first to run pipeline stages.
</div>
)}
{STAGES.map(s => (
<StageCard key={s.id} stage={s} connected={connected} />
))}
</div>
)
}

View File

@@ -0,0 +1,201 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import { RefreshCw, Search, Edit3, X, Check, ChevronDown } from 'lucide-react'
const STAGES = ['generated', 'curated', 'final']
export default function QAPairViewer({ connected }) {
const [stage, setStage] = useState('generated')
const [files, setFiles] = useState([])
const [file, setFile] = useState('')
const [pairs, setPairs] = useState([])
const [filtered, setFiltered] = useState([])
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(false)
const [editing, setEditing] = useState(null) // { index, question, answer }
const [error, setError] = useState('')
useEffect(() => {
if (!connected) return
axios.get(`/api/files/${stage}`)
.then(({ data }) => {
setFiles(data.files.map(f => f.name))
setFile(data.files[0]?.name || '')
})
.catch(() => setFiles([]))
}, [stage, connected])
useEffect(() => {
if (!file || !connected) return
setLoading(true); setError('')
axios.get(`/api/pairs/${file}?stage=${stage}`)
.then(({ data }) => { setPairs(data.pairs); setFiltered(data.pairs) })
.catch(err => setError(err.response?.data?.detail || err.message))
.finally(() => setLoading(false))
}, [file, stage, connected])
useEffect(() => {
if (!search.trim()) { setFiltered(pairs); return }
const q = search.toLowerCase()
setFiltered(pairs.filter(p =>
JSON.stringify(p).toLowerCase().includes(q)
))
}, [search, pairs])
const saveEdit = () => {
if (!editing) return
const updated = [...pairs]
updated[editing.index] = { ...updated[editing.index], ...editing }
setPairs(updated)
setFiltered(updated)
setEditing(null)
}
const scoreColor = score => {
if (!score && score !== 0) return 'text-slate-500'
if (score >= 8) return 'text-green-400'
if (score >= 5) return 'text-yellow-400'
return 'text-red-400'
}
return (
<div className="space-y-4">
{/* Controls */}
<div className="flex flex-wrap gap-3 items-center">
<div className="flex gap-1 bg-[#161b27] rounded-xl p-1">
{STAGES.map(s => (
<button key={s} onClick={() => setStage(s)}
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors capitalize
${stage === s ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-slate-200'}`}>
{s}
</button>
))}
</div>
<div className="relative">
<ChevronDown size={12} className="absolute right-3 top-2.5 text-slate-500 pointer-events-none"/>
<select
value={file}
onChange={e => setFile(e.target.value)}
className="bg-[#161b27] border border-slate-700 rounded-lg px-3 py-1.5 pr-8 text-xs
text-slate-200 focus:outline-none focus:border-blue-500 appearance-none"
>
{files.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div className="relative flex-1 min-w-48">
<Search size={13} className="absolute left-3 top-2 text-slate-500"/>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search pairs…"
className="w-full bg-[#161b27] border border-slate-700 rounded-lg pl-8 pr-3 py-1.5 text-xs
text-slate-200 focus:outline-none focus:border-blue-500"
/>
</div>
<button onClick={() => setFile(f => f)}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300 transition-colors">
<RefreshCw size={12}/> Reload
</button>
<span className="text-xs text-slate-500">{filtered.length} pairs</span>
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
{/* Table */}
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] overflow-hidden">
{loading ? (
<p className="py-10 text-center text-xs text-slate-500">Loading</p>
) : filtered.length === 0 ? (
<p className="py-10 text-center text-xs text-slate-500">No pairs found</p>
) : (
<div className="overflow-auto max-h-[60vh]">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-[#161b27] border-b border-slate-700/40">
<tr className="text-slate-500">
<th className="text-left px-4 py-2 w-8">#</th>
<th className="text-left px-4 py-2 w-1/2">Question</th>
<th className="text-left px-4 py-2">Answer</th>
<th className="text-right px-4 py-2 w-16">Score</th>
<th className="px-4 py-2 w-8"></th>
</tr>
</thead>
<tbody>
{filtered.map((pair, i) => (
<tr key={i} className="border-b border-slate-700/20 hover:bg-slate-700/10 align-top">
<td className="px-4 py-2 text-slate-600">{i + 1}</td>
<td className="px-4 py-2 text-slate-300 max-w-xs">
<p className="line-clamp-3">{pair.question || pair.prompt || JSON.stringify(pair)}</p>
</td>
<td className="px-4 py-2 text-slate-400">
<p className="line-clamp-3">{pair.answer || pair.response || pair.output || '—'}</p>
</td>
<td className={`px-4 py-2 text-right font-medium ${scoreColor(pair.score)}`}>
{pair.score != null ? pair.score : '—'}
</td>
<td className="px-4 py-2">
<button
onClick={() => setEditing({ index: i, ...pair })}
className="p-1 rounded hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 transition-colors"
>
<Edit3 size={12}/>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Edit modal */}
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6">
<div className="bg-[#161b27] border border-slate-700 rounded-xl w-full max-w-2xl space-y-4 p-6">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-200">Edit Pair #{editing.index + 1}</h3>
<button onClick={() => setEditing(null)} className="text-slate-500 hover:text-slate-200">
<X size={16}/>
</button>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Question</label>
<textarea
rows={3}
value={editing.question || editing.prompt || ''}
onChange={e => setEditing(v => ({ ...v, question: e.target.value }))}
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-2 text-xs
text-slate-200 focus:outline-none focus:border-blue-500 resize-none"
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Answer</label>
<textarea
rows={5}
value={editing.answer || editing.response || editing.output || ''}
onChange={e => setEditing(v => ({ ...v, answer: e.target.value }))}
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-2 text-xs
text-slate-200 focus:outline-none focus:border-blue-500 resize-none"
/>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setEditing(null)}
className="px-4 py-2 rounded-lg text-xs text-slate-400 hover:text-slate-200 transition-colors">
Cancel
</button>
<button onClick={saveEdit}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs bg-blue-600 hover:bg-blue-500 transition-colors">
<Check size={13}/> Save
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,146 @@
import React, { useEffect, useRef, useState } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
import { TerminalSquare, RefreshCw } from 'lucide-react'
const WS_BASE = `ws://${window.location.host}`
export default function Terminal({ connected }) {
const containerRef = useRef(null)
const termRef = useRef(null)
const fitRef = useRef(null)
const wsRef = useRef(null)
const [status, setStatus] = useState('idle') // idle | open | closed | error
const boot = () => {
// Clean up existing
if (wsRef.current) wsRef.current.close()
if (termRef.current) { termRef.current.dispose(); termRef.current = null }
if (!containerRef.current) return
const term = new XTerm({
theme: {
background: '#0a0d14',
foreground: '#e2e8f0',
cursor: '#60a5fa',
cursorAccent: '#0a0d14',
black: '#1e2130',
red: '#f87171',
green: '#34d399',
yellow: '#fbbf24',
blue: '#60a5fa',
magenta: '#a78bfa',
cyan: '#22d3ee',
white: '#e2e8f0',
},
fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
cursorBlink: true,
scrollback: 5000,
})
const fit = new FitAddon()
term.loadAddon(fit)
term.loadAddon(new WebLinksAddon())
term.open(containerRef.current)
fit.fit()
termRef.current = term
fitRef.current = fit
const ws = new WebSocket(`${WS_BASE}/api/terminal`)
wsRef.current = ws
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
setStatus('open')
term.writeln('\x1b[1;34mSSH Terminal — connected\x1b[0m')
}
ws.onmessage = e => {
const data = e.data instanceof ArrayBuffer
? new Uint8Array(e.data)
: e.data
term.write(data)
}
ws.onclose = () => {
setStatus('closed')
term.writeln('\r\n\x1b[1;31m[Session closed]\x1b[0m')
}
ws.onerror = () => {
setStatus('error')
term.writeln('\r\n\x1b[1;31m[WebSocket error]\x1b[0m')
}
term.onData(data => {
if (ws.readyState === WebSocket.OPEN) ws.send(data)
})
term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
}
})
}
// Auto-boot when connected
useEffect(() => {
if (connected) boot()
return () => {
wsRef.current?.close()
termRef.current?.dispose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connected])
// Resize observer
useEffect(() => {
const obs = new ResizeObserver(() => fitRef.current?.fit())
if (containerRef.current) obs.observe(containerRef.current)
return () => obs.disconnect()
}, [])
const statusColor = {
idle: 'text-slate-500',
open: 'text-green-400',
closed: 'text-yellow-400',
error: 'text-red-400',
}
return (
<div className="flex flex-col h-full space-y-2">
<div className="flex items-center justify-between">
<div className={`flex items-center gap-2 text-xs ${statusColor[status]}`}>
<TerminalSquare size={14}/>
SSH Terminal {status}
</div>
<button
onClick={boot}
disabled={!connected}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300
disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RefreshCw size={12}/> Reconnect
</button>
</div>
{!connected ? (
<div className="flex-1 flex items-center justify-center rounded-xl border border-slate-700/50 bg-[#0a0d14]">
<p className="text-xs text-slate-600">Connect to SSH server to use the terminal</p>
</div>
) : (
<div
ref={containerRef}
className="flex-1 rounded-xl overflow-hidden border border-slate-700/50"
style={{ minHeight: '480px', background: '#0a0d14' }}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,265 @@
import React, { useState, useEffect, useRef } from 'react'
import axios from 'axios'
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Legend,
} from 'recharts'
import { Play, Square, RefreshCw, Cpu, Thermometer, Zap } from 'lucide-react'
const WS_BASE = `ws://${window.location.host}`
function GpuCard({ gpu }) {
const memPct = Math.round((gpu.memory_used / gpu.memory_total) * 100)
return (
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu size={15} className="text-purple-400"/>
<span className="text-xs font-semibold text-slate-200 truncate max-w-[180px]">{gpu.name}</span>
</div>
<div className="flex items-center gap-1 text-xs text-orange-400">
<Thermometer size={12}/>{gpu.temperature}°C
</div>
</div>
{/* GPU utilization */}
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-500">GPU Utilization</span>
<span className="text-purple-400 font-medium">{gpu.utilization}%</span>
</div>
<div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className="h-full bg-purple-500 rounded-full transition-all"
style={{ width: `${gpu.utilization}%` }}/>
</div>
</div>
{/* VRAM */}
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-500">VRAM</span>
<span className="text-blue-400 font-medium">{gpu.memory_used}/{gpu.memory_total} MB ({memPct}%)</span>
</div>
<div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all"
style={{
width: `${memPct}%`,
background: memPct > 90 ? '#ef4444' : memPct > 70 ? '#f59e0b' : '#3b82f6'
}}/>
</div>
</div>
{gpu.power_draw && (
<div className="flex items-center gap-1 text-xs text-yellow-400">
<Zap size={11}/> {gpu.power_draw.toFixed(0)} W
</div>
)}
</div>
)
}
export default function TrainingMonitor({ connected }) {
const [files, setFiles] = useState([])
const [config, setConfig] = useState({
model_name: 'llama3.1:8b',
dataset_path: '',
output_dir: '/opt/synthetic/output',
num_epochs: 3,
batch_size: 2,
learning_rate: 0.0002,
})
const [gpus, setGpus] = useState([])
const [logs, setLogs] = useState([])
const [lossData, setLossData] = useState([])
const [running, setRunning] = useState(false)
const wsRef = useRef(null)
const logRef = useRef(null)
// Poll GPU stats
useEffect(() => {
if (!connected) return
const poll = async () => {
try {
const { data } = await axios.get('/api/gpu')
setGpus(data.gpus || [])
} catch {}
}
poll()
const id = setInterval(poll, 5000)
return () => clearInterval(id)
}, [connected])
// Load final files for dataset selector
useEffect(() => {
if (!connected) return
axios.get('/api/files/final')
.then(({ data }) => setFiles(data.files.map(f => f.name)))
.catch(() => {})
}, [connected])
useEffect(() => {
logRef.current?.scrollTo(0, logRef.current.scrollHeight)
}, [logs])
const parseLoss = line => {
const m = line.match(/loss[:\s=]+([0-9.]+)/i)
const s = line.match(/step[:\s=]+(\d+)/i)
if (m) {
setLossData(d => [...d, {
step: s ? parseInt(s[1]) : d.length + 1,
loss: parseFloat(m[1]),
}])
}
}
const startTraining = () => {
if (wsRef.current) wsRef.current.close()
setLogs([]); setLossData([]); setRunning(true)
const params = new URLSearchParams({
model_name: config.model_name,
dataset_path: config.dataset_path,
output_dir: config.output_dir,
num_epochs: config.num_epochs,
batch_size: config.batch_size,
learning_rate: config.learning_rate,
})
const ws = new WebSocket(`${WS_BASE}/api/train?${params}`)
wsRef.current = ws
ws.onmessage = e => {
const msg = JSON.parse(e.data)
if (msg.type === 'log') {
setLogs(l => [...l, msg.data])
parseLoss(msg.data)
}
if (msg.type === 'done' || msg.type === 'error') {
setRunning(false)
ws.close()
}
}
ws.onerror = () => setRunning(false)
}
const stopTraining = () => {
wsRef.current?.close()
setRunning(false)
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* GPU Cards */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">GPU Status</h3>
<button onClick={() => connected && axios.get('/api/gpu').then(r => setGpus(r.data.gpus))}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300 transition-colors">
<RefreshCw size={11}/> Refresh
</button>
</div>
{gpus.length === 0
? <p className="text-xs text-slate-600 py-4 text-center">
{connected ? 'No GPU data available' : 'Connect to view GPU stats'}
</p>
: gpus.map((g, i) => <GpuCard key={i} gpu={g}/>)
}
</div>
{/* Training config */}
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] p-4 space-y-3">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Training Config</h3>
{[
{ key: 'model_name', label: 'Base Model', type: 'text' },
{ key: 'output_dir', label: 'Output Dir', type: 'text' },
{ key: 'num_epochs', label: 'Epochs', type: 'number' },
{ key: 'batch_size', label: 'Batch Size', type: 'number' },
{ key: 'learning_rate',label: 'Learning Rate', type: 'number', step: '0.00001' },
].map(f => (
<div key={f.key}>
<label className="block text-xs text-slate-500 mb-1">{f.label}</label>
<input
type={f.type}
step={f.step}
value={config[f.key]}
onChange={e => setConfig(c => ({ ...c, [f.key]: e.target.value }))}
disabled={running}
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-1.5 text-xs
text-slate-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
</div>
))}
<div>
<label className="block text-xs text-slate-500 mb-1">Dataset File</label>
<select
value={config.dataset_path}
onChange={e => setConfig(c => ({ ...c, dataset_path: e.target.value }))}
disabled={running}
className="w-full bg-[#0f1117] border border-slate-700 rounded-lg px-3 py-1.5 text-xs
text-slate-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
>
<option value="">Select dataset</option>
{files.map(f => <option key={f} value={`/opt/synthetic/synthetic-data-kit/data/final/${f}`}>{f}</option>)}
</select>
</div>
<div className="flex gap-2 pt-1">
<button
onClick={startTraining}
disabled={!connected || running || !config.dataset_path}
className="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-xs font-medium
bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Play size={13}/> Start Training
</button>
{running && (
<button
onClick={stopTraining}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium
bg-red-600/20 border border-red-700/40 text-red-400 hover:bg-red-600/30 transition-colors"
>
<Square size={13}/> Stop
</button>
)}
</div>
</div>
</div>
{/* Loss chart */}
{lossData.length > 1 && (
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] p-4">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Training Loss</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={lossData}>
<CartesianGrid strokeDasharray="3 3" stroke="#2d3348"/>
<XAxis dataKey="step" tick={{ fill: '#64748b', fontSize: 10 }} label={{ value: 'Step', fill: '#64748b', fontSize: 10, position: 'insideBottom', offset: -5 }}/>
<YAxis tick={{ fill: '#64748b', fontSize: 10 }}/>
<Tooltip contentStyle={{ background: '#161b27', border: '1px solid #334155', borderRadius: '8px', fontSize: '12px' }}/>
<Line type="monotone" dataKey="loss" stroke="#3b82f6" strokeWidth={2} dot={false}/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Log output */}
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] overflow-hidden">
<div className="px-4 py-2 border-b border-slate-700/50 text-xs text-slate-500">Training Log</div>
<div ref={logRef} className="h-48 overflow-y-auto bg-[#0a0d14] p-3 font-mono text-xs leading-relaxed">
{logs.length === 0
? <span className="text-slate-600">No training output yet</span>
: logs.map((l, i) => (
<div key={i} className={
l.includes('Error') ? 'text-red-400' :
l.includes('loss') ? 'text-blue-300' : 'text-slate-300'
}>{l}</div>
))
}
</div>
</div>
</div>
)
}

41
frontend/src/index.css Normal file
View File

@@ -0,0 +1,41 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
body {
margin: 0;
background-color: #0f1117;
color: #e2e8f0;
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
}
/* xterm.js overrides */
.xterm-viewport {
overflow-y: auto !important;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #1e2130;
}
::-webkit-scrollbar-thumb {
background: #3b4563;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #4f5e80;
}
/* Log viewer */
.log-line-error { color: #f87171; }
.log-line-warning { color: #fbbf24; }
.log-line-success { color: #34d399; }
.log-line-info { color: #60a5fa; }

10
frontend/src/index.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)