aipackage/cezen-portal/training.html
2026-06-30 10:51:41 +05:30

731 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=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 &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 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 &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"></script>
</body>
</html>