Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"/> | |
| <title>Uptime Monitor</title> | |
| <style> | |
| :root{ | |
| --bg:#0b1220; --panel:#121a2e; --card:#0f1730; --text:#e6edf7; --muted:#9fb0cf; | |
| --green:#18c37e; --red:#ff6363; --accent:#3a8dde; --ring: rgba(58,141,222,.35); | |
| --radius:16px; --shadow: 0 10px 28px rgba(2,8,23,.35); | |
| } | |
| *{box-sizing:border-box} | |
| html,body{height:100%} | |
| body{margin:0; font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; | |
| color:var(--text); background:linear-gradient(180deg,#0b1220,#0b1220 60%, #0d1530);} | |
| header{display:flex; align-items:center; justify-content:space-between; padding:16px 22px; | |
| position:sticky; top:0; background:#0b1220cc; backdrop-filter:saturate(120%) blur(6px); border-bottom:1px solid #1b2542;} | |
| .brand{display:flex; align-items:center; gap:12px;} | |
| .logo-dot{width:14px; height:14px; border-radius:50%; background:linear-gradient(135deg,var(--accent),#6ea8ff); box-shadow:0 0 20px #2e6fd6;} | |
| .title{font-weight:700; letter-spacing:.3px} | |
| .actions button{background:var(--accent); color:white; border:none; padding:10px 14px; border-radius:12px; cursor:pointer; | |
| box-shadow:var(--shadow); margin-left:8px; font-weight:600;} | |
| .actions button#addSiteBtn{background:#243254; border:1px solid #2d3c63} | |
| main{max-width:1100px; margin:26px auto; padding:0 16px; display:grid; gap:18px} | |
| .card{background:var(--panel); border-radius:var(--radius); box-shadow:var(--shadow); border:1px solid #1b2542;} | |
| .card-head{display:flex; align-items:center; justify-content:space-between; padding:16px 18px; border-bottom:1px solid #1b2542} | |
| .muted{color:var(--muted); font-size:13px} | |
| .table-wrap{overflow:auto} | |
| table{width:100%; border-collapse:collapse} | |
| th, td{padding:12px 14px; border-bottom:1px solid #1b2542; text-align:left} | |
| th{color:var(--muted); font-weight:600; background:#0f1730} | |
| tbody tr:hover{background:#0f1730} | |
| .dot{width:12px; height:12px; border-radius:50%; box-shadow:0 0 0 3px #0b1220, 0 0 16px rgba(0,0,0,.25); display:inline-block;} | |
| .dot.green{background:var(--green)} .dot.red{background:var(--red)} | |
| a.url{color:#a9c6ff; text-decoration:none} a.url:hover{text-decoration:underline} | |
| .badge{padding:4px 8px; border-radius:999px; font-size:12px; border:1px solid #263257; color:#c9d7ff; background:#102143} | |
| td .row-actions{display:flex; gap:8px} | |
| button.ghost{background:transparent; border:1px solid #27355f; color:#c9d7ff; padding:8px 12px; border-radius:12px; cursor:pointer;} | |
| .hidden{display:none} | |
| .incidents{padding:10px 16px} | |
| .incident{display:flex; align-items:center; justify-content:space-between; background:#0f1730; border:1px solid #1b2542; | |
| border-radius:12px; padding:10px 12px; margin-bottom:10px;} | |
| .incident .down{color:#ff9f9f} .incident .ok{color:#9fffc7} | |
| dialog{border:none; border-radius:18px; padding:0; background:#111a31; color:var(--text); box-shadow: var(--shadow);} | |
| .dialog-card{padding:18px; width:380px} | |
| .dialog-card h3{margin:0 0 10px} | |
| .dialog-card label{display:block; margin:10px 0} | |
| .dialog-card input{width:100%; padding:10px 12px; border-radius:12px; border:1px solid #283663; background:#0f1730; color:var(--text);} | |
| .dialog-card small{color:var(--muted)} | |
| .dialog-card .row{display:flex; gap:10px; margin-top:14px} | |
| .dialog-card button{background:var(--accent); color:white; border:none; padding:10px 14px; border-radius:12px; cursor:pointer; font-weight:600;} | |
| .dialog-card button.ghost{background:transparent; border:1px solid #27355f; color:#c9d7ff} | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="brand"> | |
| <div class="logo-dot"></div> | |
| <div class="title">Uptime Monitor</div> | |
| </div> | |
| <div class="actions"> | |
| <button id="checkNowBtn">Check Now</button> | |
| <button id="addSiteBtn">+ Add Site</button> | |
| </div> | |
| </header> | |
| <main> | |
| <section class="card"> | |
| <div class="card-head"> | |
| <h2>Monitors</h2> | |
| <span id="lastRefresh" class="muted"></span> | |
| </div> | |
| <div class="table-wrap"> | |
| <table id="statusTable"> | |
| <thead> | |
| <tr> | |
| <th>Status</th> | |
| <th>Name</th> | |
| <th>URL</th> | |
| <th>Last Check</th> | |
| <th>Resp (ms)</th> | |
| <th>Code</th> | |
| <th>Uptime 24h</th> | |
| <th>Uptime 7d</th> | |
| <th></th> | |
| </tr> | |
| </thead> | |
| <tbody id="statusTbody"></tbody> | |
| </table> | |
| </div> | |
| </section> | |
| <section id="incidentPane" class="card hidden"> | |
| <div class="card-head"> | |
| <h2 id="incidentTitle">Incidents</h2> | |
| <button id="closeIncidents" class="ghost">Close</button> | |
| </div> | |
| <div id="incidentsList" class="incidents"></div> | |
| </section> | |
| </main> | |
| <!-- Add site modal --> | |
| <dialog id="addDialog"> | |
| <form method="dialog" id="addForm" class="dialog-card"> | |
| <h3>Add Site</h3> | |
| <label>Display Name | |
| <input type="text" id="siteName" placeholder="e.g., Weather API"/> | |
| </label> | |
| <label>URL | |
| <input type="url" id="siteUrl" placeholder="https://example.com/api/ping" required/> | |
| </label> | |
| <label>Hugging Face token (optional) | |
| <input type="password" id="hfToken" placeholder="hf_..." autocomplete="off"/> | |
| <small>Needed for private/Org Spaces. Paste only the token (without “Bearer”).</small> | |
| </label> | |
| <div class="row"> | |
| <button type="submit" id="saveSite">Save</button> | |
| <button id="cancelAdd" class="ghost">Cancel</button> | |
| </div> | |
| </form> | |
| </dialog> | |
| <script> | |
| const tbody = document.getElementById("statusTbody"); | |
| const lastRefresh = document.getElementById("lastRefresh"); | |
| const checkNowBtn = document.getElementById("checkNowBtn"); | |
| const addSiteBtn = document.getElementById("addSiteBtn"); | |
| const addDialog = document.getElementById("addDialog"); | |
| const addForm = document.getElementById("addForm"); | |
| const cancelAdd = document.getElementById("cancelAdd"); | |
| const siteName = document.getElementById("siteName"); | |
| const siteUrl = document.getElementById("siteUrl"); | |
| const hfToken = document.getElementById("hfToken"); | |
| const incidentPane = document.getElementById("incidentPane"); | |
| const incidentTitle = document.getElementById("incidentTitle"); | |
| const incidentsList = document.getElementById("incidentsList"); | |
| const closeIncidents = document.getElementById("closeIncidents"); | |
| function fmtTs(s){ | |
| if(!s) return "—"; | |
| const d = new Date(s); | |
| return d.toLocaleString(); | |
| } | |
| function fmtPct(v){ | |
| if(v === null || v === undefined) return "—"; | |
| return `${v.toFixed ? v.toFixed(2) : v}%`; | |
| } | |
| function dot(ok){ | |
| return `<span class="dot ${ok ? 'green':'red'}" title="${ok?'UP':'DOWN'}"></span>`; | |
| } | |
| async function fetchStatus(){ | |
| const res = await fetch("/api/status"); | |
| const data = await res.json(); | |
| tbody.innerHTML = ""; | |
| data.forEach(item => { | |
| const last = item.last || {}; | |
| const tr = document.createElement("tr"); | |
| tr.innerHTML = ` | |
| <td>${dot(last.ok)}</td> | |
| <td>${item.name}</td> | |
| <td><a class="url" href="${item.url}" target="_blank" rel="noopener">${item.url}</a></td> | |
| <td>${fmtTs(last.ts)}</td> | |
| <td>${last.ms ?? "—"}</td> | |
| <td>${last.status_code ?? "—"}</td> | |
| <td><span class="badge">${fmtPct(item.uptime24h)}</span></td> | |
| <td><span class="badge">${fmtPct(item.uptime7d)}</span></td> | |
| <td class="row-actions"> | |
| <button class="ghost" data-action="incidents" data-url="${item.url}" data-name="${item.name}">Incidents</button> | |
| <button class="ghost" data-action="delete" data-url="${item.url}">Delete</button> | |
| </td> | |
| `; | |
| tbody.appendChild(tr); | |
| }); | |
| lastRefresh.textContent = `Last refresh: ${new Date().toLocaleTimeString()}`; | |
| } | |
| async function checkNow(){ | |
| checkNowBtn.disabled = true; | |
| try{ | |
| await fetch("/api/check-now", {method:"POST"}); | |
| await fetchStatus(); | |
| } finally { | |
| checkNowBtn.disabled = false; | |
| } | |
| } | |
| function openAdd(){ | |
| siteName.value = ""; | |
| siteUrl.value = ""; | |
| hfToken.value = ""; | |
| addDialog.showModal(); | |
| } | |
| function closeAdd(){ addDialog.close(); } | |
| addForm.addEventListener("submit", async (e) => { | |
| e.preventDefault(); | |
| const body = { name: siteName.value || siteUrl.value, url: siteUrl.value }; | |
| const tok = (hfToken.value || "").trim(); | |
| if (tok) { | |
| body.hf_token = tok.startsWith("Bearer ") ? tok.slice(7).trim() : tok; | |
| } | |
| const res = await fetch("/api/sites", { | |
| method:"POST", | |
| headers: { "Content-Type":"application/json" }, | |
| body: JSON.stringify(body) | |
| }); | |
| if (res.ok){ | |
| closeAdd(); | |
| await fetchStatus(); | |
| } else { | |
| const msg = await res.text(); | |
| alert("Failed to add site:\n" + msg); | |
| } | |
| }); | |
| cancelAdd.addEventListener("click", (e)=>{ e.preventDefault(); closeAdd(); }); | |
| tbody.addEventListener("click", async (e) => { | |
| const btn = e.target.closest("button"); | |
| if(!btn) return; | |
| const action = btn.dataset.action; | |
| const url = btn.dataset.url; | |
| if(action === "delete"){ | |
| if(confirm(`Delete monitor for:\n${url}?`)){ | |
| await fetch(`/api/sites?url=${encodeURIComponent(url)}`, { method: "DELETE" }); | |
| await fetchStatus(); | |
| } | |
| } | |
| if(action === "incidents"){ | |
| await loadIncidents(url, btn.dataset.name || url); | |
| } | |
| }); | |
| async function loadIncidents(url, name){ | |
| const res = await fetch(`/api/incidents?url=${encodeURIComponent(url)}`); | |
| const data = await res.json(); | |
| incidentPane.classList.remove("hidden"); | |
| incidentTitle.textContent = `Incidents — ${name}`; | |
| if(!data.length){ | |
| incidentsList.innerHTML = `<div class="muted" style="padding:8px 2px">No incidents recorded.</div>`; | |
| return; | |
| } | |
| incidentsList.innerHTML = ""; | |
| data.forEach(x => { | |
| const end = x.end_ts ? new Date(x.end_ts) : null; | |
| const start = new Date(x.start_ts); | |
| const durationMin = end ? Math.max(0, Math.round((end - start)/60000)) : null; | |
| const div = document.createElement("div"); | |
| div.className = "incident"; | |
| div.innerHTML = ` | |
| <div> | |
| <div><strong class="down">DOWN</strong> ${start.toLocaleString()}</div> | |
| ${end ? `<div><strong class="ok">UP</strong> ${end.toLocaleString()}</div>` : `<div class="muted">ongoing...</div>`} | |
| </div> | |
| <div class="muted">${durationMin !== null ? durationMin + " min" : ""}</div> | |
| `; | |
| incidentsList.appendChild(div); | |
| }); | |
| } | |
| closeIncidents.addEventListener("click", ()=> incidentPane.classList.add("hidden")); | |
| document.getElementById("checkNowBtn").addEventListener("click", checkNow); | |
| document.getElementById("addSiteBtn").addEventListener("click", openAdd); | |
| fetchStatus(); | |
| setInterval(fetchStatus, 30000); | |
| </script> | |
| </body> | |
| </html> | |