731 lines
32 KiB
HTML
731 lines
32 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Training — Nexus One AI</title>
|
||
<link rel="stylesheet" href="style.css?v=4">
|
||
<style>
|
||
/* ── Shared layout ────────────────────────────────────────── */
|
||
.tr-card { background:var(--navy2); border:1px solid var(--bdr); border-radius:14px; padding:28px; margin-bottom:28px; }
|
||
.tr-card-title { font-size:16px; font-weight:700; color:var(--ink); margin-bottom:4px; }
|
||
.tr-card-sub { font-size:13px; color:var(--lt); margin-bottom:20px; }
|
||
|
||
.tr-btn { padding:9px 18px; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; border:none; transition:all .15s; font-family:inherit; }
|
||
.tr-btn.primary { background:var(--purple); color:var(--ink); }
|
||
.tr-btn.primary:hover { background:#6D28D9; }
|
||
.tr-btn.ghost { background:rgba(255,255,255,.03); color:var(--med); border:1px solid var(--bdr); }
|
||
.tr-btn.ghost:hover { border-color:var(--purple); color:var(--purple); }
|
||
.tr-btn.danger { background:rgba(185,28,28,.08); color:#B91C1C; border:1px solid rgba(239,68,68,.25); }
|
||
.tr-btn.danger:hover { background:#DC2626; color:var(--ink); }
|
||
.tr-btn:disabled { opacity:.45; cursor:not-allowed; }
|
||
|
||
.tr-table-wrap { border:1px solid var(--bdr); border-radius:10px; overflow:hidden; }
|
||
table.tr-table { width:100%; border-collapse:collapse; }
|
||
.tr-table th { background:rgba(255,255,255,.03); padding:10px 14px; text-align:left; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:var(--lt); border-bottom:1px solid var(--bdr); }
|
||
.tr-table td { padding:12px 14px; border-bottom:1px solid var(--bdr); font-size:13px; color:var(--ink); vertical-align:middle; }
|
||
.tr-table tr:last-child td { border-bottom:none; }
|
||
.tr-table tr:hover td { background:rgba(255,255,255,.03); }
|
||
.tr-empty { text-align:center; color:var(--lt); padding:40px 0; font-size:13px; }
|
||
|
||
/* ── Dataset upload zone ──────────────────────────────────── */
|
||
.upload-zone { border:2px dashed var(--bdr); border-radius:10px; padding:32px; text-align:center; cursor:pointer; transition:.2s; background:rgba(255,255,255,.03); }
|
||
.upload-zone:hover, .upload-zone.drag { border-color:var(--purple); background:rgba(124,58,237,.12); }
|
||
.upload-zone input { display:none; }
|
||
.upload-zone .uz-icon { font-size:32px; margin-bottom:8px; }
|
||
.upload-zone .uz-label { font-size:14px; font-weight:600; color:var(--ink); margin-bottom:4px; }
|
||
.upload-zone .uz-hint { font-size:12px; color:var(--lt); }
|
||
.upload-progress { margin-top:10px; font-size:13px; color:var(--purple); display:none; }
|
||
|
||
/* ── Launch form ──────────────────────────────────────────── */
|
||
.launch-form { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
|
||
@media(max-width:640px){ .launch-form { grid-template-columns:1fr; } }
|
||
.launch-form .full { grid-column:1/-1; }
|
||
.lf-label { display:block; font-size:12px; font-weight:600; color:var(--med); margin-bottom:5px; }
|
||
.lf-input, .lf-select { width:100%; padding:9px 12px; border:1.5px solid var(--bdr); border-radius:8px; font-size:13px; font-family:inherit; outline:none; color:var(--ink); box-sizing:border-box; }
|
||
.lf-input:focus, .lf-select:focus { border-color:var(--purple); }
|
||
.lf-hint { font-size:11px; color:var(--lt); margin-top:3px; }
|
||
.param-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; }
|
||
@media(max-width:640px){ .param-grid { grid-template-columns:1fr 1fr; } }
|
||
|
||
/* ── Status badges ────────────────────────────────────────── */
|
||
.badge-status { display:inline-block; font-size:10px; font-weight:700; padding:2px 9px; border-radius:10px; text-transform:uppercase; letter-spacing:.4px; }
|
||
.badge-status.running { background:rgba(34,197,94,.15); color:#15803D; }
|
||
.badge-status.completed { background:#DBEAFE; color:#1D4ED8; }
|
||
.badge-status.failed { background:#FEE2E2; color:#B91C1C; }
|
||
.badge-status.pending { background:rgba(234,179,8,.15); color:#92400E; }
|
||
.badge-status.cancelled { background:#F3F4F6; color:#6B7280; }
|
||
|
||
/* ── Log viewer ───────────────────────────────────────────── */
|
||
.log-wrap { background:#0F1117; border-radius:8px; padding:14px 16px; font-family:monospace; font-size:11px; line-height:1.6; color:#A3E635; max-height:220px; overflow-y:auto; margin-top:14px; display:none; white-space:pre-wrap; word-break:break-all; }
|
||
.log-toggle { font-size:12px; color:var(--purple); cursor:pointer; font-weight:600; margin-top:8px; display:inline-block; }
|
||
|
||
/* ── Loss chart ───────────────────────────────────────────── */
|
||
.chart-wrap { margin-top:16px; display:none; }
|
||
.chart-wrap canvas { max-width:100%; border-radius:8px; }
|
||
|
||
/* ── Job row expanded ─────────────────────────────────────── */
|
||
.job-detail { padding:14px 20px 18px; background:#F8FAFC; border-top:1px solid var(--bdr); display:none; }
|
||
.job-detail.open { display:block; }
|
||
.job-meta { display:flex; gap:24px; flex-wrap:wrap; font-size:12px; color:var(--lt); margin-bottom:10px; }
|
||
.job-meta span { display:flex; gap:4px; }
|
||
.job-meta strong { color:var(--ink); }
|
||
|
||
/* ── Toast ────────────────────────────────────────────────── */
|
||
#tr-toast { position:fixed; bottom:24px; right:24px; background:#1E3A5F; color:var(--ink); padding:12px 20px; border-radius:10px; font-size:13px; display:none; z-index:9999; box-shadow:0 8px 24px rgba(0,0,0,.2); max-width:340px; }
|
||
#tr-toast.err { background:#7F1D1D; }
|
||
|
||
/* ── Toolbar ──────────────────────────────────────────────── */
|
||
.tr-toolbar { display:flex; align-items:center; gap:10px; margin-bottom:16px; flex-wrap:wrap; }
|
||
</style>
|
||
</head>
|
||
<body data-role="admin">
|
||
|
||
<header class="topnav">
|
||
<a href="index.html" class="brand">Nexus One <span>AI</span></a>
|
||
<nav>
|
||
<a href="index.html">Home</a>
|
||
<a href="quickstart.html">Quick Start</a>
|
||
<a href="prompts.html">Prompt Library</a>
|
||
<a href="usecases.html">Use Cases</a>
|
||
<span class="nav-sep"></span>
|
||
<div class="nav-dropdown">
|
||
<button class="nav-drop-btn">Help ▾</button>
|
||
<div class="nav-drop-menu">
|
||
<span class="nav-drop-cat">LEARN /</span>
|
||
<a href="quickstart.html">Quick Start</a>
|
||
<a href="models.html">Models</a>
|
||
<span class="nav-drop-cat">SUPPORT /</span>
|
||
<a href="troubleshooting.html">Troubleshoot</a>
|
||
<a href="faq.html">FAQ</a>
|
||
<span class="nav-drop-cat">MORE /</span>
|
||
<a href="glossary.html">Glossary</a>
|
||
<a href="whats-new.html">What's New</a>
|
||
</div>
|
||
</div>
|
||
<div class="nav-dropdown">
|
||
<button class="nav-drop-btn active">Admin ▾</button>
|
||
<div class="nav-drop-menu nav-drop-menu-wide">
|
||
<span class="nav-drop-cat">DOCS /</span>
|
||
<a href="security.html">Security & Privacy</a>
|
||
<a href="admin.html">Admin Guide</a>
|
||
<span class="nav-drop-cat">MONITOR /</span>
|
||
<a href="dashboard.html">Dashboard</a>
|
||
<a href="analytics.html">Usage Analytics</a>
|
||
<a href="audit.html">Audit Log</a>
|
||
<a href="feedback.html">Feedback & Ratings</a>
|
||
<span class="nav-drop-cat">MANAGE /</span>
|
||
<a href="users.html">Users</a>
|
||
<a href="teams.html">Teams</a>
|
||
<a href="models-admin.html">Model Manager</a>
|
||
<a href="training.html" class="active">Training</a>
|
||
<a href="knowledge.html">Knowledge Base</a>
|
||
<span class="nav-drop-cat">TOOLS /</span>
|
||
<a href="apikeys.html">API Keys</a>
|
||
<a href="benchmark.html">Benchmarking</a>
|
||
<a href="model-compare.html">Model Compare</a>
|
||
<a href="api-playground.html">API Playground</a>
|
||
<a href="guardrails.html">Guardrails</a>
|
||
<a href="rag-quality.html">RAG Quality</a>
|
||
<a href="router.html">Model Router</a>
|
||
<a href="connectors.html">Connectors</a>
|
||
<span class="nav-drop-cat">SYSTEM /</span>
|
||
<a href="console.html">Console</a>
|
||
<a href="settings.html">Settings</a>
|
||
</div>
|
||
</div>
|
||
<div class="nav-dropdown">
|
||
<button class="nav-drop-btn">AI Tools ▾</button>
|
||
<div class="nav-drop-menu">
|
||
<span class="nav-drop-cat">INTELLIGENCE /</span>
|
||
<a href="documents.html">Document Intelligence</a>
|
||
<a href="chat-multi.html">Multimodal Chat</a>
|
||
<a href="prompt-studio.html">Prompt Studio</a>
|
||
<a href="meeting.html">Meeting Assistant</a>
|
||
<span class="nav-drop-cat">AUTOMATION /</span>
|
||
<a href="agents.html">Agent Builder</a>
|
||
<a href="schedules.html">Scheduled Jobs</a>
|
||
<a href="workflows.html">Workflow Automation</a>
|
||
<span class="nav-drop-cat">QUALITY /</span>
|
||
<a href="evals.html">AI Eval Suite</a>
|
||
<a href="chatrooms.html">Chat Rooms</a>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
<a href="notifications.html" style="position:relative">🔔</a>
|
||
<span class="badge" data-brand="tier">Basic Tier</span>
|
||
<div id="nav-org-logo" class="nav-org-logo"></div>
|
||
</header>
|
||
|
||
<div class="page-hero">
|
||
<div class="label">Admin · Fine-Tuning</div>
|
||
<h1>Model Training</h1>
|
||
<p>Upload datasets, launch QLoRA fine-tuning jobs, and monitor training progress live.</p>
|
||
</div>
|
||
|
||
<div class="content">
|
||
|
||
<!-- ── 1. Dataset Manager ── -->
|
||
<div class="tr-card">
|
||
<div class="tr-card-title">📂 Dataset Manager</div>
|
||
<div class="tr-card-sub">Upload JSONL or CSV files. JSONL rows must have a <code>text</code> field, or <code>prompt</code>/<code>completion</code> pair, or <code>instruction</code>/<code>output</code> pair.</div>
|
||
|
||
<div class="upload-zone" id="upload-zone" onclick="document.getElementById('file-input').click()">
|
||
<input type="file" id="file-input" accept=".jsonl,.json,.csv" onchange="uploadDataset(this.files[0])">
|
||
<div class="uz-icon">📤</div>
|
||
<div class="uz-label">Click to upload or drag & drop</div>
|
||
<div class="uz-hint">.jsonl · .json · .csv — max 500 MB</div>
|
||
</div>
|
||
<div class="upload-progress" id="upload-progress">Uploading…</div>
|
||
|
||
<div style="margin-top:20px;">
|
||
<div class="tr-toolbar">
|
||
<button class="tr-btn ghost" onclick="loadDatasets()">↺ Refresh</button>
|
||
</div>
|
||
<div class="tr-table-wrap">
|
||
<table class="tr-table">
|
||
<thead>
|
||
<tr><th>Filename</th><th>Rows</th><th>Size</th><th>Uploaded</th><th></th></tr>
|
||
</thead>
|
||
<tbody id="ds-tbody">
|
||
<tr><td colspan="5" class="tr-empty">Loading…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── 2. Launch Fine-Tune ── -->
|
||
<div class="tr-card">
|
||
<div class="tr-card-title">🚀 Launch Fine-Tune Job</div>
|
||
<div class="tr-card-sub">Uses QLoRA (4-bit quantization + LoRA adapters). Requires a GPU with ≥ 8 GB VRAM.</div>
|
||
|
||
<div class="launch-form">
|
||
<div class="full">
|
||
<label class="lf-label">Job Name</label>
|
||
<input type="text" id="lf-name" class="lf-input" placeholder="e.g. mistral-govquery-v1">
|
||
</div>
|
||
|
||
<div>
|
||
<label class="lf-label">Base Model</label>
|
||
<select id="lf-model" class="lf-select">
|
||
<option value="">Loading models…</option>
|
||
</select>
|
||
<div class="lf-hint">Models currently in Ollama</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="lf-label">Dataset</label>
|
||
<select id="lf-dataset" class="lf-select">
|
||
<option value="">Select a dataset…</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="full">
|
||
<label class="lf-label" style="margin-bottom:10px;">QLoRA Parameters</label>
|
||
<div class="param-grid">
|
||
<div>
|
||
<label class="lf-label">Epochs</label>
|
||
<input type="number" id="lf-epochs" class="lf-input" value="3" min="1" max="50">
|
||
<div class="lf-hint">Training passes over dataset</div>
|
||
</div>
|
||
<div>
|
||
<label class="lf-label">Learning Rate</label>
|
||
<input type="number" id="lf-lr" class="lf-input" value="0.0002" step="0.00001" min="0.000001">
|
||
<div class="lf-hint">Recommended: 1e-4 to 3e-4</div>
|
||
</div>
|
||
<div>
|
||
<label class="lf-label">Batch Size</label>
|
||
<input type="number" id="lf-batch" class="lf-input" value="4" min="1" max="64">
|
||
<div class="lf-hint">Per-device, lower = less VRAM</div>
|
||
</div>
|
||
<div>
|
||
<label class="lf-label">LoRA Rank (r)</label>
|
||
<input type="number" id="lf-lora-r" class="lf-input" value="16" min="4" max="256" step="4">
|
||
<div class="lf-hint">Higher = more capacity</div>
|
||
</div>
|
||
<div>
|
||
<label class="lf-label">LoRA Alpha</label>
|
||
<input type="number" id="lf-lora-alpha" class="lf-input" value="32" min="4" max="512" step="4">
|
||
<div class="lf-hint">Usually 2× LoRA rank</div>
|
||
</div>
|
||
<div>
|
||
<label class="lf-label">Output Model Name</label>
|
||
<input type="text" id="lf-output" class="lf-input" placeholder="auto">
|
||
<div class="lf-hint">Name for Ollama registration</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="full">
|
||
<button class="tr-btn primary" onclick="launchJob()" id="launch-btn">▶ Launch Training</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── 3. Job Monitor ── -->
|
||
<div class="tr-card">
|
||
<div class="tr-card-title">📊 Training Jobs</div>
|
||
<div class="tr-card-sub">Click a job row to expand logs and the live loss curve. Running jobs auto-refresh every 10 seconds.</div>
|
||
|
||
<div class="tr-toolbar">
|
||
<button class="tr-btn ghost" onclick="loadJobs()">↺ Refresh</button>
|
||
</div>
|
||
|
||
<div class="tr-table-wrap">
|
||
<table class="tr-table">
|
||
<thead>
|
||
<tr><th>Name</th><th>Base Model</th><th>Dataset</th><th>Status</th><th>Created</th><th></th></tr>
|
||
</thead>
|
||
<tbody id="jobs-tbody">
|
||
<tr><td colspan="6" class="tr-empty">Loading…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /content -->
|
||
|
||
<footer>
|
||
<p data-brand="footer">Powered by Cezen</p>
|
||
</footer>
|
||
|
||
<div id="tr-toast"></div>
|
||
|
||
<script>
|
||
const _API = '/api';
|
||
|
||
const MOCK_DATASETS = [
|
||
{id:1, name:'hr_qa_pairs.jsonl', size_bytes:248000, row_count:412, created_at:'2026-05-20T10:00:00Z'},
|
||
{id:2, name:'finance_sops_qa.jsonl', size_bytes:189000, row_count:301, created_at:'2026-05-25T11:30:00Z'},
|
||
{id:3, name:'legal_clauses_qa.jsonl', size_bytes:542000, row_count:887, created_at:'2026-06-01T09:15:00Z'},
|
||
];
|
||
const MOCK_JOBS = [
|
||
{id:1, name:'HR Policy LoRA', base_model:'llama3:8b', dataset_name:'hr_qa_pairs.jsonl', status:'completed', epochs:3, loss_final:0.142, created_at:'2026-06-10T08:00:00Z', completed_at:'2026-06-10T14:22:00Z'},
|
||
{id:2, name:'Finance Expert', base_model:'mistral:7b', dataset_name:'finance_sops_qa.jsonl', status:'running', epochs:5, loss_final:null, created_at:'2026-06-28T06:00:00Z', completed_at:null},
|
||
{id:3, name:'Legal Classifier', base_model:'llama3:8b', dataset_name:'legal_clauses_qa.jsonl', status:'failed', epochs:2, loss_final:null, created_at:'2026-06-15T10:00:00Z', completed_at:null},
|
||
];
|
||
|
||
let datasetsCache = [];
|
||
let jobRefreshTimer = null;
|
||
let lossCharts = {};
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function toast(msg, err=false) {
|
||
const t = document.getElementById('tr-toast');
|
||
t.textContent = msg;
|
||
t.className = err ? 'err' : '';
|
||
t.style.display = 'block';
|
||
setTimeout(() => t.style.display='none', 3500);
|
||
}
|
||
|
||
function fmt(iso) {
|
||
if (!iso) return '—';
|
||
const s = iso.replace(/\+00:00$/, 'Z').replace(/\.\d+Z$/, 'Z');
|
||
return new Date(s).toLocaleString([], {dateStyle:'short', timeStyle:'short'});
|
||
}
|
||
|
||
function fmtBytes(b) {
|
||
if (b < 1024) return b + ' B';
|
||
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
|
||
return (b/(1024*1024)).toFixed(1) + ' MB';
|
||
}
|
||
|
||
function statusBadge(s) {
|
||
return `<span class="badge-status ${s}">${s}</span>`;
|
||
}
|
||
|
||
// ── Drag and drop ─────────────────────────────────────────────────────────────
|
||
|
||
const zone = document.getElementById('upload-zone');
|
||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag'); });
|
||
zone.addEventListener('dragleave', () => zone.classList.remove('drag'));
|
||
zone.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
zone.classList.remove('drag');
|
||
const f = e.dataTransfer.files[0];
|
||
if (f) uploadDataset(f);
|
||
});
|
||
|
||
// ── Dataset Manager ───────────────────────────────────────────────────────────
|
||
|
||
async function loadDatasets() {
|
||
const tbody = document.getElementById('ds-tbody');
|
||
let datasetsCache2 = [];
|
||
try {
|
||
const res = await fetch(`${_API}/training/datasets`,{credentials:'include'});
|
||
if(!res.ok) throw new Error();
|
||
const d=await res.json();
|
||
const rows = Array.isArray(d) ? d : (Array.isArray(d.datasets) ? d.datasets : []);
|
||
datasetsCache2 = rows.map((item, index) => ({
|
||
...item,
|
||
id: item.id ?? index,
|
||
orig_name: item.orig_name || item.name || item.filename || 'Untitled dataset',
|
||
row_count: Number(item.row_count ?? item.rows ?? 0),
|
||
size_bytes: Number(item.size_bytes ?? item.size ?? 0),
|
||
uploaded_at: item.uploaded_at || item.created_at || item.updated_at || null
|
||
}));
|
||
} catch(e){
|
||
datasetsCache2 = MOCK_DATASETS.map((item, index) => ({
|
||
...item,
|
||
id: item.id ?? index,
|
||
orig_name: item.orig_name || item.name || item.filename || 'Untitled dataset',
|
||
row_count: Number(item.row_count ?? item.rows ?? 0),
|
||
size_bytes: Number(item.size_bytes ?? item.size ?? 0),
|
||
uploaded_at: item.uploaded_at || item.created_at || item.updated_at || null
|
||
}));
|
||
}
|
||
datasetsCache = datasetsCache2;
|
||
|
||
// Update dataset selector in launch form
|
||
const sel = document.getElementById('lf-dataset');
|
||
const prev = sel.value;
|
||
sel.innerHTML = '<option value="">Select a dataset…</option>';
|
||
datasetsCache.forEach(d => {
|
||
const opt = document.createElement('option');
|
||
opt.value = d.id;
|
||
opt.textContent = `${d.orig_name} (${Number(d.row_count || 0).toLocaleString()} rows)`;
|
||
sel.appendChild(opt);
|
||
});
|
||
if (prev) sel.value = prev;
|
||
|
||
if (!datasetsCache.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="tr-empty">No datasets uploaded yet. Upload a JSONL or CSV file above.</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = datasetsCache.map(d => `
|
||
<tr>
|
||
<td><strong>${d.orig_name}</strong></td>
|
||
<td>${Number(d.row_count || 0).toLocaleString()}</td>
|
||
<td>${fmtBytes(Number(d.size_bytes || 0))}</td>
|
||
<td>${fmt(d.uploaded_at)}</td>
|
||
<td><button class="tr-btn danger" style="padding:5px 12px;font-size:12px;" onclick="deleteDataset(${d.id},'${d.orig_name.replace(/'/g,"\\'")}')">Delete</button></td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
async function uploadDataset(file) {
|
||
if (!file) return;
|
||
const prog = document.getElementById('upload-progress');
|
||
prog.style.display = 'block';
|
||
prog.textContent = `Uploading ${file.name}…`;
|
||
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
try {
|
||
const res = await fetch(`${_API}/training/datasets`, {method:'POST', credentials:'include', body:fd});
|
||
const d = await res.json();
|
||
if (!res.ok) throw new Error(d.detail || 'Upload failed');
|
||
toast(`✓ Uploaded ${file.name} — ${d.row_count.toLocaleString()} rows`);
|
||
loadDatasets();
|
||
} catch(e) {
|
||
toast(e.message, true);
|
||
} finally {
|
||
prog.style.display = 'none';
|
||
document.getElementById('file-input').value = '';
|
||
}
|
||
}
|
||
|
||
async function deleteDataset(id, name) {
|
||
if (!confirm(`Delete dataset "${name}"? This cannot be undone.`)) return;
|
||
const res = await fetch(`${_API}/training/datasets/${id}`, {method:'DELETE', credentials:'include'});
|
||
if (res.ok) {
|
||
toast('Dataset deleted');
|
||
loadDatasets();
|
||
} else {
|
||
const d = await res.json().catch(()=>({}));
|
||
toast(d.detail || 'Delete failed', true);
|
||
}
|
||
}
|
||
|
||
// ── Model selector ────────────────────────────────────────────────────────────
|
||
|
||
async function loadModels() {
|
||
const sel = document.getElementById('lf-model');
|
||
try {
|
||
const data = await fetch(`${_API}/models`, {credentials:'include'}).then(r=>r.json());
|
||
const models = data.models || data || [];
|
||
sel.innerHTML = '<option value="">Select base model…</option>';
|
||
(Array.isArray(models) ? models : []).forEach(m => {
|
||
const name = m.name || m;
|
||
const opt = document.createElement('option');
|
||
opt.value = name;
|
||
opt.textContent = name;
|
||
sel.appendChild(opt);
|
||
});
|
||
if (!models.length) {
|
||
sel.innerHTML = '<option value="">No models available — pull one in Model Manager</option>';
|
||
}
|
||
} catch {
|
||
sel.innerHTML = '<option value="">Could not load models</option>';
|
||
}
|
||
}
|
||
|
||
// ── Launch job ────────────────────────────────────────────────────────────────
|
||
|
||
async function launchJob() {
|
||
const name = document.getElementById('lf-name').value.trim();
|
||
const base_model = document.getElementById('lf-model').value;
|
||
const dataset_id = parseInt(document.getElementById('lf-dataset').value);
|
||
const epochs = parseInt(document.getElementById('lf-epochs').value);
|
||
const lr = parseFloat(document.getElementById('lf-lr').value);
|
||
const batch_size = parseInt(document.getElementById('lf-batch').value);
|
||
const lora_r = parseInt(document.getElementById('lf-lora-r').value);
|
||
const lora_alpha = parseInt(document.getElementById('lf-lora-alpha').value);
|
||
const output_name = document.getElementById('lf-output').value.trim() || name.replace(/\s+/g,'_').toLowerCase();
|
||
|
||
if (!name) { toast('Please enter a job name', true); return; }
|
||
if (!base_model) { toast('Please select a base model', true); return; }
|
||
if (!dataset_id) { toast('Please select a dataset', true); return; }
|
||
|
||
const btn = document.getElementById('launch-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Launching…';
|
||
|
||
try {
|
||
const res = await fetch(`${_API}/training/jobs`, {
|
||
method:'POST', credentials:'include',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({name, base_model, dataset_id, epochs, lr, batch_size, lora_r, lora_alpha, output_name})
|
||
});
|
||
const d = await res.json();
|
||
if (!res.ok) throw new Error(d.detail || 'Launch failed');
|
||
toast(`✓ Job "${name}" launched (ID: ${d.job_id})`);
|
||
loadJobs();
|
||
startAutoRefresh();
|
||
} catch(e) {
|
||
toast(e.message, true);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = '▶ Launch Training';
|
||
}
|
||
}
|
||
|
||
// ── Job Monitor ───────────────────────────────────────────────────────────────
|
||
|
||
let openJobId = null;
|
||
|
||
async function loadJobs() {
|
||
const tbody = document.getElementById('jobs-tbody');
|
||
let jobs = [];
|
||
try { const res=await fetch(`${_API}/training/jobs`,{credentials:'include'}); if(!res.ok) throw new Error(); const d=await res.json(); jobs=d.jobs||[]; } catch(e){ jobs=MOCK_JOBS; }
|
||
|
||
if (!jobs.length) {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="tr-empty">No training jobs yet. Configure and launch one above.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = jobs.map(j => `
|
||
<tr class="job-row" onclick="toggleJobDetail(${j.id}, this)" style="cursor:pointer;">
|
||
<td><strong>${j.name}</strong></td>
|
||
<td>${j.base_model}</td>
|
||
<td>${j.dataset_name || '—'}</td>
|
||
<td>${statusBadge(j.status)}</td>
|
||
<td>${fmt(j.created_at)}</td>
|
||
<td>
|
||
${j.status === 'running'
|
||
? `<button class="tr-btn danger" style="padding:5px 12px;font-size:12px;" onclick="event.stopPropagation();cancelJob(${j.id},'${j.name.replace(/'/g,"\\'")}')">Cancel</button>`
|
||
: ''}
|
||
</td>
|
||
</tr>
|
||
<tr id="detail-${j.id}" style="display:none;">
|
||
<td colspan="6" style="padding:0;">
|
||
<div class="job-detail" id="detail-body-${j.id}">
|
||
<div class="job-meta">
|
||
<span><strong>Epochs:</strong> ${j.config?.epochs ?? '—'}</span>
|
||
<span><strong>LR:</strong> ${j.config?.lr ?? '—'}</span>
|
||
<span><strong>Batch:</strong> ${j.config?.batch_size ?? '—'}</span>
|
||
<span><strong>LoRA r:</strong> ${j.config?.lora_r ?? '—'} / alpha: ${j.config?.lora_alpha ?? '—'}</span>
|
||
<span><strong>Started:</strong> ${fmt(j.started_at)}</span>
|
||
<span><strong>Finished:</strong> ${fmt(j.finished_at)}</span>
|
||
<span><strong>Output:</strong> ${j.output_path || '—'}</span>
|
||
</div>
|
||
<div class="chart-wrap" id="chart-wrap-${j.id}">
|
||
<canvas id="chart-${j.id}" height="160"></canvas>
|
||
</div>
|
||
<span class="log-toggle" onclick="toggleLog(${j.id})">▼ Show Logs</span>
|
||
<div class="log-wrap" id="log-${j.id}">Loading logs…</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
// Restore open job
|
||
if (openJobId) {
|
||
const row = document.querySelector(`.job-row[onclick*="toggleJobDetail(${openJobId}"]`);
|
||
if (row) loadJobDetail(openJobId);
|
||
}
|
||
|
||
// Manage auto-refresh
|
||
const hasRunning = jobs.some(j => j.status === 'running' || j.status === 'pending');
|
||
if (hasRunning) startAutoRefresh(); else stopAutoRefresh();
|
||
}
|
||
|
||
function toggleJobDetail(id, row) {
|
||
const detailRow = document.getElementById(`detail-${id}`);
|
||
const isOpen = detailRow.style.display !== 'none';
|
||
// Close all
|
||
document.querySelectorAll('[id^="detail-"]').forEach(r => r.style.display = 'none');
|
||
if (!isOpen) {
|
||
detailRow.style.display = '';
|
||
openJobId = id;
|
||
loadJobDetail(id);
|
||
} else {
|
||
openJobId = null;
|
||
}
|
||
}
|
||
|
||
async function loadJobDetail(id) {
|
||
const data = await fetch(`${_API}/training/jobs/${id}`, {credentials:'include'}).then(r=>r.json()).catch(()=>null);
|
||
if (!data) return;
|
||
|
||
// Render log
|
||
const logEl = document.getElementById(`log-${id}`);
|
||
if (logEl) {
|
||
const msgs = (data.log_entries || []).map(e => {
|
||
if (e.type === 'loss') return `[step ${e.step}] loss=${e.loss} epoch=${e.epoch} lr=${e.lr}`;
|
||
if (e.type === 'error') return `ERROR: ${e.msg}${e.traceback ? '\n' + e.traceback : ''}`;
|
||
return `[${e.type}] ${e.msg || JSON.stringify(e)}`;
|
||
}).join('\n');
|
||
logEl.textContent = msgs || '(no log entries yet)';
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
}
|
||
|
||
// Render loss chart
|
||
renderLossChart(id, data.log_entries || []);
|
||
}
|
||
|
||
function renderLossChart(id, entries) {
|
||
const lossEntries = entries.filter(e => e.type === 'loss');
|
||
const wrap = document.getElementById(`chart-wrap-${id}`);
|
||
const canvas = document.getElementById(`chart-${id}`);
|
||
if (!wrap || !canvas || !lossEntries.length) return;
|
||
|
||
wrap.style.display = 'block';
|
||
|
||
const labels = lossEntries.map(e => `Step ${e.step}`);
|
||
const values = lossEntries.map(e => e.loss);
|
||
|
||
// Destroy previous chart instance if any
|
||
if (lossCharts[id]) { lossCharts[id].destroy(); }
|
||
|
||
// Minimal inline chart (no external deps)
|
||
const ctx = canvas.getContext('2d');
|
||
const W = canvas.parentElement.clientWidth || 600;
|
||
canvas.width = W;
|
||
canvas.height = 160;
|
||
|
||
const PAD = {top:20, right:20, bottom:36, left:56};
|
||
const cw = W - PAD.left - PAD.right;
|
||
const ch = 160 - PAD.top - PAD.bottom;
|
||
|
||
const minV = Math.min(...values);
|
||
const maxV = Math.max(...values);
|
||
const rangeV = maxV - minV || 1;
|
||
|
||
function xp(i) { return PAD.left + (i / (values.length - 1 || 1)) * cw; }
|
||
function yp(v) { return PAD.top + (1 - (v - minV) / rangeV) * ch; }
|
||
|
||
ctx.clearRect(0, 0, W, 160);
|
||
|
||
// Background
|
||
ctx.fillStyle = '#F8FAFC';
|
||
ctx.fillRect(0, 0, W, 160);
|
||
ctx.strokeStyle = '#E2E8F0';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(PAD.left, PAD.top, cw, ch);
|
||
|
||
// Grid lines (5)
|
||
ctx.setLineDash([3,3]);
|
||
ctx.strokeStyle = '#E2E8F0';
|
||
for (let i=0; i<=4; i++) {
|
||
const y = PAD.top + (ch / 4) * i;
|
||
ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(PAD.left+cw, y); ctx.stroke();
|
||
const v = (maxV - (rangeV/4)*i).toFixed(4);
|
||
ctx.fillStyle='#94A3B8'; ctx.font='10px sans-serif'; ctx.textAlign='right';
|
||
ctx.fillText(v, PAD.left-6, y+3);
|
||
}
|
||
ctx.setLineDash([]);
|
||
|
||
// X labels (max 6)
|
||
ctx.fillStyle='#94A3B8'; ctx.font='10px sans-serif'; ctx.textAlign='center';
|
||
const step = Math.max(1, Math.floor(values.length / 6));
|
||
for (let i=0; i<values.length; i+=step) {
|
||
ctx.fillText(labels[i], xp(i), PAD.top+ch+18);
|
||
}
|
||
|
||
// Loss line
|
||
if (values.length > 1) {
|
||
const grad = ctx.createLinearGradient(PAD.left, PAD.top, PAD.left, PAD.top+ch);
|
||
grad.addColorStop(0, 'rgba(124,58,237,.18)');
|
||
grad.addColorStop(1, 'rgba(124,58,237,.01)');
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(xp(0), yp(values[0]));
|
||
for (let i=1; i<values.length; i++) ctx.lineTo(xp(i), yp(values[i]));
|
||
ctx.lineTo(xp(values.length-1), PAD.top+ch);
|
||
ctx.lineTo(xp(0), PAD.top+ch);
|
||
ctx.closePath();
|
||
ctx.fillStyle = grad;
|
||
ctx.fill();
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(xp(0), yp(values[0]));
|
||
for (let i=1; i<values.length; i++) ctx.lineTo(xp(i), yp(values[i]));
|
||
ctx.strokeStyle = '#7C3AED'; ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Title
|
||
ctx.fillStyle='#1E293B'; ctx.font='bold 11px sans-serif'; ctx.textAlign='left';
|
||
ctx.fillText('Training Loss', PAD.left, PAD.top-6);
|
||
|
||
lossCharts[id] = { destroy: () => {} }; // simple sentinel
|
||
}
|
||
|
||
function toggleLog(id) {
|
||
const logEl = document.getElementById(`log-${id}`);
|
||
const isVisible = logEl.style.display === 'block';
|
||
logEl.style.display = isVisible ? 'none' : 'block';
|
||
const tog = logEl.previousElementSibling;
|
||
if (tog) tog.textContent = isVisible ? '▼ Show Logs' : '▲ Hide Logs';
|
||
}
|
||
|
||
async function cancelJob(id, name) {
|
||
if (!confirm(`Cancel job "${name}"?`)) return;
|
||
const res = await fetch(`${_API}/training/jobs/${id}`, {method:'DELETE', credentials:'include'});
|
||
if (res.ok) {
|
||
toast('Job cancelled');
|
||
loadJobs();
|
||
} else {
|
||
const d = await res.json().catch(()=>({}));
|
||
toast(d.detail || 'Cancel failed', true);
|
||
}
|
||
}
|
||
|
||
// ── Auto-refresh ──────────────────────────────────────────────────────────────
|
||
|
||
function startAutoRefresh() {
|
||
if (jobRefreshTimer) return;
|
||
jobRefreshTimer = setInterval(() => {
|
||
loadJobs();
|
||
if (openJobId) loadJobDetail(openJobId);
|
||
}, 10000);
|
||
}
|
||
|
||
function stopAutoRefresh() {
|
||
if (jobRefreshTimer) { clearInterval(jobRefreshTimer); jobRefreshTimer = null; }
|
||
}
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
|
||
loadDatasets();
|
||
loadModels();
|
||
loadJobs();
|
||
</script>
|
||
<script src="auth.js"></script>
|
||
<script src="branding.js"></script>
|
||
</body>
|
||
</html>
|