aipackage/cezen-portal/training.html
Jino Jose b3eec34158 Sync cezen-portal/ from live working copy (V1.0 GA polish + search-icon ordering fix)
Brings the ISO-packaged portal copy current with the live cezen-portal/
working directory, which had drifted across the entire V1.0 GA polish
initiative (Phases 1-10) plus this session's topnav icon-order bugfix.

- 52 existing files updated, 2 new pages added (about.html, health.html)
- cgit/cezen-portal/ is now byte-identical to cezen-portal/
- Backend files (rag_ingest.py, cezen_license.py) already in sync;
  main.py intentionally left as-is (cgit is ahead there with
  workstation-category commercial-fit logic not yet backported to the
  live working copy)

Next: rebuild the ISO (bash autoinstall/build-iso.sh) to bake this in.
2026-07-09 11:11:38 +05:30

732 lines
32 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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=12">
<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">Governance & Audit</a>
<a href="health.html">Health Center</a>
<a href="feedback.html">Feedback &amp; 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 Catalog</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">MCP & Enterprise 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">AI Workspace</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 Studio</a>
<a href="schedules.html">Scheduled Jobs</a>
<a href="workflows.html">Workflow Designer</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" class="nav-icon-btn" style="position:relative" aria-label="Notifications"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M15 17h5l-1.4-1.4A2 2 0 0 1 18 14.2V11a6 6 0 1 0-12 0v3.2a2 2 0 0 1-.6 1.4L4 17h5"/><path d="M10 17a2 2 0 0 0 4 0"/></svg></span></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"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></span> 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"><span class="icon-svg icon-xl" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg></span></div>
<div class="uz-label">Click to upload or drag &amp; 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?v=3"></script>
</body>
</html>