Spaces:
Running
Running
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <meta name="apple-mobile-web-app-capable" content="yes"> | |
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> | |
| <meta name="theme-color" content="#0a0e17"> | |
| <link rel="manifest" href="manifest.json"> | |
| <link rel="apple-touch-icon" href="icon.png"> | |
| <title>Server Monitor</title> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| *,*::before,*::after{margin:0;padding:0;box-sizing:border-box} | |
| :root{ | |
| --bg:#0a0e17;--surface:#111827;--surface2:#1a2332;--border:#1e2d3d; | |
| --text:#e2e8f0;--text2:#8b9dc3;--text3:#4a5e78; | |
| --green:#22c55e;--green-dim:#0a3d1f;--yellow:#eab308;--yellow-dim:#3d3408; | |
| --red:#ef4444;--red-dim:#3d0a0a;--blue:#3b82f6;--cyan:#06b6d4; | |
| --glow-green:0 0 20px rgba(34,197,94,.15); | |
| --glow-red:0 0 20px rgba(239,68,68,.15); | |
| } | |
| html,body{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif; | |
| min-height:100vh;overflow-x:hidden;-webkit-font-smoothing:antialiased} | |
| body::before{content:'';position:fixed;inset:0;z-index:9999;pointer-events:none; | |
| opacity:.03;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")} | |
| .app{max-width:520px;margin:0 auto;padding:16px 16px 80px;position:relative} | |
| /* Header */ | |
| .header{display:flex;align-items:center;justify-content:space-between; | |
| padding:16px 0 20px;border-bottom:1px solid var(--border);margin-bottom:16px} | |
| .header-left{display:flex;align-items:center;gap:10px} | |
| .logo{width:32px;height:32px;border-radius:8px; | |
| background:linear-gradient(135deg,var(--cyan),var(--blue)); | |
| display:flex;align-items:center;justify-content:center; | |
| font-family:'JetBrains Mono',monospace;font-weight:700;font-size:14px;color:#fff; | |
| box-shadow:0 0 24px rgba(6,182,212,.2)} | |
| .header h1{font-size:16px;font-weight:600;letter-spacing:-.02em} | |
| .header-meta{text-align:right} | |
| .header-meta .time{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2)} | |
| .header-meta .status{font-size:10px;color:var(--green);display:flex;align-items:center;gap:4px;justify-content:flex-end;margin-top:2px} | |
| .header-meta .status::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--green); | |
| animation:pulse-dot 2s ease-in-out infinite} | |
| @keyframes pulse-dot{0%,100%{opacity:1}50%{opacity:.4}} | |
| /* ===== GRID VIEW (overview) ===== */ | |
| .grid{display:grid;grid-template-columns:1fr 1fr;gap:12px} | |
| .grid-card{background:var(--surface);border:1px solid var(--border);border-radius:14px; | |
| padding:16px;position:relative;overflow:hidden;cursor:pointer; | |
| transition:transform .15s,box-shadow .15s; | |
| animation:card-in .4s cubic-bezier(.16,1,.3,1) backwards} | |
| .grid-card:nth-child(1){animation-delay:.05s} | |
| .grid-card:nth-child(2){animation-delay:.1s} | |
| .grid-card:nth-child(3){animation-delay:.15s} | |
| .grid-card:nth-child(4){animation-delay:.2s} | |
| @keyframes card-in{from{opacity:0;transform:translateY(12px)}} | |
| .grid-card:active{transform:scale(.97)} | |
| .grid-card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px; | |
| background:linear-gradient(90deg,var(--cyan),var(--blue));opacity:.6} | |
| .grid-card.warn::before{background:linear-gradient(90deg,var(--yellow),#f59e0b)} | |
| .grid-card.danger::before{background:linear-gradient(90deg,var(--red),#dc2626)} | |
| .grid-dot{width:8px;height:8px;border-radius:50%;background:var(--green); | |
| box-shadow:var(--glow-green);display:inline-block;vertical-align:middle;margin-right:6px} | |
| .grid-dot.offline{background:var(--red);box-shadow:var(--glow-red);animation:blink 1s step-end infinite} | |
| @keyframes blink{50%{opacity:.3}} | |
| .grid-hostname{font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:600; | |
| white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:10px} | |
| .grid-age{font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text3); | |
| display:block;margin-top:2px;margin-left:14px} | |
| .grid-stats{display:flex;flex-direction:column;gap:8px} | |
| .grid-stat{display:flex;align-items:center;gap:6px} | |
| .grid-stat-label{font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text3); | |
| width:28px;flex-shrink:0;text-transform:uppercase} | |
| .grid-stat-bar{flex:1;height:6px;background:var(--surface2);border-radius:3px;overflow:hidden} | |
| .grid-stat-fill{height:100%;border-radius:3px;transition:width .6s cubic-bezier(.16,1,.3,1)} | |
| .grid-stat-val{font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:600;width:32px;text-align:right} | |
| .grid-gpu-summary{font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text3); | |
| margin-top:8px;padding-top:8px;border-top:1px solid var(--border)} | |
| /* ===== DETAIL VIEW ===== */ | |
| .detail-overlay{position:fixed;inset:0;background:var(--bg);z-index:150; | |
| overflow-y:auto;-webkit-overflow-scrolling:touch; | |
| transform:translateX(100%);transition:transform .3s cubic-bezier(.16,1,.3,1)} | |
| .detail-overlay.open{transform:translateX(0)} | |
| .detail-inner{max-width:520px;margin:0 auto;padding:16px 16px 40px} | |
| .detail-back{display:flex;align-items:center;gap:6px; | |
| font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--cyan); | |
| background:none;border:none;cursor:pointer;padding:12px 0;margin-bottom:8px} | |
| .detail-back::before{content:'<';font-size:14px} | |
| .detail-head{display:flex;align-items:center;justify-content:space-between; | |
| margin-bottom:20px;padding-bottom:14px;border-bottom:1px solid var(--border)} | |
| .detail-host{display:flex;align-items:center;gap:10px} | |
| .detail-host .dot{width:10px;height:10px;border-radius:50%;background:var(--green);box-shadow:var(--glow-green)} | |
| .detail-host .dot.offline{background:var(--red);box-shadow:var(--glow-red);animation:blink 1s step-end infinite} | |
| .detail-host h2{font-family:'JetBrains Mono',monospace;font-size:16px;font-weight:700} | |
| .detail-age{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text3); | |
| background:var(--surface2);padding:4px 10px;border-radius:6px} | |
| /* Stats row */ | |
| .stats{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:18px} | |
| .stat{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:12px} | |
| .stat-label{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.06em; | |
| font-family:'JetBrains Mono',monospace;margin-bottom:4px} | |
| .stat-value{font-family:'JetBrains Mono',monospace;font-size:22px;font-weight:700;letter-spacing:-.02em} | |
| .stat-bar{height:3px;background:var(--border);border-radius:2px;margin-top:6px;overflow:hidden} | |
| .stat-bar-fill{height:100%;border-radius:2px;transition:width .8s cubic-bezier(.16,1,.3,1)} | |
| .stat-sub{font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text3);margin-top:3px} | |
| /* GPU Section */ | |
| .section-title{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text3); | |
| text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px;margin-top:6px; | |
| display:flex;align-items:center;gap:8px} | |
| .section-title::before{content:'';flex:0 0 12px;height:1px;background:var(--border)} | |
| .section-title::after{content:'';flex:1;height:1px;background:var(--border)} | |
| .gpu-card{background:var(--surface);border:1px solid var(--border);border-radius:10px; | |
| padding:12px;margin-bottom:8px} | |
| .gpu-card.hot{border-color:rgba(239,68,68,.3)} | |
| .gpu-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px} | |
| .gpu-name{font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:500;color:var(--text2)} | |
| .gpu-temp{font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:600} | |
| .gpu-meters{display:grid;grid-template-columns:1fr 1fr;gap:8px} | |
| .gpu-meter-label{font-size:9px;color:var(--text3);font-family:'JetBrains Mono',monospace; | |
| margin-bottom:3px;display:flex;justify-content:space-between} | |
| .gpu-meter-bar{height:4px;background:var(--border);border-radius:2px;overflow:hidden} | |
| .gpu-meter-fill{height:100%;border-radius:2px;transition:width .8s cubic-bezier(.16,1,.3,1)} | |
| /* Tasks */ | |
| .task-item{display:flex;align-items:center;gap:8px;padding:8px 0; | |
| border-bottom:1px solid rgba(30,45,61,.5)} | |
| .task-item:last-child{border-bottom:none} | |
| .task-gpu-badge{font-family:'JetBrains Mono',monospace;font-size:9px; | |
| background:rgba(59,130,246,.15);color:var(--blue);padding:2px 6px;border-radius:4px;flex-shrink:0} | |
| .task-cmd{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2); | |
| white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1} | |
| .task-dur{font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text3);flex-shrink:0} | |
| /* Shared */ | |
| .empty{text-align:center;padding:60px 20px} | |
| .empty-icon{font-size:40px;margin-bottom:12px;opacity:.3} | |
| .empty-text{font-size:14px;color:var(--text3)} | |
| .loading{text-align:center;padding:80px 20px} | |
| .loading-spinner{width:32px;height:32px;border:2px solid var(--border); | |
| border-top-color:var(--cyan);border-radius:50%;margin:0 auto 16px;animation:spin .8s linear infinite} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .loading-text{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text3)} | |
| .error-banner{background:var(--red-dim);border:1px solid rgba(239,68,68,.3); | |
| border-radius:10px;padding:12px 16px;margin-bottom:16px; | |
| font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--red)} | |
| .c-green{color:var(--green)}.c-yellow{color:var(--yellow)}.c-red{color:var(--red)} | |
| .bg-green{background:var(--green)}.bg-yellow{background:var(--yellow)}.bg-red{background:var(--red)} | |
| .bg-blue{background:var(--blue)}.bg-cyan{background:var(--cyan)} | |
| /* Bottom bar */ | |
| .settings-bar{position:fixed;bottom:0;left:0;right:0; | |
| background:rgba(10,14,23,.92);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px); | |
| border-top:1px solid var(--border);padding:12px 16px;z-index:100; | |
| display:flex;align-items:center;justify-content:center;gap:16px} | |
| .settings-bar .info{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text3)} | |
| .countdown{color:var(--cyan)} | |
| .btn{background:var(--surface);border:1px solid var(--border);border-radius:8px; | |
| padding:6px 10px;color:var(--text2);font-size:11px;font-family:'JetBrains Mono',monospace; | |
| cursor:pointer;transition:all .2s} | |
| .btn:active{transform:scale(.95);background:var(--surface2)} | |
| /* Token overlay */ | |
| .token-overlay{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:200; | |
| display:flex;align-items:center;justify-content:center;padding:20px} | |
| .token-box{background:var(--surface);border:1px solid var(--border);border-radius:16px; | |
| padding:28px 24px;width:100%;max-width:400px} | |
| .token-box h2{font-size:16px;margin-bottom:6px} | |
| .token-box p{font-size:12px;color:var(--text2);margin-bottom:16px} | |
| .token-box input{width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border); | |
| border-radius:8px;color:var(--text);font-family:'JetBrains Mono',monospace;font-size:13px;outline:none} | |
| .token-box input:focus{border-color:var(--cyan)} | |
| .token-box .btn-row{display:flex;gap:8px;margin-top:12px} | |
| .token-box button{flex:1;padding:10px;border-radius:8px;font-size:13px;font-weight:600; | |
| cursor:pointer;border:none;transition:all .15s} | |
| .btn-primary{background:var(--cyan);color:#000} | |
| .btn-primary:active{transform:scale(.97)} | |
| .btn-secondary{background:var(--surface2);color:var(--text2);border:1px solid var(--border) } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app" id="app"> | |
| <header class="header"> | |
| <div class="header-left"> | |
| <div class="logo">SM</div> | |
| <h1>Server Monitor</h1> | |
| </div> | |
| <div class="header-meta"> | |
| <div class="time" id="clock"></div> | |
| <div class="status" id="status-line">LIVE</div> | |
| </div> | |
| </header> | |
| <div id="error-container"></div> | |
| <div id="server-list"> | |
| <div class="loading"><div class="loading-spinner"></div> | |
| <div class="loading-text">Connecting...</div></div> | |
| </div> | |
| </div> | |
| <!-- Detail slide-in panel --> | |
| <div class="detail-overlay" id="detail-overlay"> | |
| <div class="detail-inner" id="detail-content"></div> | |
| </div> | |
| <div class="settings-bar"> | |
| <button class="btn" onclick="fetchData()">REFRESH</button> | |
| <span class="info">next <span class="countdown" id="countdown">60</span>s</span> | |
| <button class="btn" onclick="showTokenDialog()">TOKEN</button> | |
| </div> | |
| <div class="token-overlay" id="token-overlay" style="display:none"> | |
| <div class="token-box"> | |
| <h2>HuggingFace Token</h2> | |
| <p>Repo is private. Enter your read token to view data.</p> | |
| <input type="password" id="token-input" placeholder="hf_xxxxx" autocomplete="off"> | |
| <div class="btn-row"> | |
| <button class="btn-secondary" onclick="closeTokenDialog()">Cancel</button> | |
| <button class="btn-primary" onclick="saveToken()">Save</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const REPO = 'Wendy-Fly/Truck_2026'; | |
| const MONITOR_DIR = 'monitor'; | |
| const REFRESH_INTERVAL = 60; | |
| let countdown = REFRESH_INTERVAL, timer = null; | |
| let serverData = []; // cached for detail view | |
| // --- Helpers --- | |
| function updateClock() { | |
| document.getElementById('clock').textContent = | |
| new Date().toLocaleTimeString('en-GB', { hour12: false }); | |
| } | |
| setInterval(updateClock, 1000); updateClock(); | |
| function getToken() { return localStorage.getItem('hf_token') || ''; } | |
| function showTokenDialog() { | |
| document.getElementById('token-overlay').style.display = 'flex'; | |
| document.getElementById('token-input').value = getToken(); | |
| } | |
| function closeTokenDialog() { document.getElementById('token-overlay').style.display = 'none'; } | |
| function saveToken() { | |
| const t = document.getElementById('token-input').value.trim(); | |
| if (t) localStorage.setItem('hf_token', t); else localStorage.removeItem('hf_token'); | |
| closeTokenDialog(); fetchData(); | |
| } | |
| function startCountdown() { | |
| countdown = REFRESH_INTERVAL; | |
| if (timer) clearInterval(timer); | |
| timer = setInterval(() => { | |
| countdown--; | |
| document.getElementById('countdown').textContent = countdown; | |
| if (countdown <= 0) fetchData(); | |
| }, 1000); | |
| } | |
| function pctColor(p) { return p >= 90 ? 'red' : p >= 70 ? 'yellow' : 'green'; } | |
| function tempColor(t) { return t >= 85 ? 'red' : t >= 70 ? 'yellow' : 'green'; } | |
| function formatAge(ts) { | |
| if (!ts) return 'unknown'; | |
| const m = Math.floor((Date.now() - new Date(ts).getTime()) / 60000); | |
| if (m < 1) return 'just now'; | |
| if (m < 60) return m + 'min ago'; | |
| const h = Math.floor(m / 60); | |
| if (h < 24) return h + 'h' + (m % 60) + 'm'; | |
| return Math.floor(h / 24) + 'd ago'; | |
| } | |
| function formatDuration(since) { | |
| if (!since) return '--'; | |
| const ms = Date.now() - new Date(since).getTime(); | |
| const h = Math.floor(ms / 3600000), m = Math.floor((ms % 3600000) / 60000); | |
| if (h > 24) return Math.floor(h / 24) + 'd ' + (h % 24) + 'h'; | |
| if (h > 0) return h + 'h ' + m + 'm'; | |
| return m + 'm'; | |
| } | |
| function isOffline(ts) { return !ts || (Date.now() - new Date(ts).getTime()) > 15 * 60000; } | |
| function gpuVramPct(g) { | |
| if (g.memory_percent != null) return g.memory_percent; | |
| return g.memory_total_mb > 0 ? g.memory_used_mb / g.memory_total_mb * 100 : 0; | |
| } | |
| function escHtml(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; } | |
| // --- GRID CARD (compact overview) --- | |
| function renderGridCard(data, idx) { | |
| const hw = data.hardware || {}; | |
| const gpus = hw.gpus || []; | |
| const offline = isOffline(data.timestamp); | |
| const cpuPct = hw.cpu_percent || 0; | |
| const gpuAvg = gpus.length > 0 ? Math.round(gpus.reduce((s,g) => s + gpuVramPct(g), 0) / gpus.length) : 0; | |
| const gpuMax = gpus.length > 0 ? Math.round(Math.max(...gpus.map(gpuVramPct))) : 0; | |
| const maxPct = Math.max(cpuPct, gpuMax); | |
| const cls = offline ? 'danger' : maxPct >= 90 ? 'danger' : maxPct >= 70 ? 'warn' : ''; | |
| // Count busy GPUs (>10% VRAM) | |
| const busyGpus = gpus.filter(g => gpuVramPct(g) > 10).length; | |
| return ` | |
| <div class="grid-card ${cls}" onclick="showDetail(${idx})"> | |
| <div class="grid-hostname"> | |
| <span class="grid-dot ${offline ? 'offline' : ''}"></span>${escHtml(data.hostname)} | |
| <span class="grid-age">${formatAge(data.timestamp)}</span> | |
| </div> | |
| <div class="grid-stats"> | |
| <div class="grid-stat"> | |
| <span class="grid-stat-label">CPU</span> | |
| <div class="grid-stat-bar"><div class="grid-stat-fill bg-${pctColor(cpuPct)}" style="width:${cpuPct}%"></div></div> | |
| <span class="grid-stat-val c-${pctColor(cpuPct)}">${Math.round(cpuPct)}%</span> | |
| </div> | |
| <div class="grid-stat"> | |
| <span class="grid-stat-label">GPU</span> | |
| <div class="grid-stat-bar"><div class="grid-stat-fill bg-${pctColor(gpuAvg)}" style="width:${gpuAvg}%"></div></div> | |
| <span class="grid-stat-val c-${pctColor(gpuAvg)}">${gpuAvg}%</span> | |
| </div> | |
| </div> | |
| <div class="grid-gpu-summary">${gpus.length} GPU${gpus.length > 1 ? 's' : ''} · ${busyGpus} busy</div> | |
| </div>`; | |
| } | |
| // --- DETAIL VIEW (full info) --- | |
| function renderDetail(data) { | |
| const hw = data.hardware || {}; | |
| const gpus = hw.gpus || []; | |
| const tasks = data.tasks || []; | |
| const offline = isOffline(data.timestamp); | |
| const cpuPct = hw.cpu_percent || 0; | |
| const memPct = hw.memory_percent || 0; | |
| const diskPct = hw.disk_percent || 0; | |
| const gpuAvg = gpus.length > 0 ? Math.round(gpus.reduce((s,g) => s + gpuVramPct(g), 0) / gpus.length) : 0; | |
| let gpuHtml = ''; | |
| if (gpus.length) { | |
| gpuHtml = `<div class="section-title">GPUs (${gpus.length})</div>`; | |
| for (const g of gpus) { | |
| const vp = Math.round(gpuVramPct(g)); | |
| const temp = g.temp_c != null ? g.temp_c : null; | |
| const tc = temp != null ? tempColor(temp) : 'green'; | |
| gpuHtml += ` | |
| <div class="gpu-card ${temp >= 85 ? 'hot' : ''}"> | |
| <div class="gpu-head"> | |
| <span class="gpu-name">#${g.id} ${g.name}</span> | |
| <span class="gpu-temp c-${tc}">${temp != null ? temp + '°C' : ''}</span> | |
| </div> | |
| <div class="gpu-meters"> | |
| <div> | |
| <div class="gpu-meter-label"><span>VRAM</span><span>${vp}%</span></div> | |
| <div class="gpu-meter-bar"><div class="gpu-meter-fill bg-${pctColor(vp)}" style="width:${vp}%"></div></div> | |
| </div> | |
| <div> | |
| <div class="gpu-meter-label"><span>MEM</span><span>${Math.round(g.memory_used_mb/1024)}/${Math.round(g.memory_total_mb/1024)}G</span></div> | |
| <div class="gpu-meter-bar"><div class="gpu-meter-fill bg-${pctColor(vp)}" style="width:${vp}%"></div></div> | |
| </div> | |
| </div> | |
| </div>`; | |
| } | |
| } | |
| let tasksHtml = ''; | |
| if (tasks.length) { | |
| tasksHtml = `<div class="section-title">Running Tasks (${tasks.length})</div>`; | |
| for (const t of tasks) { | |
| tasksHtml += ` | |
| <div class="task-item"> | |
| <span class="task-gpu-badge">GPU${t.gpu_id >= 0 ? t.gpu_id : '?'}</span> | |
| <span class="task-cmd" title="${escHtml(t.command)}">${escHtml(t.command)}</span> | |
| <span class="task-dur">${formatDuration(t.running_since)}</span> | |
| </div>`; | |
| } | |
| } else { | |
| tasksHtml = `<div class="section-title">Tasks</div><div style="font-size:12px;color:var(--text3);padding:8px 0">No running tasks</div>`; | |
| } | |
| return ` | |
| <button class="detail-back" onclick="hideDetail()">Back</button> | |
| <div class="detail-head"> | |
| <div class="detail-host"> | |
| <span class="dot ${offline ? 'offline' : ''}"></span> | |
| <h2>${escHtml(data.hostname)}</h2> | |
| </div> | |
| <span class="detail-age">${formatAge(data.timestamp)}</span> | |
| </div> | |
| <div class="stats"> | |
| <div class="stat"> | |
| <div class="stat-label">CPU</div> | |
| <div class="stat-value c-${pctColor(cpuPct)}">${cpuPct}<small>%</small></div> | |
| <div class="stat-bar"><div class="stat-bar-fill bg-${pctColor(cpuPct)}" style="width:${cpuPct}%"></div></div> | |
| </div> | |
| <div class="stat"> | |
| <div class="stat-label">GPU AVG</div> | |
| <div class="stat-value c-${pctColor(gpuAvg)}">${gpuAvg}<small>%</small></div> | |
| <div class="stat-bar"><div class="stat-bar-fill bg-${pctColor(gpuAvg)}" style="width:${gpuAvg}%"></div></div> | |
| <div class="stat-sub">${gpus.length}x GPU</div> | |
| </div> | |
| <div class="stat"> | |
| <div class="stat-label">${diskPct ? 'DISK' : memPct ? 'MEM' : 'MEM'}</div> | |
| <div class="stat-value c-${pctColor(diskPct || memPct)}">${diskPct || memPct || '--'}<small>${(diskPct || memPct) ? '%' : ''}</small></div> | |
| <div class="stat-bar"><div class="stat-bar-fill bg-${pctColor(diskPct || memPct)}" style="width:${diskPct || memPct || 0}%"></div></div> | |
| <div class="stat-sub">${hw.memory_used_gb ? hw.memory_used_gb + '/' + hw.memory_total_gb + 'G' : ''}</div> | |
| </div> | |
| </div> | |
| ${gpuHtml} | |
| ${tasksHtml}`; | |
| } | |
| function showDetail(idx) { | |
| const data = serverData[idx]; | |
| if (!data) return; | |
| document.getElementById('detail-content').innerHTML = renderDetail(data); | |
| document.getElementById('detail-overlay').classList.add('open'); | |
| } | |
| function hideDetail() { | |
| document.getElementById('detail-overlay').classList.remove('open'); | |
| } | |
| // --- FETCH --- | |
| async function fetchData() { | |
| countdown = REFRESH_INTERVAL; | |
| const token = getToken(); | |
| const headers = token ? { 'Authorization': 'Bearer ' + token } : {}; | |
| const errorEl = document.getElementById('error-container'); | |
| const listEl = document.getElementById('server-list'); | |
| try { | |
| const treeUrl = `https://huggingface.co/api/datasets/${REPO}/tree/main/${MONITOR_DIR}`; | |
| const treeResp = await fetch(treeUrl, { headers }); | |
| if (!treeResp.ok) { | |
| if (treeResp.status === 401 || treeResp.status === 403) { | |
| errorEl.innerHTML = `<div class="error-banner">Access denied. Click TOKEN below to enter your HF token.</div>`; | |
| listEl.innerHTML = ''; return; | |
| } | |
| if (treeResp.status === 404) { | |
| listEl.innerHTML = `<div class="empty"><div class="empty-icon">~/</div><div class="empty-text">No monitor/ directory yet.<br>Start a server agent first.</div></div>`; | |
| errorEl.innerHTML = ''; return; | |
| } | |
| throw new Error(`API ${treeResp.status}`); | |
| } | |
| const files = await treeResp.json(); | |
| const jsonFiles = files.filter(f => f.path.endsWith('.json')); | |
| if (!jsonFiles.length) { | |
| listEl.innerHTML = `<div class="empty"><div class="empty-icon">~/</div><div class="empty-text">Waiting for first report...</div></div>`; | |
| errorEl.innerHTML = ''; return; | |
| } | |
| const servers = []; | |
| for (const f of jsonFiles) { | |
| const url = `https://huggingface.co/datasets/${REPO}/resolve/main/${f.path}?t=${Date.now()}`; | |
| const resp = await fetch(url, { headers }); | |
| if (resp.ok) servers.push(await resp.json()); | |
| } | |
| servers.sort((a, b) => { | |
| const aO = isOffline(a.timestamp), bO = isOffline(b.timestamp); | |
| if (aO !== bO) return aO ? 1 : -1; | |
| return (a.hostname || '').localeCompare(b.hostname || ''); | |
| }); | |
| serverData = servers; | |
| listEl.innerHTML = `<div class="grid">${servers.map((s, i) => renderGridCard(s, i)).join('')}</div>`; | |
| errorEl.innerHTML = ''; | |
| document.getElementById('status-line').textContent = `LIVE · ${servers.length} server${servers.length > 1 ? 's' : ''}`; | |
| } catch (err) { | |
| errorEl.innerHTML = `<div class="error-banner">Error: ${escHtml(err.message)}</div>`; | |
| } | |
| startCountdown(); | |
| } | |
| // Back gesture | |
| document.getElementById('detail-overlay').addEventListener('touchstart', function(e) { | |
| this._touchX = e.touches[0].clientX; | |
| }, { passive: true }); | |
| document.getElementById('detail-overlay').addEventListener('touchend', function(e) { | |
| if (this._touchX < 40 && e.changedTouches[0].clientX > 80) hideDetail(); | |
| }, { passive: true }); | |
| fetchData(); | |
| </script> | |
| </body> | |
| </html> | |