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

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Python
__pycache__/
*.py[cod]
*.pyo
.env
.venv/
venv/
*.egg-info/
dist/
build/
# Node
node_modules/
frontend/dist/
frontend/.vite/
# OS
.DS_Store
Thumbs.db
desktop.ini
# IDE
.vscode/
.idea/
*.swp
# Secrets
*.pem
*.key
id_rsa
id_ed25519
.env.local
.env.production

12
backend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

39
backend/gpu.py Normal file
View File

@@ -0,0 +1,39 @@
from ssh_client import ssh_manager
def get_gpu_stats() -> dict:
"""Query nvidia-smi on the remote host and return parsed GPU info."""
try:
if not ssh_manager.is_connected():
return {"gpus": [], "error": "Not connected"}
out, err, code = ssh_manager.execute(
"nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total,"
"temperature.gpu,power.draw --format=csv,noheader,nounits",
use_conda=False
)
if code != 0:
return {"gpus": [], "error": err.strip() or "nvidia-smi failed"}
gpus = []
for line in out.strip().split("\n"):
if not line.strip():
continue
parts = [p.strip() for p in line.split(",")]
if len(parts) >= 5:
try:
gpus.append({
"name": parts[0],
"utilization": int(parts[1]),
"memory_used": int(parts[2]),
"memory_total": int(parts[3]),
"temperature": int(parts[4]),
"power_draw": float(parts[5]) if len(parts) > 5 else None,
})
except (ValueError, IndexError):
pass
return {"gpus": gpus, "error": None}
except Exception as e:
return {"gpus": [], "error": str(e)}

450
backend/main.py Normal file
View File

@@ -0,0 +1,450 @@
import asyncio
import json
import os
import tempfile
import threading
from pathlib import Path
from typing import Optional
import httpx
import yaml
from fastapi import FastAPI, File, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from gpu import get_gpu_stats
from pipeline import STAGE_DIRS, CONFIG_PATH, ingest_cmd, create_cmd, curate_cmd, save_as_cmd, train_cmd
from ssh_client import ssh_manager
# ──────────────────────────────────────────────────────────────────────────────
app = FastAPI(title="LLM Trainer API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.2.47:11434")
# ──────────────────────────────────────────────────────────────────────────────
# Pydantic models
# ──────────────────────────────────────────────────────────────────────────────
class ConnectRequest(BaseModel):
host: str = "192.168.2.47"
username: str = "tocmo0nlord"
password: Optional[str] = None
key_path: Optional[str] = None
port: int = 22
class TrainRequest(BaseModel):
model_name: str = "llama3.1:8b"
dataset_path: str
output_dir: str = "/opt/synthetic/output"
num_epochs: int = 3
batch_size: int = 2
learning_rate: float = 2e-4
# ──────────────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────────────
def _require_ssh():
if not ssh_manager.is_connected():
raise HTTPException(status_code=503, detail="Not connected to SSH server")
async def _stream_ws(websocket: WebSocket, command: str, use_conda: bool = True):
"""Run a remote command and stream output lines over WebSocket."""
await websocket.accept()
loop = asyncio.get_event_loop()
queue: asyncio.Queue = asyncio.Queue()
def _worker():
try:
for line in ssh_manager.execute_stream(command, use_conda=use_conda):
asyncio.run_coroutine_threadsafe(
queue.put({"type": "log", "data": line}), loop
)
asyncio.run_coroutine_threadsafe(
queue.put({"type": "done", "data": "Command completed."}), loop
)
except Exception as exc:
asyncio.run_coroutine_threadsafe(
queue.put({"type": "error", "data": str(exc)}), loop
)
threading.Thread(target=_worker, daemon=True).start()
try:
while True:
msg = await queue.get()
await websocket.send_json(msg)
if msg["type"] in ("done", "error"):
break
except WebSocketDisconnect:
pass
# ──────────────────────────────────────────────────────────────────────────────
# Connection
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/connect")
async def connect(req: ConnectRequest):
try:
ssh_manager.connect(
host=req.host,
username=req.username,
password=req.password,
key_path=req.key_path,
port=req.port,
)
return {"status": "connected", "host": req.host, "username": req.username}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
@app.post("/api/disconnect")
async def disconnect():
ssh_manager.disconnect()
return {"status": "disconnected"}
@app.get("/api/status")
async def status():
connected = ssh_manager.is_connected()
gpu = get_gpu_stats() if connected else {"gpus": [], "error": "Not connected"}
return {
"connected": connected,
"host": ssh_manager.host if connected else None,
"username": ssh_manager.username if connected else None,
"gpu": gpu,
}
# ──────────────────────────────────────────────────────────────────────────────
# GPU
# ──────────────────────────────────────────────────────────────────────────────
@app.get("/api/gpu")
async def gpu():
_require_ssh()
return get_gpu_stats()
# ──────────────────────────────────────────────────────────────────────────────
# File management
# ──────────────────────────────────────────────────────────────────────────────
@app.get("/api/files/{stage}")
async def list_files(stage: str):
if stage not in STAGE_DIRS:
raise HTTPException(status_code=400, detail=f"Unknown stage: {stage}")
_require_ssh()
out, _, code = ssh_manager.execute(
f"ls -la '{STAGE_DIRS[stage]}' 2>/dev/null | tail -n +2", use_conda=False
)
files = []
for line in out.strip().split("\n"):
if not line.strip() or line.startswith("total"):
continue
parts = line.split()
if len(parts) >= 9 and not parts[0].startswith("d"):
files.append({
"name": " ".join(parts[8:]),
"size": int(parts[4]),
"modified": f"{parts[5]} {parts[6]} {parts[7]}",
})
return {"stage": stage, "directory": STAGE_DIRS[stage], "files": files}
@app.delete("/api/files/{stage}/{filename}")
async def delete_file(stage: str, filename: str):
if stage not in STAGE_DIRS:
raise HTTPException(status_code=400, detail=f"Unknown stage: {stage}")
_require_ssh()
path = f"{STAGE_DIRS[stage]}/{filename}"
_, err, code = ssh_manager.execute(f"rm -f '{path}'", use_conda=False)
if code != 0:
raise HTTPException(status_code=500, detail=err)
return {"deleted": filename}
@app.get("/api/files/{stage}/{filename}/preview")
async def preview_file(stage: str, filename: str, lines: int = 120):
if stage not in STAGE_DIRS:
raise HTTPException(status_code=400, detail=f"Unknown stage: {stage}")
_require_ssh()
path = f"{STAGE_DIRS[stage]}/{filename}"
out, err, code = ssh_manager.execute(f"head -n {lines} '{path}'", use_conda=False)
if code != 0:
raise HTTPException(status_code=500, detail=err)
return {"filename": filename, "content": out}
@app.post("/api/upload")
async def upload_file(file: UploadFile = File(...)):
_require_ssh()
suffix = Path(file.filename).suffix
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(await file.read())
tmp_path = tmp.name
try:
remote_path = f"{STAGE_DIRS['input']}/{file.filename}"
ssh_manager.upload_file(tmp_path, remote_path)
return {"uploaded": file.filename, "remote_path": remote_path}
finally:
os.unlink(tmp_path)
# ──────────────────────────────────────────────────────────────────────────────
# Pipeline (WebSocket streaming)
# ──────────────────────────────────────────────────────────────────────────────
@app.websocket("/api/pipeline/ingest")
async def ws_ingest(websocket: WebSocket, filename: str):
if not ssh_manager.is_connected():
await websocket.accept()
await websocket.send_json({"type": "error", "data": "Not connected"})
return
cmd = ingest_cmd(f"{STAGE_DIRS['input']}/{filename}")
await _stream_ws(websocket, cmd)
@app.websocket("/api/pipeline/create")
async def ws_create(websocket: WebSocket, filename: str,
num_pairs: int = 50, pair_type: str = "qa"):
if not ssh_manager.is_connected():
await websocket.accept()
await websocket.send_json({"type": "error", "data": "Not connected"})
return
cmd = create_cmd(f"{STAGE_DIRS['parsed']}/{filename}", num_pairs, pair_type)
await _stream_ws(websocket, cmd)
@app.websocket("/api/pipeline/curate")
async def ws_curate(websocket: WebSocket, filename: str,
output_filename: str, threshold: float = 7.0):
if not ssh_manager.is_connected():
await websocket.accept()
await websocket.send_json({"type": "error", "data": "Not connected"})
return
cmd = curate_cmd(
f"{STAGE_DIRS['generated']}/{filename}",
f"{STAGE_DIRS['curated']}/{output_filename}",
threshold,
)
await _stream_ws(websocket, cmd)
@app.websocket("/api/pipeline/save")
async def ws_save(websocket: WebSocket, filename: str,
output_filename: str, fmt: str = "jsonl"):
if not ssh_manager.is_connected():
await websocket.accept()
await websocket.send_json({"type": "error", "data": "Not connected"})
return
cmd = save_as_cmd(
f"{STAGE_DIRS['curated']}/{filename}",
f"{STAGE_DIRS['final']}/{output_filename}",
fmt,
)
await _stream_ws(websocket, cmd)
# ──────────────────────────────────────────────────────────────────────────────
# QA Pairs viewer
# ──────────────────────────────────────────────────────────────────────────────
@app.get("/api/pairs/{filename}")
async def get_pairs(filename: str, stage: str = "generated"):
_require_ssh()
path = f"{STAGE_DIRS.get(stage, STAGE_DIRS['generated'])}/{filename}"
out, err, code = ssh_manager.execute(f"cat '{path}'", use_conda=False)
if code != 0:
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
pairs = []
for line in out.strip().split("\n"):
if not line.strip():
continue
try:
pairs.append(json.loads(line))
except json.JSONDecodeError:
pass
return {"filename": filename, "count": len(pairs), "pairs": pairs}
# ──────────────────────────────────────────────────────────────────────────────
# Config editor
# ──────────────────────────────────────────────────────────────────────────────
@app.get("/api/config")
async def get_config():
_require_ssh()
try:
raw = ssh_manager.read_remote_file(CONFIG_PATH)
return {"config": yaml.safe_load(raw), "raw": raw}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
@app.put("/api/config")
async def update_config(payload: dict):
_require_ssh()
try:
ssh_manager.write_remote_file(CONFIG_PATH, yaml.dump(payload, default_flow_style=False))
return {"status": "updated"}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
# ──────────────────────────────────────────────────────────────────────────────
# Training (WebSocket streaming)
# ──────────────────────────────────────────────────────────────────────────────
@app.websocket("/api/train")
async def ws_train(
websocket: WebSocket,
model_name: str = "llama3.1:8b",
dataset_path: str = "",
output_dir: str = "/opt/synthetic/output",
num_epochs: int = 3,
batch_size: int = 2,
learning_rate: float = 2e-4,
):
if not ssh_manager.is_connected():
await websocket.accept()
await websocket.send_json({"type": "error", "data": "Not connected"})
return
cmd = train_cmd(model_name, dataset_path, output_dir, num_epochs, batch_size, learning_rate)
await _stream_ws(websocket, cmd)
# ──────────────────────────────────────────────────────────────────────────────
# Interactive terminal (xterm.js ↔ SSH shell)
# ──────────────────────────────────────────────────────────────────────────────
@app.websocket("/api/terminal")
async def ws_terminal(websocket: WebSocket):
await websocket.accept()
if not ssh_manager.is_connected():
await websocket.send_text("\r\nNot connected to SSH server.\r\n")
return
channel = None
try:
channel = ssh_manager.open_shell_channel()
async def ssh_to_ws():
while True:
if channel.recv_ready():
data = channel.recv(4096)
if not data:
break
await websocket.send_bytes(data)
elif channel.exit_status_ready():
break
else:
await asyncio.sleep(0.02)
async def ws_to_ssh():
try:
while True:
data = await websocket.receive_bytes()
channel.send(data)
except WebSocketDisconnect:
pass
await asyncio.gather(ssh_to_ws(), ws_to_ssh())
except WebSocketDisconnect:
pass
except Exception as exc:
try:
await websocket.send_text(f"\r\nError: {exc}\r\n")
except Exception:
pass
finally:
if channel:
try:
channel.close()
except Exception:
pass
# ──────────────────────────────────────────────────────────────────────────────
# Model manager (Ollama)
# ──────────────────────────────────────────────────────────────────────────────
@app.get("/api/models")
async def list_models():
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{OLLAMA_URL}/api/tags")
resp.raise_for_status()
return {"models": resp.json().get("models", [])}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
@app.websocket("/api/models/pull")
async def ws_pull_model(websocket: WebSocket, model_name: str):
await websocket.accept()
try:
async with httpx.AsyncClient(timeout=600) as client:
async with client.stream(
"POST", f"{OLLAMA_URL}/api/pull",
json={"name": model_name, "stream": True}
) as resp:
async for line in resp.aiter_lines():
if line.strip():
try:
await websocket.send_json(json.loads(line))
except json.JSONDecodeError:
pass
await websocket.send_json({"status": "success"})
except WebSocketDisconnect:
pass
except Exception as exc:
try:
await websocket.send_json({"status": "error", "error": str(exc)})
except Exception:
pass
@app.delete("/api/models/{model_name:path}")
async def delete_model(model_name: str):
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.request(
"DELETE", f"{OLLAMA_URL}/api/delete",
json={"name": model_name}
)
resp.raise_for_status()
return {"deleted": model_name}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
# ──────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080, reload=True)

73
backend/pipeline.py Normal file
View File

@@ -0,0 +1,73 @@
# ──────────────────────────────────────────────────────────────────────────────
# Pipeline paths & command builders
# These match the remote Ubuntu server layout from LLM_TRAINER_APP_SCOPE.md
# ──────────────────────────────────────────────────────────────────────────────
SDK_BIN = (
"/home/tocmo0nlord/miniconda3/envs/synthetic-data/bin/synthetic-data-kit"
)
CONFIG_PATH = "/opt/synthetic/synthetic-data-kit/config.yaml"
DATA_BASE = "/opt/synthetic/synthetic-data-kit/data"
STAGE_DIRS = {
"input": f"{DATA_BASE}/input",
"parsed": f"{DATA_BASE}/parsed",
"generated": f"{DATA_BASE}/generated",
"curated": f"{DATA_BASE}/curated",
"final": f"{DATA_BASE}/final",
}
TRAIN_SCRIPT = "/opt/synthetic/train.py"
OUTPUT_BASE = "/opt/synthetic/output"
def _sdk(subcommand: str, *args) -> str:
return f"{SDK_BIN} --config {CONFIG_PATH} {subcommand} {' '.join(args)}"
def ingest_cmd(input_file: str) -> str:
return _sdk("ingest", f"'{input_file}'", "-o", STAGE_DIRS["parsed"])
def create_cmd(parsed_file: str, num_pairs: int = 50, pair_type: str = "qa") -> str:
return _sdk(
"create", f"'{parsed_file}'",
"-o", STAGE_DIRS["generated"],
"--type", pair_type,
"--num-pairs", str(num_pairs),
)
def curate_cmd(generated_file: str, output_file: str, threshold: float = 7.0) -> str:
return _sdk(
"curate", f"'{generated_file}'",
"-o", f"'{output_file}'",
"--threshold", str(threshold),
)
def save_as_cmd(curated_file: str, output_file: str, fmt: str = "jsonl") -> str:
return _sdk(
"save-as", f"'{curated_file}'",
"-f", fmt,
"-o", f"'{output_file}'",
)
def train_cmd(
model_name: str,
dataset_path: str,
output_dir: str = OUTPUT_BASE,
num_epochs: int = 3,
batch_size: int = 2,
learning_rate: float = 2e-4,
) -> str:
return (
f"python3 {TRAIN_SCRIPT} "
f"--model '{model_name}' "
f"--dataset '{dataset_path}' "
f"--output '{output_dir}' "
f"--epochs {num_epochs} "
f"--batch-size {batch_size} "
f"--lr {learning_rate}"
)

7
backend/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
paramiko==3.4.0
httpx==0.27.0
pyyaml==6.0.1
python-multipart==0.0.9
websockets==12.0

177
backend/ssh_client.py Normal file
View File

@@ -0,0 +1,177 @@
import base64
import threading
import time
from typing import Optional
import paramiko
class SSHClient:
def __init__(self):
self.client: Optional[paramiko.SSHClient] = None
self.connected = False
self.host = ""
self.username = ""
self.port = 22
self._keepalive_thread: Optional[threading.Thread] = None
self._stop_keepalive = threading.Event()
self._lock = threading.Lock()
def connect(self, host: str, username: str, password: str = None,
key_path: str = None, port: int = 22) -> bool:
with self._lock:
try:
if self.client:
self.client.close()
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
kwargs = {"hostname": host, "port": port, "username": username, "timeout": 10}
if key_path:
kwargs["key_filename"] = key_path
if password:
kwargs["password"] = password
self.client.connect(**kwargs)
self.connected = True
self.host = host
self.username = username
self.port = port
self._stop_keepalive.clear()
self._keepalive_thread = threading.Thread(target=self._keepalive_loop, daemon=True)
self._keepalive_thread.start()
return True
except Exception as e:
self.connected = False
raise e
def disconnect(self):
self._stop_keepalive.set()
if self.client:
self.client.close()
self.connected = False
def _keepalive_loop(self):
while not self._stop_keepalive.wait(30):
try:
transport = self.client.get_transport()
if transport and transport.is_active():
transport.send_ignore()
else:
self.connected = False
break
except Exception:
self.connected = False
break
def execute(self, command: str, use_conda: bool = True) -> tuple:
if not self.is_connected():
raise Exception("Not connected to SSH server")
if use_conda:
full_cmd = (
f"source /home/{self.username}/miniconda3/etc/profile.d/conda.sh && "
f"conda activate synthetic-data && {command}"
)
else:
full_cmd = command
_, stdout, stderr = self.client.exec_command(full_cmd)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
exit_code = stdout.channel.recv_exit_status()
return out, err, exit_code
def execute_stream(self, command: str, use_conda: bool = True):
"""Generator that yields output lines from a command."""
if not self.is_connected():
raise Exception("Not connected to SSH server")
if use_conda:
full_cmd = (
f"source /home/{self.username}/miniconda3/etc/profile.d/conda.sh && "
f"conda activate synthetic-data && {command}"
)
else:
full_cmd = command
transport = self.client.get_transport()
channel = transport.open_session()
channel.get_pty()
channel.exec_command(full_cmd)
buffer = b""
while True:
if channel.recv_ready():
data = channel.recv(4096)
if not data:
break
buffer += data
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
yield line.decode("utf-8", errors="replace") + "\n"
elif channel.exit_status_ready():
if buffer:
yield buffer.decode("utf-8", errors="replace")
break
else:
time.sleep(0.05)
channel.close()
def open_shell_channel(self, term: str = "xterm-256color", width: int = 220, height: int = 50):
"""Open an interactive shell channel for the terminal panel."""
if not self.is_connected():
raise Exception("Not connected to SSH server")
transport = self.client.get_transport()
channel = transport.open_session()
channel.get_pty(term=term, width=width, height=height)
channel.invoke_shell()
# Auto-activate conda env
activate = (
f"source /home/{self.username}/miniconda3/etc/profile.d/conda.sh && "
f"conda activate synthetic-data\n"
)
channel.send(activate)
return channel
def upload_file(self, local_path: str, remote_path: str):
if not self.is_connected():
raise Exception("Not connected to SSH server")
sftp = self.client.open_sftp()
try:
sftp.put(local_path, remote_path)
finally:
sftp.close()
def read_remote_file(self, remote_path: str) -> str:
out, err, code = self.execute(f"cat '{remote_path}'", use_conda=False)
if code != 0:
raise Exception(f"Failed to read file: {err}")
return out
def write_remote_file(self, remote_path: str, content: str):
encoded = base64.b64encode(content.encode()).decode()
cmd = f"echo '{encoded}' | base64 -d > '{remote_path}'"
out, err, code = self.execute(cmd, use_conda=False)
if code != 0:
raise Exception(f"Failed to write file: {err}")
def is_connected(self) -> bool:
try:
if self.client:
transport = self.client.get_transport()
if transport and transport.is_active():
return True
except Exception:
pass
self.connected = False
return False
# Singleton shared across all routes
ssh_manager = SSHClient()

29
docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
version: "3.9"
services:
backend:
build: ./backend
container_name: llm-trainer-backend
restart: unless-stopped
ports:
- "8080:8080"
environment:
- OLLAMA_URL=http://192.168.2.47:11434
networks:
- llm-net
frontend:
build: ./frontend
container_name: llm-trainer-frontend
restart: unless-stopped
ports:
- "3000:80"
depends_on:
- backend
networks:
- llm-net
networks:
llm-net:
driver: bridge

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

18
frontend/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LLM Trainer Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

22
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy API and WebSocket calls to the backend
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "llm-trainer-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.7",
"@xterm/xterm": "^5.3.0",
"@xterm/addon-fit": "^0.8.0",
"@xterm/addon-web-links": "^0.9.0",
"recharts": "^2.10.3",
"react-dropzone": "^14.2.3",
"lucide-react": "^0.344.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.1.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

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>
)

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
fontFamily: {
mono: ['JetBrains Mono', 'Fira Code', 'Consolas', 'monospace'],
},
},
},
plugins: [],
}

16
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
ws: true,
},
},
},
})