ServerMonitor / index.html
Wendy-Fly's picture
Upload folder using huggingface_hub
4b15561 verified
Raw
History Blame Contribute Delete
23.9 kB
<!DOCTYPE html>
<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) !important}
</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>