| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Efficient Transformer Toolkit – Live Dashboard</title> | |
| <script src="https://unpkg.com/plotly.js@2.35.2/dist/plotly.min.js"></script> | |
| <link rel="stylesheet" href="https://unpkg.com/xterm@4.18.0/css/xterm.css" /> | |
| <script src="https://unpkg.com/xterm@4.18.0/lib/xterm.js"></script> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { font-family: 'Segoe UI', sans-serif; height: 100vh; background: #1e1e2f; color: #ccc; } | |
| .login-overlay { | |
| position: fixed; top: 0; left: 0; width: 100%; height: 100%; | |
| background: #1e1e2f; display: flex; align-items: center; justify-content: center; z-index: 1000; | |
| } | |
| .login-box { | |
| background: #2d2d44; padding: 2rem; border-radius: 8px; width: 360px; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.5); | |
| } | |
| .login-box h2 { color: #fff; margin-bottom: 1.5rem; text-align: center; } | |
| .login-box input { width: 100%; padding: 0.7rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid #555; background: #3b3b55; color: #ddd; } | |
| .login-box button { width: 100%; padding: 0.7rem; background: #7baeff; border: none; border-radius: 4px; color: #fff; font-weight: bold; cursor: pointer; } | |
| .login-box .error { color: #ff6b6b; font-size: 0.85rem; margin-bottom: 0.5rem; } | |
| .app { display: flex; height: 100vh; } | |
| .sidebar { width: 340px; background: #2d2d44; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.8rem; } | |
| .sidebar h2 { color: #fff; margin-bottom: 0.3rem; font-size: 1.3rem; display: flex; justify-content: space-between; align-items: center; } | |
| .sidebar label { font-size: 0.85rem; color: #aaa; margin-bottom: 2px; display: block; } | |
| select, input { width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid #555; background: #3b3b55; color: #ddd; font-size: 0.9rem; } | |
| select:focus, input:focus { border-color: #7baeff; outline: none; } | |
| .btn { padding: 0.6rem 1rem; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; color: #fff; } | |
| .btn-start { background: #28a745; } | |
| .btn-stop { background: #dc3545; } | |
| .btn-update { background: #ffc107; color: #000; } | |
| .btn-logout { background: #6c757d; font-size: 0.8rem; padding: 0.3rem 0.6rem; } | |
| .btn:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .main { flex: 1; display: flex; flex-direction: column; } | |
| .top-bar { background: #2d2d44; padding: 0.8rem 1.5rem; display: flex; align-items: center; gap: 1rem; } | |
| .progress-container { flex: 1; background: #444; border-radius: 10px; height: 20px; margin: 0 1rem; position: relative; } | |
| .progress-bar { height: 100%; background: #28a745; border-radius: 10px; width: 0%; transition: width 0.3s; } | |
| .progress-text { position: absolute; left: 50%; transform: translateX(-50%); color: #fff; font-size: 0.75rem; line-height: 20px; white-space: nowrap; } | |
| .gpu-info { display: flex; gap: 1rem; align-items: center; font-size: 0.85rem; } | |
| .panels { flex: 1; display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 0.8rem; padding: 0.8rem; overflow-y: auto; } | |
| .panel { background: #2d2d44; border-radius: 6px; padding: 1rem; display: flex; flex-direction: column; } | |
| .panel h3 { margin-bottom: 0.6rem; font-size: 1rem; color: #fff; } | |
| .log-viewer { grid-column: span 2; overflow-y: auto; background: #1a1a2e; color: #0f0; font-family: monospace; font-size: 0.8rem; padding: 0.5rem; height: 200px; white-space: pre-wrap; } | |
| .job-list { font-size: 0.8rem; max-height: 200px; overflow-y: auto; background: #1a1a2e; padding: 0.3rem; border-radius: 4px; margin-top: 0.5rem; } | |
| .job-item { padding: 0.3rem 0.4rem; cursor: pointer; border-bottom: 1px solid #444; display: flex; justify-content: space-between; } | |
| .job-item:hover { background: #3b3b60; } | |
| .status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; } | |
| .queued { background: yellow; } | |
| .running { background: cyan; } | |
| .completed { background: lime; } | |
| .failed { background: red; } | |
| .stopped { background: orange; } | |
| .toast { position: fixed; top: 1rem; right: 1rem; padding: 0.8rem 1.2rem; border-radius: 6px; color: #fff; z-index: 2000; animation: fadeIn 0.3s; } | |
| .toast-success { background: #28a745; } | |
| .toast-error { background: #dc3545; } | |
| @keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Login overlay --> | |
| <div class="login-overlay" id="loginOverlay"> | |
| <div class="login-box"> | |
| <h2 id="authTitle">🔐 Login</h2> | |
| <div class="error" id="authError"></div> | |
| <div id="loginFields"> | |
| <input type="text" id="loginUsername" placeholder="Username" autocomplete="username"> | |
| <input type="password" id="loginPassword" placeholder="Password" autocomplete="current-password"> | |
| <button onclick="doLogin()">Sign In</button> | |
| </div> | |
| <div id="registerFields" style="display:none;"> | |
| <input type="text" id="regUsername" placeholder="Choose a username" autocomplete="username"> | |
| <input type="password" id="regPassword" placeholder="Create password"> | |
| <input type="password" id="regPassword2" placeholder="Confirm password"> | |
| <button onclick="doRegister()">Create Account</button> | |
| </div> | |
| <div style="margin-top:1rem; text-align:center; font-size:0.85rem; color:#aaa;"> | |
| <span id="toggleText">Don't have an account?</span> | |
| <a href="#" id="toggleLink" onclick="toggleAuth()">Create one</a> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main app --> | |
| <div class="app" id="app" style="display:none;"> | |
| <div class="sidebar"> | |
| <h2>🚀 Experiment Config | |
| <button class="btn btn-logout" onclick="doLogout()">Logout</button> | |
| </h2> | |
| <label>Model</label> | |
| <select id="modelSelect"></select> | |
| <label>Task</label> | |
| <select id="taskSelect"></select> | |
| <label>Attention Type</label> | |
| <select id="attentionSelect"></select> | |
| <label>Training Method</label> | |
| <select id="methodSelect"> | |
| <option value="baseline">Baseline</option> | |
| <option value="lora">LoRA</option> | |
| <option value="rdrop">RDrop</option> | |
| <option value="r3f">R3F</option> | |
| </select> | |
| <label>Epochs</label> | |
| <input type="number" id="epochs" value="3" min="1" max="100"> | |
| <label>Batch Size</label> | |
| <input type="number" id="batchSize" value="16" min="1" max="256"> | |
| <label>Learning Rate</label> | |
| <input type="number" id="learningRate" value="2e-5" step="1e-6" min="1e-8" max="1"> | |
| <label>Compression Rank</label> | |
| <input type="number" id="rank" value="64" min="1" max="512"> | |
| <label>Max Seq Length</label> | |
| <input type="number" id="maxLen" value="128" min="8" max="1024"> | |
| <div style="display:flex; gap:0.5rem;"> | |
| <button class="btn btn-start" id="startBtn">▶ Start Training</button> | |
| <button class="btn btn-stop" id="stopBtn" disabled>⏹ Stop</button> | |
| </div> | |
| <div style="display:flex; gap:0.5rem;"> | |
| <button class="btn btn-update" id="updateBtn" disabled>✎ Update Config</button> | |
| </div> | |
| <div class="job-list" id="jobList"><div style="color:#aaa">Loading jobs...</div></div> | |
| </div> | |
| <div class="main"> | |
| <div class="top-bar"> | |
| <span id="jobStatus" style="font-weight:bold;">⚪ Idle</span> | |
| <div class="progress-container"> | |
| <div class="progress-bar" id="progressBar"></div> | |
| <div class="progress-text" id="progressText">0%</div> | |
| </div> | |
| <div class="gpu-info" id="gpuInfo">GPU: N/A</div> | |
| </div> | |
| <div class="panels"> | |
| <div class="panel"><h3>Training Loss</h3><div id="lossPlot" style="flex:1;"></div></div> | |
| <div class="panel"><h3>Evaluation Metrics</h3><div id="evalPlot" style="flex:1;"></div></div> | |
| <div class="panel log-viewer"><h3>Logs</h3><div id="logContent" style="flex:1;"></div></div> | |
| <div class="panel"><h3>System</h3><div id="systemInfo">CPU: -- | Mem: --</div></div> | |
| <div class="panel" style="grid-column: span 2;"> | |
| <h3>Live Terminal (stdout/stderr)</h3> | |
| <pre id="terminalOutput" style="background:#000; color:#0f0; height:200px; overflow-y:auto; padding:5px; font-family:monospace; font-size:12px;"></pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ======================== AUTH & STATE ======================== | |
| const API_BASE = 'http://127.0.0.1:8000/api'; | |
| const WS_BASE = 'ws://127.0.0.1:8000/ws/jobs'; | |
| let authToken = localStorage.getItem('authToken'); | |
| let currentJobId = null; | |
| let ws = null; | |
| let plotData = { lossSteps: [], losses: [], evalSteps: [], evalLosses: [] }; | |
| let reconnectTimeout = null; | |
| let jobListInterval = null; | |
| let systemInterval = null; | |
| // Toast notifications | |
| function showToast(msg, type = 'success') { | |
| const t = document.createElement('div'); | |
| t.className = `toast toast-${type}`; | |
| t.textContent = msg; | |
| document.body.appendChild(t); | |
| setTimeout(() => t.remove(), 3000); | |
| } | |
| // ======================== AUTH FUNCTIONS ======================== | |
| async function doLogin() { | |
| const username = document.getElementById('loginUsername').value.trim(); | |
| const password = document.getElementById('loginPassword').value; | |
| if (!username || !password) { | |
| document.getElementById('authError').textContent = 'Username and password required'; | |
| return; | |
| } | |
| try { | |
| const resp = await fetch(`${API_BASE}/auth/login/`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ username, password }) | |
| }); | |
| if (!resp.ok) throw new Error('Invalid credentials'); | |
| const data = await resp.json(); | |
| authToken = data.token; | |
| localStorage.setItem('authToken', authToken); | |
| document.getElementById('loginOverlay').style.display = 'none'; | |
| document.getElementById('app').style.display = 'flex'; | |
| initApp(); | |
| showToast('Logged in successfully'); | |
| } catch (e) { | |
| document.getElementById('authError').textContent = e.message; | |
| } | |
| } | |
| let authMode = 'login'; | |
| function toggleAuth() { | |
| if (authMode === 'login') { | |
| authMode = 'register'; | |
| document.getElementById('authTitle').textContent = '📝 Create Account'; | |
| document.getElementById('loginFields').style.display = 'none'; | |
| document.getElementById('registerFields').style.display = 'block'; | |
| document.getElementById('toggleText').textContent = 'Already have an account?'; | |
| document.getElementById('toggleLink').textContent = 'Sign In'; | |
| } else { | |
| authMode = 'login'; | |
| document.getElementById('authTitle').textContent = '🔐 Login'; | |
| document.getElementById('loginFields').style.display = 'block'; | |
| document.getElementById('registerFields').style.display = 'none'; | |
| document.getElementById('toggleText').textContent = "Don't have an account?"; | |
| document.getElementById('toggleLink').textContent = 'Create one'; | |
| } | |
| } | |
| async function doRegister() { | |
| const username = document.getElementById('regUsername').value.trim(); | |
| const password = document.getElementById('regPassword').value; | |
| const password2 = document.getElementById('regPassword2').value; | |
| if (!username || !password) { | |
| document.getElementById('authError').textContent = 'All fields are required'; | |
| return; | |
| } | |
| if (password !== password2) { | |
| document.getElementById('authError').textContent = 'Passwords do not match'; | |
| return; | |
| } | |
| try { | |
| const resp = await fetch(`${API_BASE}/auth/register/`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ username, password }) | |
| }); | |
| if (!resp.ok) { | |
| const data = await resp.json(); | |
| throw new Error(data.error || 'Registration failed'); | |
| } | |
| const data = await resp.json(); | |
| authToken = data.token; | |
| localStorage.setItem('authToken', authToken); | |
| document.getElementById('loginOverlay').style.display = 'none'; | |
| document.getElementById('app').style.display = 'flex'; | |
| initApp(); | |
| showToast('Account created successfully'); | |
| } catch (e) { | |
| document.getElementById('authError').textContent = e.message; | |
| } | |
| } | |
| function doLogout() { | |
| localStorage.removeItem('authToken'); | |
| authToken = null; | |
| if (ws) { ws.close(); ws = null; } | |
| if (reconnectTimeout) clearTimeout(reconnectTimeout); | |
| if (jobListInterval) clearInterval(jobListInterval); | |
| if (systemInterval) clearInterval(systemInterval); | |
| location.reload(); | |
| } | |
| function authHeaders() { | |
| return authToken ? { 'Authorization': `Token ${authToken}` } : {}; | |
| } | |
| // ======================== API HELPERS ======================== | |
| async function apiGet(url) { | |
| const res = await fetch(`${API_BASE}${url}`, { headers: authHeaders() }); | |
| if (res.status === 401) { doLogout(); return null; } | |
| if (!res.ok) throw new Error(`API error: ${res.status}`); | |
| return res.json(); | |
| } | |
| async function apiPost(url, body = {}) { | |
| const res = await fetch(`${API_BASE}${url}`, { | |
| method: 'POST', | |
| headers: { ...authHeaders(), 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body) | |
| }); | |
| if (res.status === 401) { doLogout(); return null; } | |
| if (!res.ok) throw new Error(`API error: ${res.status}`); | |
| return res.json(); | |
| } | |
| // ======================== CONFIG LOADING ======================== | |
| async function loadConfigOptions() { | |
| try { | |
| const [models, tasks, attentions] = await Promise.all([ | |
| apiGet('/config/models/'), | |
| apiGet('/config/tasks/'), | |
| apiGet('/config/attentions/') | |
| ]); | |
| if (models) populateSelect('modelSelect', models.models, 'distilbert-base-uncased'); | |
| if (tasks) populateSelect('taskSelect', tasks.tasks, 'mrpc'); | |
| if (attentions) populateSelect('attentionSelect', attentions.attentions, 'full'); | |
| } catch (e) { console.error('Config load error:', e); } | |
| } | |
| function populateSelect(id, options, defaultVal) { | |
| const sel = document.getElementById(id); | |
| sel.innerHTML = ''; | |
| options.forEach(o => { | |
| const opt = document.createElement('option'); | |
| opt.value = o; | |
| opt.text = o; | |
| sel.appendChild(opt); | |
| }); | |
| if (defaultVal && options.includes(defaultVal)) sel.value = defaultVal; | |
| } | |
| // ======================== JOB LIST ======================== | |
| async function loadJobList() { | |
| try { | |
| const data = await apiGet('/experiments/'); | |
| if (!data) return; | |
| const container = document.getElementById('jobList'); | |
| if (!data.length) { | |
| container.innerHTML = '<div style="color:#aaa">No jobs yet</div>'; | |
| return; | |
| } | |
| container.innerHTML = data.slice(0, 20).map(j => ` | |
| <div class="job-item" onclick="selectJob('${j.id}')"> | |
| <span><span class="status-dot ${j.status}"></span>${j.name || j.id.slice(0, 8)}</span> | |
| <span style="color:#888;font-size:0.75rem;">${j.status}</span> | |
| </div> | |
| `).join(''); | |
| } catch (e) { console.error('Job list error', e); } | |
| } | |
| // ======================== JOB MANAGEMENT ======================== | |
| function getConfig() { | |
| return { | |
| model_name: document.getElementById('modelSelect').value, | |
| task_name: document.getElementById('taskSelect').value, | |
| attention_type: document.getElementById('attentionSelect').value, | |
| method: document.getElementById('methodSelect').value, | |
| epochs: parseInt(document.getElementById('epochs').value), | |
| batch_size: parseInt(document.getElementById('batchSize').value), | |
| learning_rate: parseFloat(document.getElementById('learningRate').value), | |
| compression_rank: parseInt(document.getElementById('rank').value), | |
| max_seq_length: parseInt(document.getElementById('maxLen').value), | |
| }; | |
| } | |
| async function startTraining() { | |
| try { | |
| const config = getConfig(); | |
| const data = await apiPost('/train/', config); | |
| if (data) { | |
| selectJob(data.id); | |
| showToast('Training started!'); | |
| } | |
| } catch (e) { | |
| showToast('Failed to start training', 'error'); | |
| } | |
| } | |
| async function stopTraining() { | |
| if (!currentJobId) return; | |
| try { | |
| await apiPost(`/jobs/${currentJobId}/stop/`); | |
| showToast('Stop signal sent'); | |
| } catch (e) { | |
| showToast('Failed to stop job', 'error'); | |
| } | |
| } | |
| async function updateConfig() { | |
| if (!currentJobId) return; | |
| try { | |
| const config = getConfig(); | |
| await fetch(`${API_BASE}/jobs/${currentJobId}/config/`, { | |
| method: 'PATCH', | |
| headers: { ...authHeaders(), 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(config) | |
| }); | |
| showToast('Config updated'); | |
| } catch (e) { | |
| showToast('Failed to update config', 'error'); | |
| } | |
| } | |
| // ======================== WEBSOCKET & JOB SELECTION ======================== | |
| function connectWebSocket(jobId) { | |
| if (ws) { | |
| ws.close(); | |
| ws = null; | |
| } | |
| ws = new WebSocket(`${WS_BASE}/${jobId}/`); | |
| ws.onmessage = handleWSMessage; | |
| ws.onerror = (err) => console.error('WebSocket error', err); | |
| ws.onclose = () => { | |
| console.log('WebSocket closed, will reconnect in 2s'); | |
| if (reconnectTimeout) clearTimeout(reconnectTimeout); | |
| reconnectTimeout = setTimeout(() => { | |
| if (currentJobId) connectWebSocket(currentJobId); | |
| }, 2000); | |
| }; | |
| } | |
| function resetUI() { | |
| document.getElementById('progressBar').style.width = '0%'; | |
| document.getElementById('progressText').innerText = '0%'; | |
| plotData = { lossSteps: [], losses: [], evalSteps: [], evalLosses: [] }; | |
| Plotly.newPlot('lossPlot', [{ x: [], y: [], type: 'scatter', mode: 'lines+markers', name: 'Loss' }], | |
| { margin: { t: 10 }, paper_bgcolor: '#2d2d44', plot_bgcolor: '#2d2d44', font: { color: '#ccc' } }); | |
| Plotly.newPlot('evalPlot', [{ x: [], y: [], type: 'scatter', mode: 'lines+markers', name: 'Eval Loss' }], | |
| { margin: { t: 10 }, paper_bgcolor: '#2d2d44', plot_bgcolor: '#2d2d44', font: { color: '#ccc' } }); | |
| document.getElementById('logContent').innerText = ''; | |
| } | |
| async function selectJob(jobId) { | |
| if (currentJobId === jobId) return; | |
| currentJobId = jobId; | |
| document.getElementById('startBtn').disabled = true; | |
| document.getElementById('stopBtn').disabled = false; | |
| document.getElementById('updateBtn').disabled = false; | |
| resetUI(); | |
| connectWebSocket(jobId); | |
| try { | |
| const job = await apiGet(`/jobs/${jobId}/`); | |
| if (job) { | |
| document.getElementById('jobStatus').innerHTML = `<span class="status-dot ${job.status}"></span>${job.status}`; | |
| if (job.logs) document.getElementById('logContent').innerText = job.logs; | |
| if (job.current_step && job.total_steps) { | |
| const pct = (job.current_step / job.total_steps) * 100; | |
| document.getElementById('progressBar').style.width = pct + '%'; | |
| document.getElementById('progressText').innerText = Math.round(pct) + '%'; | |
| } | |
| } | |
| } catch (e) { console.error('Failed to load job details', e); } | |
| } | |
| // ======================== WEBSOCKET MESSAGE HANDLER ======================== | |
| function handleWSMessage(event) { | |
| try { | |
| const msg = JSON.parse(event.data); | |
| switch (msg.type) { | |
| case 'status': | |
| document.getElementById('jobStatus').innerHTML = `<span class="status-dot ${msg.status}"></span>${msg.status}`; | |
| if (['completed', 'stopped', 'failed'].includes(msg.status)) { | |
| document.getElementById('startBtn').disabled = false; | |
| document.getElementById('stopBtn').disabled = true; | |
| document.getElementById('updateBtn').disabled = true; | |
| if (ws) { ws.close(); ws = null; } | |
| if (reconnectTimeout) clearTimeout(reconnectTimeout); | |
| loadJobList(); | |
| } | |
| break; | |
| case 'terminal': | |
| const termEl = document.getElementById('terminalOutput'); | |
| termEl.innerText += msg.data + '\n'; | |
| termEl.scrollTop = termEl.scrollHeight; | |
| break; | |
| case 'log': | |
| const logEl = document.getElementById('logContent'); | |
| logEl.innerText += msg.data + '\n'; | |
| logEl.scrollTop = logEl.scrollHeight; | |
| break; | |
| case 'metrics': | |
| if (msg.data.loss !== undefined) { | |
| plotData.lossSteps.push(plotData.lossSteps.length + 1); | |
| plotData.losses.push(msg.data.loss); | |
| Plotly.update('lossPlot', { x: [plotData.lossSteps], y: [plotData.losses] }, {}, [0]); | |
| } | |
| if (msg.data.eval_loss !== undefined) { | |
| plotData.evalSteps.push(plotData.evalSteps.length + 1); | |
| plotData.evalLosses.push(msg.data.eval_loss); | |
| Plotly.update('evalPlot', { x: [plotData.evalSteps], y: [plotData.evalLosses] }, {}, [0]); | |
| } | |
| break; | |
| case 'progress': | |
| const pct = Math.min(100, Math.round(msg.progress || 0)); | |
| document.getElementById('progressBar').style.width = pct + '%'; | |
| document.getElementById('progressText').innerText = pct + '% (Remaining: ' + formatTime(msg.eta_seconds) + ')'; | |
| break; | |
| default: | |
| // ignore | |
| } | |
| } catch (e) { | |
| console.error('Failed to parse WebSocket message', e); | |
| } | |
| } | |
| function formatTime(seconds) { | |
| if (!seconds || seconds < 0) return '--'; | |
| const h = Math.floor(seconds / 3600); | |
| const m = Math.floor((seconds % 3600) / 60); | |
| const s = Math.round(seconds % 60); | |
| return `${h}h ${m}m ${s}s`; | |
| } | |
| // ======================== SYSTEM POLLING ======================== | |
| async function pollSystem() { | |
| try { | |
| const sys = await apiGet('/system/status/'); | |
| if (sys) document.getElementById('systemInfo').innerHTML = `CPU: ${sys.cpu}% | Mem: ${sys.memory}%`; | |
| const gpu = await apiGet('/system/gpu/'); | |
| if (gpu && gpu.cuda_available) { | |
| const used = gpu.memory_allocated || 0; | |
| const total = gpu.memory_total || 0; | |
| document.getElementById('gpuInfo').innerHTML = `GPU: ${used.toFixed(2)} / ${total.toFixed(2)} GB`; | |
| } else { | |
| document.getElementById('gpuInfo').innerHTML = 'GPU: N/A'; | |
| } | |
| } catch (e) { console.warn('System poll error', e); } | |
| } | |
| // ======================== INITIALIZATION ======================== | |
| async function initApp() { | |
| await loadConfigOptions(); | |
| await loadJobList(); | |
| jobListInterval = setInterval(loadJobList, 10000); | |
| systemInterval = setInterval(pollSystem, 3000); | |
| pollSystem(); | |
| } | |
| // Check login state on load | |
| if (authToken) { | |
| document.getElementById('loginOverlay').style.display = 'none'; | |
| document.getElementById('app').style.display = 'flex'; | |
| initApp(); | |
| } else { | |
| document.getElementById('loginOverlay').style.display = 'flex'; | |
| document.getElementById('app').style.display = 'none'; | |
| } | |
| // Attach event listeners | |
| document.getElementById('startBtn').addEventListener('click', startTraining); | |
| document.getElementById('stopBtn').addEventListener('click', stopTraining); | |
| document.getElementById('updateBtn').addEventListener('click', updateConfig); | |
| document.getElementById('loginPassword')?.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') doLogin(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Xet Storage Details
- Size:
- 22.7 kB
- Xet hash:
- 0e2cacfbd50f86351a217bc1c5dd70e76d2b89f0c48fb74ba6f4c9687bf1f259
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.