Download webapp/templates/admin.html from neev2211/arabic-native-classifier: direct link, hf CLI and curl.
- Browser
- Download file 20.8 kB
-
https://huggingface.co/spaces/neev2211/arabic-native-classifier/resolve/main/webapp/templates/admin.html
- Command line
-
hf download hf://spaces/neev2211/arabic-native-classifier/webapp/templates/admin.html
-
curl -L -o admin.html https://huggingface.co/spaces/neev2211/arabic-native-classifier/resolve/main/webapp/templates/admin.html
20.8 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Admin — Model Training</title> | |
| <link | |
| href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" | |
| rel="stylesheet" | |
| /> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| :root { | |
| --bg: #fafaf9; | |
| --surface: #fff; | |
| --surface2: #f5f5f4; | |
| --surface3: #e7e5e4; | |
| --text: #1c1917; | |
| --text2: #57534e; | |
| --text3: #a8a29e; | |
| --accent: #2563eb; | |
| --accent2: #3b82f6; | |
| --accent-soft: #dbeafe; | |
| --green: #16a34a; | |
| --green-soft: #dcfce7; | |
| --red: #dc2626; | |
| --red-soft: #fee2e2; | |
| --amber: #d97706; | |
| --amber-soft: #fef3c7; | |
| --border: #e7e5e4; | |
| --radius: 10px; | |
| --shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); | |
| } | |
| body { | |
| font-family: "DM Sans", system-ui, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| line-height: 1.5; | |
| min-height: 100vh; | |
| } | |
| .container { | |
| max-width: 900px; | |
| margin: 0 auto; | |
| padding: 32px 24px; | |
| } | |
| /* Header */ | |
| .header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 32px; | |
| gap: 16px; | |
| flex-wrap: wrap; | |
| } | |
| .header h1 { | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| } | |
| .header-actions { | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| } | |
| .badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 4px 12px; | |
| border-radius: 20px; | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| } | |
| .badge-green { | |
| background: var(--green-soft); | |
| color: var(--green); | |
| } | |
| .badge-amber { | |
| background: var(--amber-soft); | |
| color: var(--amber); | |
| } | |
| .badge-red { | |
| background: var(--red-soft); | |
| color: var(--red); | |
| } | |
| .btn { | |
| padding: 8px 16px; | |
| border-radius: 8px; | |
| font-family: inherit; | |
| font-size: 0.85rem; | |
| font-weight: 600; | |
| border: 1px solid var(--border); | |
| cursor: pointer; | |
| text-decoration: none; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| transition: all 0.15s; | |
| } | |
| .btn-primary { | |
| background: var(--text); | |
| color: #fff; | |
| border-color: var(--text); | |
| } | |
| .btn-primary:hover { | |
| background: #292524; | |
| } | |
| .btn-ghost { | |
| background: transparent; | |
| color: var(--text2); | |
| } | |
| .btn-ghost:hover { | |
| background: var(--surface2); | |
| } | |
| /* Card */ | |
| .card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 24px; | |
| box-shadow: var(--shadow); | |
| margin-bottom: 20px; | |
| } | |
| .card-title { | |
| font-size: 1rem; | |
| font-weight: 700; | |
| margin-bottom: 4px; | |
| } | |
| .card-desc { | |
| color: var(--text2); | |
| font-size: 0.85rem; | |
| margin-bottom: 16px; | |
| } | |
| /* Upload Zone */ | |
| .upload-zone { | |
| border: 2px dashed var(--border); | |
| border-radius: var(--radius); | |
| padding: 40px 20px; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| background: var(--surface2); | |
| } | |
| .upload-zone:hover, | |
| .upload-zone.drag-over { | |
| border-color: var(--accent); | |
| background: var(--accent-soft); | |
| } | |
| .upload-zone .icon { | |
| font-size: 2rem; | |
| margin-bottom: 8px; | |
| } | |
| .upload-zone .label { | |
| font-weight: 600; | |
| font-size: 0.95rem; | |
| } | |
| .upload-zone .hint { | |
| color: var(--text3); | |
| font-size: 0.8rem; | |
| margin-top: 4px; | |
| } | |
| .file-info { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 12px 16px; | |
| background: var(--surface2); | |
| border-radius: 8px; | |
| margin-top: 12px; | |
| } | |
| .file-info .name { | |
| font-weight: 600; | |
| font-size: 0.9rem; | |
| flex: 1; | |
| } | |
| .file-info .remove { | |
| color: var(--red); | |
| cursor: pointer; | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| } | |
| /* Progress */ | |
| .progress-section { | |
| display: none; | |
| } | |
| .progress-section.visible { | |
| display: block; | |
| } | |
| .progress-bar-wrap { | |
| height: 8px; | |
| background: var(--surface3); | |
| border-radius: 4px; | |
| overflow: hidden; | |
| margin: 12px 0; | |
| } | |
| .progress-bar { | |
| height: 100%; | |
| background: var(--accent); | |
| border-radius: 4px; | |
| transition: width 0.3s; | |
| width: 0%; | |
| } | |
| .progress-label { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.8rem; | |
| color: var(--text2); | |
| } | |
| .phase-badge { | |
| display: inline-block; | |
| padding: 3px 10px; | |
| border-radius: 12px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| margin-bottom: 8px; | |
| } | |
| .phase-downloading { | |
| background: #dbeafe; | |
| color: #2563eb; | |
| } | |
| .phase-embedding { | |
| background: #ede9fe; | |
| color: #7c3aed; | |
| } | |
| .phase-training { | |
| background: var(--amber-soft); | |
| color: var(--amber); | |
| } | |
| .phase-complete { | |
| background: var(--green-soft); | |
| color: var(--green); | |
| } | |
| .phase-error { | |
| background: var(--red-soft); | |
| color: var(--red); | |
| } | |
| /* Log */ | |
| .log-panel { | |
| background: #1c1917; | |
| color: #d6d3d1; | |
| border-radius: 8px; | |
| padding: 16px; | |
| max-height: 400px; | |
| overflow-y: auto; | |
| font-family: "JetBrains Mono", monospace; | |
| font-size: 0.78rem; | |
| line-height: 1.7; | |
| margin-top: 12px; | |
| } | |
| .log-panel .log-line { | |
| white-space: pre-wrap; | |
| word-break: break-all; | |
| } | |
| .log-panel .log-time { | |
| color: #a8a29e; | |
| margin-right: 8px; | |
| } | |
| .log-panel .log-error { | |
| color: #f87171; | |
| } | |
| .log-panel::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| .log-panel::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .log-panel::-webkit-scrollbar-thumb { | |
| background: #57534e; | |
| border-radius: 3px; | |
| } | |
| /* Metrics */ | |
| .metrics-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); | |
| gap: 12px; | |
| margin-top: 16px; | |
| } | |
| .metric-card { | |
| background: var(--surface2); | |
| border-radius: 8px; | |
| padding: 14px; | |
| text-align: center; | |
| } | |
| .metric-card .val { | |
| font-size: 1.3rem; | |
| font-weight: 700; | |
| color: var(--accent); | |
| } | |
| .metric-card .lbl { | |
| font-size: 0.75rem; | |
| color: var(--text2); | |
| margin-top: 2px; | |
| } | |
| /* Confusion Matrix */ | |
| .cm-table { | |
| margin-top: 12px; | |
| border-collapse: collapse; | |
| } | |
| .cm-table th, | |
| .cm-table td { | |
| padding: 8px 16px; | |
| text-align: center; | |
| border: 1px solid var(--border); | |
| font-size: 0.85rem; | |
| } | |
| .cm-table th { | |
| background: var(--surface2); | |
| font-weight: 600; | |
| } | |
| .cm-table td { | |
| font-family: "JetBrains Mono", monospace; | |
| } | |
| /* Format info */ | |
| .format-box { | |
| background: var(--surface2); | |
| border-radius: 8px; | |
| padding: 12px 16px; | |
| font-family: "JetBrains Mono", monospace; | |
| font-size: 0.8rem; | |
| color: var(--text2); | |
| margin-bottom: 16px; | |
| border-left: 3px solid var(--accent); | |
| } | |
| .info-box { | |
| background: #dbeafe; | |
| border: 1px solid #93c5fd; | |
| border-radius: 8px; | |
| padding: 12px 16px; | |
| font-size: 0.85rem; | |
| color: #1e40af; | |
| margin-bottom: 16px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <!-- Header --> | |
| <div class="header"> | |
| <div> | |
| <h1>Admin Panel</h1> | |
| <span style="color: var(--text2); font-size: 0.85rem" | |
| >Model training & management</span | |
| > | |
| </div> | |
| <div class="header-actions"> | |
| <a href="/" class="btn btn-ghost">Back to App</a> | |
| <a | |
| href="/admin/logout" | |
| class="btn btn-ghost" | |
| style="color: var(--red)" | |
| >Logout</a | |
| > | |
| </div> | |
| </div> | |
| <!-- Upload Card --> | |
| <div class="card" id="upload-card"> | |
| <div class="card-title">Train New Model</div> | |
| <div class="card-desc"> | |
| Upload a CSV to retrain the nativity classifier. This will download | |
| audio, extract embeddings, and train from scratch. | |
| </div> | |
| <div class="format-box"> | |
| Required CSV columns:<br /> | |
| <strong>dp_id</strong>, <strong>audio_url</strong>, | |
| <strong>nativity_status</strong>, <strong>language</strong> | |
| </div> | |
| <div class="info-box"> | |
| Embeddings are stored on the server only and are not exposed in the | |
| public repository. Training typically takes 15-45 minutes depending on | |
| dataset size. | |
| </div> | |
| <div class="info-box" style="background:#fef3c7;border-color:#fbbf24;color:#92400e;"> | |
| ⚠ On Hugging Face Spaces, storage is ephemeral. Trained models will be lost | |
| when the Space restarts. Use the <strong>Download Model</strong> button after training | |
| to save your model locally, then re-upload the files to the repository. | |
| </div> | |
| <div | |
| class="upload-zone" | |
| id="drop-zone" | |
| onclick="document.getElementById('csv-input').click()" | |
| > | |
| <div class="icon">📋</div> | |
| <div class="label">Drop CSV here or click to upload</div> | |
| <div class="hint">dp_id, audio_url, nativity_status, language</div> | |
| </div> | |
| <input type="file" id="csv-input" accept=".csv" style="display: none" /> | |
| <div id="file-info-box" style="display: none"> | |
| <div class="file-info"> | |
| <span class="name" id="file-name"></span> | |
| <span class="remove" onclick="clearFile()">Remove</span> | |
| </div> | |
| </div> | |
| <div style="margin-top: 16px; text-align: right"> | |
| <button | |
| class="btn btn-primary" | |
| id="train-btn" | |
| onclick="startTraining()" | |
| disabled | |
| > | |
| Start Training | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Progress Card --> | |
| <div class="card progress-section" id="progress-card"> | |
| <div class="card-title">Training Progress</div> | |
| <div style="margin-top: 12px"> | |
| <span class="phase-badge" id="phase-badge">Starting...</span> | |
| </div> | |
| <div class="progress-bar-wrap"> | |
| <div class="progress-bar" id="progress-bar"></div> | |
| </div> | |
| <div class="progress-label"> | |
| <span id="progress-text">0 / 0</span> | |
| <span id="progress-phase"></span> | |
| </div> | |
| <div class="log-panel" id="log-panel"></div> | |
| </div> | |
| <!-- Results Card --> | |
| <div class="card progress-section" id="results-card"> | |
| <div class="card-title">Training Results</div> | |
| <div id="results-content"></div> | |
| </div> | |
| </div> | |
| <script> | |
| let selectedFile = null; | |
| // Drag & drop | |
| const dropZone = document.getElementById("drop-zone"); | |
| ["dragenter", "dragover"].forEach((e) => | |
| dropZone.addEventListener(e, (ev) => { | |
| ev.preventDefault(); | |
| dropZone.classList.add("drag-over"); | |
| }), | |
| ); | |
| ["dragleave", "drop"].forEach((e) => | |
| dropZone.addEventListener(e, (ev) => { | |
| ev.preventDefault(); | |
| dropZone.classList.remove("drag-over"); | |
| }), | |
| ); | |
| dropZone.addEventListener("drop", (ev) => { | |
| if (ev.dataTransfer.files.length) setFile(ev.dataTransfer.files[0]); | |
| }); | |
| document.getElementById("csv-input").addEventListener("change", (ev) => { | |
| if (ev.target.files.length) setFile(ev.target.files[0]); | |
| }); | |
| function setFile(file) { | |
| if (!file.name.endsWith(".csv")) { | |
| alert("Please upload a CSV file."); | |
| return; | |
| } | |
| selectedFile = file; | |
| document.getElementById("file-name").textContent = | |
| `${file.name} (${(file.size / 1024).toFixed(1)} KB)`; | |
| document.getElementById("file-info-box").style.display = "block"; | |
| document.getElementById("train-btn").disabled = false; | |
| dropZone.style.display = "none"; | |
| } | |
| function clearFile() { | |
| selectedFile = null; | |
| document.getElementById("file-info-box").style.display = "none"; | |
| document.getElementById("train-btn").disabled = true; | |
| dropZone.style.display = ""; | |
| document.getElementById("csv-input").value = ""; | |
| } | |
| async function startTraining() { | |
| if (!selectedFile) return; | |
| const btn = document.getElementById("train-btn"); | |
| btn.disabled = true; | |
| btn.textContent = "Starting..."; | |
| const fd = new FormData(); | |
| fd.append("file", selectedFile); | |
| try { | |
| const resp = await fetch("/admin/api/train", { | |
| method: "POST", | |
| body: fd, | |
| }); | |
| const data = await resp.json(); | |
| if (!resp.ok) { | |
| alert(data.error || "Failed to start training"); | |
| btn.disabled = false; | |
| btn.textContent = "Start Training"; | |
| return; | |
| } | |
| // Show progress | |
| document.getElementById("upload-card").style.display = "none"; | |
| document.getElementById("progress-card").classList.add("visible"); | |
| document.getElementById("results-card").classList.add("visible"); | |
| streamProgress(); | |
| } catch (e) { | |
| alert("Error: " + e.message); | |
| btn.disabled = false; | |
| btn.textContent = "Start Training"; | |
| } | |
| } | |
| function streamProgress() { | |
| const evtSource = new EventSource("/admin/api/stream_train"); | |
| const logPanel = document.getElementById("log-panel"); | |
| evtSource.onmessage = function (event) { | |
| const d = JSON.parse(event.data); | |
| // Phase badge | |
| const phaseBadge = document.getElementById("phase-badge"); | |
| const phaseMap = { | |
| starting: ["Starting...", ""], | |
| loading_csv: ["Loading CSV", ""], | |
| downloading: ["Downloading Audio", "phase-downloading"], | |
| embedding: ["Extracting Embeddings", "phase-embedding"], | |
| training: ["Training Model", "phase-training"], | |
| complete: ["Complete", "phase-complete"], | |
| }; | |
| const [phaseText, phaseClass] = phaseMap[d.phase] || [d.phase, ""]; | |
| phaseBadge.textContent = phaseText; | |
| phaseBadge.className = | |
| "phase-badge " + (d.error ? "phase-error" : phaseClass); | |
| // Progress bar | |
| const pct = | |
| d.total > 0 ? Math.round((d.progress / d.total) * 100) : 0; | |
| document.getElementById("progress-bar").style.width = pct + "%"; | |
| document.getElementById("progress-text").textContent = | |
| `${d.progress} / ${d.total}`; | |
| document.getElementById("progress-phase").textContent = phaseText; | |
| // New logs | |
| if (d.logs && d.logs.length) { | |
| d.logs.forEach((log) => { | |
| const div = document.createElement("div"); | |
| div.className = | |
| "log-line" + | |
| (log.msg.includes("ERROR") || log.msg.includes("FAILED") | |
| ? " log-error" | |
| : ""); | |
| div.innerHTML = `<span class="log-time">${log.time}</span>${escapeHtml(log.msg)}`; | |
| logPanel.appendChild(div); | |
| }); | |
| logPanel.scrollTop = logPanel.scrollHeight; | |
| } | |
| // Error | |
| if (d.error) { | |
| phaseBadge.textContent = "Error"; | |
| document.getElementById("results-content").innerHTML = | |
| `<div style="color:var(--red);font-weight:600;margin-top:12px;">Training failed: ${escapeHtml(d.error)}</div>`; | |
| } | |
| // Done | |
| if (d.done) { | |
| evtSource.close(); | |
| if (d.metrics) renderMetrics(d.metrics); | |
| } | |
| }; | |
| evtSource.onerror = function () { | |
| evtSource.close(); | |
| // Poll status once more | |
| fetch("/admin/api/status") | |
| .then((r) => r.json()) | |
| .then((d) => { | |
| if (d.metrics) renderMetrics(d.metrics); | |
| }); | |
| }; | |
| } | |
| function renderMetrics(m) { | |
| const rc = document.getElementById("results-content"); | |
| let html = ` | |
| <div class="metrics-grid"> | |
| <div class="metric-card"><div class="val">${(m.accuracy * 100).toFixed(1)}%</div><div class="lbl">Accuracy</div></div> | |
| <div class="metric-card"><div class="val">${(m.f1_macro * 100).toFixed(1)}%</div><div class="lbl">F1 (macro)</div></div> | |
| <div class="metric-card"><div class="val">${(m.precision * 100).toFixed(1)}%</div><div class="lbl">Precision</div></div> | |
| <div class="metric-card"><div class="val">${(m.recall * 100).toFixed(1)}%</div><div class="lbl">Recall</div></div> | |
| <div class="metric-card"><div class="val">${m.threshold}</div><div class="lbl">Threshold</div></div> | |
| <div class="metric-card"><div class="val">${m.total_chunks}</div><div class="lbl">Total Chunks</div></div> | |
| <div class="metric-card"><div class="val">${m.total_speakers}</div><div class="lbl">Speakers</div></div> | |
| </div>`; | |
| if (m.confusion_matrix && m.confusion_matrix.length === 2) { | |
| const cm = m.confusion_matrix; | |
| html += ` | |
| <div style="margin-top:20px;"> | |
| <div style="font-weight:600;font-size:0.9rem;margin-bottom:8px;">Confusion Matrix</div> | |
| <table class="cm-table"> | |
| <tr><th></th><th>Pred Native</th><th>Pred Non-Native</th></tr> | |
| <tr><th>True Native</th><td>${cm[0][0]}</td><td>${cm[0][1]}</td></tr> | |
| <tr><th>True Non-Native</th><td>${cm[1][0]}</td><td>${cm[1][1]}</td></tr> | |
| </table> | |
| </div>`; | |
| } | |
| html += ` | |
| <div style="margin-top:20px;text-align:right;"> | |
| <a href="/admin/api/download_model" class="btn btn-primary" style="margin-right:8px;">⬇ Download Model (.zip)</a> | |
| <button class="btn btn-primary" onclick="location.reload()">Train Another Model</button> | |
| <a href="/" class="btn btn-ghost" style="margin-left:8px;">Go to Classifier</a> | |
| </div>`; | |
| rc.innerHTML = html; | |
| } | |
| function escapeHtml(str) { | |
| const d = document.createElement("div"); | |
| d.textContent = str; | |
| return d.innerHTML; | |
| } | |
| // Check if training is already in progress | |
| fetch("/admin/api/status") | |
| .then((r) => r.json()) | |
| .then((d) => { | |
| if (d.active || (d.done && d.logs.length > 0)) { | |
| document.getElementById("upload-card").style.display = "none"; | |
| document.getElementById("progress-card").classList.add("visible"); | |
| document.getElementById("results-card").classList.add("visible"); | |
| // Replay existing logs | |
| const logPanel = document.getElementById("log-panel"); | |
| d.logs.forEach((log) => { | |
| const div = document.createElement("div"); | |
| div.className = "log-line"; | |
| div.innerHTML = `<span class="log-time">${log.time}</span>${escapeHtml(log.msg)}`; | |
| logPanel.appendChild(div); | |
| }); | |
| logPanel.scrollTop = logPanel.scrollHeight; | |
| if (d.active) streamProgress(); | |
| if (d.metrics) renderMetrics(d.metrics); | |
| if (d.error) { | |
| document.getElementById("phase-badge").textContent = "Error"; | |
| document.getElementById("phase-badge").className = | |
| "phase-badge phase-error"; | |
| document.getElementById("results-content").innerHTML = | |
| `<div style="color:var(--red);font-weight:600;">Training failed: ${escapeHtml(d.error)}</div>`; | |
| } | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |