Spaces:
Sleeping
Sleeping
Upload 3 files
Browse files- static/app.js +141 -0
- static/style.css +82 -0
static/app.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const tbody = document.getElementById("statusTbody");
|
| 2 |
+
const lastRefresh = document.getElementById("lastRefresh");
|
| 3 |
+
const checkNowBtn = document.getElementById("checkNowBtn");
|
| 4 |
+
const addSiteBtn = document.getElementById("addSiteBtn");
|
| 5 |
+
const addDialog = document.getElementById("addDialog");
|
| 6 |
+
const addForm = document.getElementById("addForm");
|
| 7 |
+
const cancelAdd = document.getElementById("cancelAdd");
|
| 8 |
+
const siteName = document.getElementById("siteName");
|
| 9 |
+
const siteUrl = document.getElementById("siteUrl");
|
| 10 |
+
|
| 11 |
+
const incidentPane = document.getElementById("incidentPane");
|
| 12 |
+
const incidentTitle = document.getElementById("incidentTitle");
|
| 13 |
+
const incidentsList = document.getElementById("incidentsList");
|
| 14 |
+
const closeIncidents = document.getElementById("closeIncidents");
|
| 15 |
+
|
| 16 |
+
function fmtTs(s){
|
| 17 |
+
if(!s) return "—";
|
| 18 |
+
const d = new Date(s);
|
| 19 |
+
return d.toLocaleString();
|
| 20 |
+
}
|
| 21 |
+
function fmtPct(v){
|
| 22 |
+
if(v === null || v === undefined) return "—";
|
| 23 |
+
return `${v.toFixed ? v.toFixed(2) : v}%`;
|
| 24 |
+
}
|
| 25 |
+
function dot(ok){
|
| 26 |
+
return `<span class="dot ${ok ? 'green':'red'}" title="${ok?'UP':'DOWN'}"></span>`;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
async function fetchStatus(){
|
| 30 |
+
const res = await fetch("/api/status");
|
| 31 |
+
const data = await res.json();
|
| 32 |
+
tbody.innerHTML = "";
|
| 33 |
+
data.forEach(item => {
|
| 34 |
+
const last = item.last || {};
|
| 35 |
+
const tr = document.createElement("tr");
|
| 36 |
+
tr.innerHTML = `
|
| 37 |
+
<td>${dot(last.ok)}</td>
|
| 38 |
+
<td>${item.name}</td>
|
| 39 |
+
<td><a class="url" href="${item.url}" target="_blank" rel="noopener">${item.url}</a></td>
|
| 40 |
+
<td>${fmtTs(last.ts)}</td>
|
| 41 |
+
<td>${last.ms ?? "—"}</td>
|
| 42 |
+
<td>${last.status_code ?? "—"}</td>
|
| 43 |
+
<td><span class="badge">${fmtPct(item.uptime24h)}</span></td>
|
| 44 |
+
<td><span class="badge">${fmtPct(item.uptime7d)}</span></td>
|
| 45 |
+
<td class="row-actions">
|
| 46 |
+
<button class="ghost" data-action="incidents" data-url="${item.url}" data-name="${item.name}">Incidents</button>
|
| 47 |
+
<button class="ghost" data-action="delete" data-url="${item.url}">Delete</button>
|
| 48 |
+
</td>
|
| 49 |
+
`;
|
| 50 |
+
tbody.appendChild(tr);
|
| 51 |
+
});
|
| 52 |
+
lastRefresh.textContent = `Last refresh: ${new Date().toLocaleTimeString()}`;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
async function checkNow(){
|
| 56 |
+
checkNowBtn.disabled = true;
|
| 57 |
+
try{
|
| 58 |
+
await fetch("/api/check-now", {method:"POST"});
|
| 59 |
+
await fetchStatus();
|
| 60 |
+
} finally {
|
| 61 |
+
checkNowBtn.disabled = false;
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function openAdd(){
|
| 66 |
+
siteName.value = "";
|
| 67 |
+
siteUrl.value = "";
|
| 68 |
+
addDialog.showModal();
|
| 69 |
+
}
|
| 70 |
+
function closeAdd(){
|
| 71 |
+
addDialog.close();
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
addForm.addEventListener("submit", async (e) => {
|
| 75 |
+
e.preventDefault();
|
| 76 |
+
const body = { name: siteName.value || siteUrl.value, url: siteUrl.value };
|
| 77 |
+
if(!body.url) return;
|
| 78 |
+
const res = await fetch("/api/sites", {
|
| 79 |
+
method:"POST",
|
| 80 |
+
headers: { "Content-Type":"application/json" },
|
| 81 |
+
body: JSON.stringify(body)
|
| 82 |
+
});
|
| 83 |
+
if(res.ok){
|
| 84 |
+
closeAdd();
|
| 85 |
+
await fetchStatus();
|
| 86 |
+
} else {
|
| 87 |
+
alert("Failed to add site");
|
| 88 |
+
}
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
cancelAdd.addEventListener("click", (e)=>{ e.preventDefault(); closeAdd(); });
|
| 92 |
+
|
| 93 |
+
tbody.addEventListener("click", async (e) => {
|
| 94 |
+
const btn = e.target.closest("button");
|
| 95 |
+
if(!btn) return;
|
| 96 |
+
const action = btn.dataset.action;
|
| 97 |
+
const url = btn.dataset.url;
|
| 98 |
+
if(action === "delete"){
|
| 99 |
+
if(confirm(`Delete monitor for:\n${url}?`)){
|
| 100 |
+
await fetch(`/api/sites?url=${encodeURIComponent(url)}`, { method: "DELETE" });
|
| 101 |
+
await fetchStatus();
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
if(action === "incidents"){
|
| 105 |
+
await loadIncidents(url, btn.dataset.name || url);
|
| 106 |
+
}
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
async function loadIncidents(url, name){
|
| 110 |
+
const res = await fetch(`/api/incidents?url=${encodeURIComponent(url)}`);
|
| 111 |
+
const data = await res.json();
|
| 112 |
+
incidentPane.classList.remove("hidden");
|
| 113 |
+
incidentTitle.textContent = `Incidents — ${name}`;
|
| 114 |
+
if(!data.length){
|
| 115 |
+
incidentsList.innerHTML = `<div class="muted" style="padding:8px 2px">No incidents recorded.</div>`;
|
| 116 |
+
return;
|
| 117 |
+
}
|
| 118 |
+
incidentsList.innerHTML = "";
|
| 119 |
+
data.forEach(x => {
|
| 120 |
+
const end = x.end_ts ? new Date(x.end_ts) : null;
|
| 121 |
+
const start = new Date(x.start_ts);
|
| 122 |
+
const durationMin = end ? Math.max(0, Math.round((end - start)/60000)) : null;
|
| 123 |
+
const div = document.createElement("div");
|
| 124 |
+
div.className = "incident";
|
| 125 |
+
div.innerHTML = `
|
| 126 |
+
<div>
|
| 127 |
+
<div><strong class="down">DOWN</strong> ${start.toLocaleString()}</div>
|
| 128 |
+
${end ? `<div><strong class="ok">UP</strong> ${end.toLocaleString()}</div>` : `<div class="muted">ongoing...</div>`}
|
| 129 |
+
</div>
|
| 130 |
+
<div class="muted">${durationMin !== null ? durationMin + " min" : ""}</div>
|
| 131 |
+
`;
|
| 132 |
+
incidentsList.appendChild(div);
|
| 133 |
+
});
|
| 134 |
+
}
|
| 135 |
+
closeIncidents.addEventListener("click", ()=> incidentPane.classList.add("hidden"));
|
| 136 |
+
|
| 137 |
+
checkNowBtn.addEventListener("click", checkNow);
|
| 138 |
+
addSiteBtn.addEventListener("click", openAdd);
|
| 139 |
+
|
| 140 |
+
fetchStatus();
|
| 141 |
+
setInterval(fetchStatus, 30000);
|
static/style.css
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root{
|
| 2 |
+
--bg:#0b1220; --panel:#121a2e; --card:#0f1730; --text:#e6edf7; --muted:#9fb0cf;
|
| 3 |
+
--green:#18c37e; --red:#ff6363; --accent:#3a8dde; --ring: rgba(58,141,222,.35);
|
| 4 |
+
--radius:16px; --shadow: 0 10px 28px rgba(2,8,23,.35);
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
*{box-sizing:border-box}
|
| 8 |
+
html,body{height:100%}
|
| 9 |
+
body{
|
| 10 |
+
margin:0; font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
|
| 11 |
+
color:var(--text); background:linear-gradient(180deg,#0b1220,#0b1220 60%, #0d1530);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
header{
|
| 15 |
+
display:flex; align-items:center; justify-content:space-between;
|
| 16 |
+
padding:16px 22px; position:sticky; top:0; background:#0b1220cc; backdrop-filter:saturate(120%) blur(6px);
|
| 17 |
+
border-bottom:1px solid #1b2542;
|
| 18 |
+
}
|
| 19 |
+
.brand{display:flex; align-items:center; gap:12px;}
|
| 20 |
+
.logo-dot{width:14px; height:14px; border-radius:50%; background:linear-gradient(135deg,var(--accent),#6ea8ff); box-shadow:0 0 20px #2e6fd6;}
|
| 21 |
+
.title{font-weight:700; letter-spacing:.3px}
|
| 22 |
+
|
| 23 |
+
.actions button{
|
| 24 |
+
background:var(--accent); color:white; border:none; padding:10px 14px; border-radius:12px; cursor:pointer;
|
| 25 |
+
box-shadow:var(--shadow); margin-left:8px; font-weight:600;
|
| 26 |
+
}
|
| 27 |
+
.actions button#addSiteBtn{background:#243254; border:1px solid #2d3c63}
|
| 28 |
+
|
| 29 |
+
main{max-width:1100px; margin:26px auto; padding:0 16px; display:grid; gap:18px}
|
| 30 |
+
.card{
|
| 31 |
+
background:var(--panel); border-radius:var(--radius); box-shadow:var(--shadow); border:1px solid #1b2542;
|
| 32 |
+
}
|
| 33 |
+
.card-head{display:flex; align-items:center; justify-content:space-between; padding:16px 18px; border-bottom:1px solid #1b2542}
|
| 34 |
+
.muted{color:var(--muted); font-size:13px}
|
| 35 |
+
.table-wrap{overflow:auto}
|
| 36 |
+
table{width:100%; border-collapse:collapse}
|
| 37 |
+
th, td{padding:12px 14px; border-bottom:1px solid #1b2542; text-align:left}
|
| 38 |
+
th{color:var(--muted); font-weight:600; background: #0f1730}
|
| 39 |
+
tbody tr:hover{background:#0f1730}
|
| 40 |
+
|
| 41 |
+
.dot{
|
| 42 |
+
width:12px; height:12px; border-radius:50%;
|
| 43 |
+
box-shadow:0 0 0 3px #0b1220, 0 0 16px rgba(0,0,0,.25);
|
| 44 |
+
display:inline-block;
|
| 45 |
+
}
|
| 46 |
+
.dot.green{background:var(--green)}
|
| 47 |
+
.dot.red{background:var(--red)}
|
| 48 |
+
|
| 49 |
+
a.url{color:#a9c6ff; text-decoration:none}
|
| 50 |
+
a.url:hover{text-decoration:underline}
|
| 51 |
+
|
| 52 |
+
.badge{padding:4px 8px; border-radius:999px; font-size:12px; border:1px solid #263257; color:#c9d7ff; background:#102143}
|
| 53 |
+
|
| 54 |
+
td .row-actions{display:flex; gap:8px}
|
| 55 |
+
button.ghost{
|
| 56 |
+
background:transparent; border:1px solid #27355f; color:#c9d7ff; padding:8px 12px; border-radius:12px; cursor:pointer;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.hidden{display:none}
|
| 60 |
+
|
| 61 |
+
.incidents{padding:10px 16px}
|
| 62 |
+
.incident{
|
| 63 |
+
display:flex; align-items:center; justify-content:space-between;
|
| 64 |
+
background:#0f1730; border:1px solid #1b2542; border-radius:12px; padding:10px 12px; margin-bottom:10px;
|
| 65 |
+
}
|
| 66 |
+
.incident .down{color:#ff9f9f}
|
| 67 |
+
.incident .ok{color:#9fffc7}
|
| 68 |
+
|
| 69 |
+
dialog{
|
| 70 |
+
border:none; border-radius:18px; padding:0; background:#111a31; color:var(--text); box-shadow: var(--shadow);
|
| 71 |
+
}
|
| 72 |
+
.dialog-card{padding:18px; width:360px}
|
| 73 |
+
.dialog-card h3{margin:0 0 10px}
|
| 74 |
+
.dialog-card label{display:block; margin:10px 0}
|
| 75 |
+
.dialog-card input{
|
| 76 |
+
width:100%; padding:10px 12px; border-radius:12px; border:1px solid #283663; background:#0f1730; color:var(--text);
|
| 77 |
+
}
|
| 78 |
+
.dialog-card .row{display:flex; gap:10px; margin-top:14px}
|
| 79 |
+
.dialog-card button{
|
| 80 |
+
background:var(--accent); color:white; border:none; padding:10px 14px; border-radius:12px; cursor:pointer; font-weight:600;
|
| 81 |
+
}
|
| 82 |
+
.dialog-card button.ghost{background:transparent; border:1px solid #27355f; color:#c9d7ff}
|