ntdservices commited on
Commit
18cd33a
·
verified ·
1 Parent(s): aa66c2a

Upload 4 files

Browse files
Files changed (2) hide show
  1. app.py +48 -36
  2. static/index.html +201 -4
app.py CHANGED
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
3
  from threading import Lock
4
 
5
  import httpx
6
- from fastapi import FastAPI, HTTPException, Request
7
  from fastapi.responses import FileResponse, JSONResponse
8
  from fastapi.middleware.cors import CORSMiddleware
9
  from starlette.staticfiles import StaticFiles
@@ -32,6 +32,10 @@ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
32
  conn.row_factory = sqlite3.Row
33
  db_lock = Lock()
34
 
 
 
 
 
35
  def init_db():
36
  with db_lock:
37
  c = conn.cursor()
@@ -41,8 +45,7 @@ def init_db():
41
  name TEXT NOT NULL,
42
  method TEXT DEFAULT 'GET',
43
  expected_min INT DEFAULT 200,
44
- expected_max INT DEFAULT 399,
45
- created_at TEXT DEFAULT (datetime('now'))
46
  );
47
  """)
48
  c.execute("""
@@ -65,6 +68,9 @@ def init_db():
65
  FOREIGN KEY (url) REFERENCES sites(url)
66
  );
67
  """)
 
 
 
68
  conn.commit()
69
 
70
  # Seed a few demo sites if empty
@@ -77,9 +83,12 @@ def init_db():
77
  pass
78
  conn.commit()
79
 
80
- def list_sites():
 
 
 
81
  with db_lock:
82
- rows = conn.execute("SELECT url, name, method, expected_min, expected_max FROM sites ORDER BY url").fetchall()
83
  return [dict(r) for r in rows]
84
 
85
  def get_last_check(url: str):
@@ -104,11 +113,9 @@ def record_check(url: str, ok: int, status_code: int | None, ms: float | None, t
104
 
105
  def open_incident_if_needed(url: str, now_iso: str, prev_ok: int | None, current_ok: int):
106
  with db_lock:
107
- # If transitioning from UP->DOWN, open a new incident
108
  if (prev_ok == 1 or prev_ok is None) and current_ok == 0:
109
  conn.execute("INSERT INTO incidents(url, start_ts) VALUES (?,?)", (url, now_iso))
110
  conn.commit()
111
- # If transitioning from DOWN->UP, close the latest open incident
112
  elif prev_ok == 0 and current_ok == 1:
113
  conn.execute(
114
  "UPDATE incidents SET end_ts=? WHERE url=? AND end_ts IS NULL",
@@ -144,12 +151,19 @@ async def check_one(client: httpx.AsyncClient, site: dict):
144
  expected_min = int(site.get("expected_min", 200))
145
  expected_max = int(site.get("expected_max", 399))
146
 
 
 
 
 
 
 
 
 
147
  t0 = time.perf_counter()
148
  code = None
149
  ok = 0
150
  try:
151
- # Use GET with follow_redirects to treat 3xx as success if within range
152
- resp = await client.get(url, follow_redirects=True, timeout=HTTP_TIMEOUT_SECONDS)
153
  code = resp.status_code
154
  ok = 1 if (expected_min <= code <= expected_max) else 0
155
  except Exception:
@@ -163,16 +177,10 @@ async def check_one(client: httpx.AsyncClient, site: dict):
163
  record_check(url, ok, code, ms, now_iso)
164
  open_incident_if_needed(url, now_iso, prev_ok, ok)
165
 
166
- return {
167
- "url": url,
168
- "ok": ok,
169
- "status_code": code,
170
- "ms": ms,
171
- "ts": now_iso
172
- }
173
 
174
  async def run_checks():
175
- sites = list_sites()
176
  if not sites:
177
  return []
178
  limits = httpx.Limits(max_connections=20, max_keepalive_connections=10)
@@ -183,9 +191,8 @@ async def run_checks():
183
  # --------------------------
184
  # API & Scheduler
185
  # --------------------------
186
- app = FastAPI(title="HF Uptime Monitor", version="1.0.0")
187
 
188
- # Simple CORS for same-origin front-end
189
  app.add_middleware(
190
  CORSMiddleware,
191
  allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
@@ -194,14 +201,12 @@ app.add_middleware(
194
  class SiteIn(BaseModel):
195
  url: HttpUrl
196
  name: str
 
197
 
198
  @app.on_event("startup")
199
  async def on_startup():
200
  init_db()
201
- # Kick an immediate check on boot (don't await, run in background)
202
  asyncio.create_task(run_checks())
203
-
204
- # Start APScheduler
205
  scheduler = AsyncIOScheduler()
206
  scheduler.add_job(run_checks, "interval", seconds=CHECK_INTERVAL_SECONDS, next_run_time=datetime.now(timezone.utc))
207
  scheduler.start()
@@ -213,7 +218,8 @@ async def on_shutdown():
213
  if sched:
214
  sched.shutdown(wait=False)
215
 
216
- # Static files
 
217
  app.mount("/static", StaticFiles(directory="static"), name="static")
218
 
219
  @app.get("/")
@@ -222,25 +228,33 @@ def root():
222
 
223
  @app.get("/api/sites")
224
  def api_sites():
225
- return JSONResponse(list_sites())
 
 
226
 
227
  @app.post("/api/sites")
228
  def api_add_site(site: SiteIn):
229
- url_s = str(site.url) # <- cast from HttpUrl to str
230
  name_s = (site.name or "").strip() or url_s
 
 
 
231
  with db_lock:
232
  try:
 
233
  conn.execute(
234
- "INSERT OR IGNORE INTO sites(url, name) VALUES (?, ?)",
235
- (url_s, name_s)
 
 
 
 
236
  )
237
  conn.commit()
238
  except sqlite3.Error as e:
239
- # bubble up the real DB message so the UI can show it
240
  raise HTTPException(400, f"DB error: {e}")
241
  return {"ok": True}
242
 
243
-
244
  @app.delete("/api/sites")
245
  def api_delete_site(url: str):
246
  with db_lock:
@@ -257,7 +271,7 @@ async def api_check_now():
257
 
258
  @app.get("/api/status")
259
  def api_status():
260
- sites = list_sites()
261
  now = datetime.now(timezone.utc)
262
  since_24h = now - timedelta(hours=24)
263
  since_7d = now - timedelta(days=7)
@@ -267,20 +281,18 @@ def api_status():
267
  last = get_last_check(s["url"])
268
  u24 = uptime_ratio(s["url"], since_24h)
269
  u7 = uptime_ratio(s["url"], since_7d)
270
- open_incident = False
271
  with db_lock:
272
  open_inc = conn.execute(
273
  "SELECT 1 FROM incidents WHERE url=? AND end_ts IS NULL LIMIT 1",
274
  (s["url"],)
275
  ).fetchone()
276
  open_incident = bool(open_inc)
277
-
278
  out.append({
279
  "url": s["url"],
280
  "name": s["name"],
281
- "last": last, # {ts, ok, status_code, ms} or None
282
- "uptime24h": u24, # float or None
283
- "uptime7d": u7, # float or None
284
  "open_incident": open_incident
285
  })
286
  return out
@@ -295,4 +307,4 @@ def api_ping():
295
 
296
  if __name__ == "__main__":
297
  import uvicorn
298
- uvicorn.run(app, host="0.0.0.0", port=PORT)
 
3
  from threading import Lock
4
 
5
  import httpx
6
+ from fastapi import FastAPI, HTTPException
7
  from fastapi.responses import FileResponse, JSONResponse
8
  from fastapi.middleware.cors import CORSMiddleware
9
  from starlette.staticfiles import StaticFiles
 
32
  conn.row_factory = sqlite3.Row
33
  db_lock = Lock()
34
 
35
+ def column_exists(table: str, col: str) -> bool:
36
+ rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
37
+ return any(r["name"] == col for r in rows)
38
+
39
  def init_db():
40
  with db_lock:
41
  c = conn.cursor()
 
45
  name TEXT NOT NULL,
46
  method TEXT DEFAULT 'GET',
47
  expected_min INT DEFAULT 200,
48
+ expected_max INT DEFAULT 399
 
49
  );
50
  """)
51
  c.execute("""
 
68
  FOREIGN KEY (url) REFERENCES sites(url)
69
  );
70
  """)
71
+ # --- schema migration: add hf_token if missing ---
72
+ if not column_exists("sites", "hf_token"):
73
+ c.execute("ALTER TABLE sites ADD COLUMN hf_token TEXT")
74
  conn.commit()
75
 
76
  # Seed a few demo sites if empty
 
83
  pass
84
  conn.commit()
85
 
86
+ def list_sites(include_secret: bool = False):
87
+ cols = "url, name, method, expected_min, expected_max"
88
+ if include_secret:
89
+ cols += ", hf_token"
90
  with db_lock:
91
+ rows = conn.execute(f"SELECT {cols} FROM sites ORDER BY url").fetchall()
92
  return [dict(r) for r in rows]
93
 
94
  def get_last_check(url: str):
 
113
 
114
  def open_incident_if_needed(url: str, now_iso: str, prev_ok: int | None, current_ok: int):
115
  with db_lock:
 
116
  if (prev_ok == 1 or prev_ok is None) and current_ok == 0:
117
  conn.execute("INSERT INTO incidents(url, start_ts) VALUES (?,?)", (url, now_iso))
118
  conn.commit()
 
119
  elif prev_ok == 0 and current_ok == 1:
120
  conn.execute(
121
  "UPDATE incidents SET end_ts=? WHERE url=? AND end_ts IS NULL",
 
151
  expected_min = int(site.get("expected_min", 200))
152
  expected_max = int(site.get("expected_max", 399))
153
 
154
+ # Build per-site headers (HF Org Spaces need Authorization)
155
+ headers = {}
156
+ token = (site.get("hf_token") or "").strip()
157
+ if token:
158
+ if token.lower().startswith("bearer "):
159
+ token = token[7:].strip()
160
+ headers["Authorization"] = f"Bearer {token}"
161
+
162
  t0 = time.perf_counter()
163
  code = None
164
  ok = 0
165
  try:
166
+ resp = await client.get(url, headers=headers, follow_redirects=True, timeout=HTTP_TIMEOUT_SECONDS)
 
167
  code = resp.status_code
168
  ok = 1 if (expected_min <= code <= expected_max) else 0
169
  except Exception:
 
177
  record_check(url, ok, code, ms, now_iso)
178
  open_incident_if_needed(url, now_iso, prev_ok, ok)
179
 
180
+ return {"url": url, "ok": ok, "status_code": code, "ms": ms, "ts": now_iso}
 
 
 
 
 
 
181
 
182
  async def run_checks():
183
+ sites = list_sites(include_secret=True)
184
  if not sites:
185
  return []
186
  limits = httpx.Limits(max_connections=20, max_keepalive_connections=10)
 
191
  # --------------------------
192
  # API & Scheduler
193
  # --------------------------
194
+ app = FastAPI(title="HF Uptime Monitor", version="1.1.0") # bumped
195
 
 
196
  app.add_middleware(
197
  CORSMiddleware,
198
  allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
 
201
  class SiteIn(BaseModel):
202
  url: HttpUrl
203
  name: str
204
+ hf_token: str | None = None # optional Bearer token without prefix
205
 
206
  @app.on_event("startup")
207
  async def on_startup():
208
  init_db()
 
209
  asyncio.create_task(run_checks())
 
 
210
  scheduler = AsyncIOScheduler()
211
  scheduler.add_job(run_checks, "interval", seconds=CHECK_INTERVAL_SECONDS, next_run_time=datetime.now(timezone.utc))
212
  scheduler.start()
 
218
  if sched:
219
  sched.shutdown(wait=False)
220
 
221
+ # Static files (if you keep external files). If using the inline HTML above,
222
+ # just place it at ./static/index.html and keep this as-is.
223
  app.mount("/static", StaticFiles(directory="static"), name="static")
224
 
225
  @app.get("/")
 
228
 
229
  @app.get("/api/sites")
230
  def api_sites():
231
+ # Public listing (no secrets)
232
+ public = list_sites(include_secret=False)
233
+ return JSONResponse(public)
234
 
235
  @app.post("/api/sites")
236
  def api_add_site(site: SiteIn):
237
+ url_s = str(site.url)
238
  name_s = (site.name or "").strip() or url_s
239
+ tok = (site.hf_token or "").strip()
240
+ if tok.lower().startswith("bearer "):
241
+ tok = tok[7:].strip()
242
  with db_lock:
243
  try:
244
+ # upsert-ish: try insert; if exists, update token/name
245
  conn.execute(
246
+ "INSERT OR IGNORE INTO sites(url, name, hf_token) VALUES (?, ?, ?)",
247
+ (url_s, name_s, tok or None)
248
+ )
249
+ conn.execute(
250
+ "UPDATE sites SET name=?, hf_token=? WHERE url=?",
251
+ (name_s, tok or None, url_s)
252
  )
253
  conn.commit()
254
  except sqlite3.Error as e:
 
255
  raise HTTPException(400, f"DB error: {e}")
256
  return {"ok": True}
257
 
 
258
  @app.delete("/api/sites")
259
  def api_delete_site(url: str):
260
  with db_lock:
 
271
 
272
  @app.get("/api/status")
273
  def api_status():
274
+ sites = list_sites(include_secret=False) # do NOT leak tokens
275
  now = datetime.now(timezone.utc)
276
  since_24h = now - timedelta(hours=24)
277
  since_7d = now - timedelta(days=7)
 
281
  last = get_last_check(s["url"])
282
  u24 = uptime_ratio(s["url"], since_24h)
283
  u7 = uptime_ratio(s["url"], since_7d)
 
284
  with db_lock:
285
  open_inc = conn.execute(
286
  "SELECT 1 FROM incidents WHERE url=? AND end_ts IS NULL LIMIT 1",
287
  (s["url"],)
288
  ).fetchone()
289
  open_incident = bool(open_inc)
 
290
  out.append({
291
  "url": s["url"],
292
  "name": s["name"],
293
+ "last": last,
294
+ "uptime24h": u24,
295
+ "uptime7d": u7,
296
  "open_incident": open_incident
297
  })
298
  return out
 
307
 
308
  if __name__ == "__main__":
309
  import uvicorn
310
+ uvicorn.run(app, host="0.0.0.0", port=PORT)
static/index.html CHANGED
@@ -4,7 +4,54 @@
4
  <meta charset="utf-8"/>
5
  <meta name="viewport" content="width=device-width,initial-scale=1"/>
6
  <title>Uptime Monitor</title>
7
- <link rel="stylesheet" href="/static/style.css"/>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  </head>
9
  <body>
10
  <header>
@@ -58,10 +105,14 @@
58
  <form method="dialog" id="addForm" class="dialog-card">
59
  <h3>Add Site</h3>
60
  <label>Display Name
61
- <input type="text" id="siteName" placeholder="e.g., My API"/>
62
  </label>
63
  <label>URL
64
- <input type="url" id="siteUrl" placeholder="https://example.com" required/>
 
 
 
 
65
  </label>
66
  <div class="row">
67
  <button type="submit" id="saveSite">Save</button>
@@ -70,6 +121,152 @@
70
  </form>
71
  </dialog>
72
 
73
- <script src="/static/app.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  </body>
75
  </html>
 
4
  <meta charset="utf-8"/>
5
  <meta name="viewport" content="width=device-width,initial-scale=1"/>
6
  <title>Uptime Monitor</title>
7
+ <style>
8
+ :root{
9
+ --bg:#0b1220; --panel:#121a2e; --card:#0f1730; --text:#e6edf7; --muted:#9fb0cf;
10
+ --green:#18c37e; --red:#ff6363; --accent:#3a8dde; --ring: rgba(58,141,222,.35);
11
+ --radius:16px; --shadow: 0 10px 28px rgba(2,8,23,.35);
12
+ }
13
+ *{box-sizing:border-box}
14
+ html,body{height:100%}
15
+ body{margin:0; font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
16
+ color:var(--text); background:linear-gradient(180deg,#0b1220,#0b1220 60%, #0d1530);}
17
+ header{display:flex; align-items:center; justify-content:space-between; padding:16px 22px;
18
+ position:sticky; top:0; background:#0b1220cc; backdrop-filter:saturate(120%) blur(6px); border-bottom:1px solid #1b2542;}
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
+ .actions button{background:var(--accent); color:white; border:none; padding:10px 14px; border-radius:12px; cursor:pointer;
23
+ box-shadow:var(--shadow); margin-left:8px; font-weight:600;}
24
+ .actions button#addSiteBtn{background:#243254; border:1px solid #2d3c63}
25
+ main{max-width:1100px; margin:26px auto; padding:0 16px; display:grid; gap:18px}
26
+ .card{background:var(--panel); border-radius:var(--radius); box-shadow:var(--shadow); border:1px solid #1b2542;}
27
+ .card-head{display:flex; align-items:center; justify-content:space-between; padding:16px 18px; border-bottom:1px solid #1b2542}
28
+ .muted{color:var(--muted); font-size:13px}
29
+ .table-wrap{overflow:auto}
30
+ table{width:100%; border-collapse:collapse}
31
+ th, td{padding:12px 14px; border-bottom:1px solid #1b2542; text-align:left}
32
+ th{color:var(--muted); font-weight:600; background:#0f1730}
33
+ tbody tr:hover{background:#0f1730}
34
+ .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;}
35
+ .dot.green{background:var(--green)} .dot.red{background:var(--red)}
36
+ a.url{color:#a9c6ff; text-decoration:none} a.url:hover{text-decoration:underline}
37
+ .badge{padding:4px 8px; border-radius:999px; font-size:12px; border:1px solid #263257; color:#c9d7ff; background:#102143}
38
+ td .row-actions{display:flex; gap:8px}
39
+ button.ghost{background:transparent; border:1px solid #27355f; color:#c9d7ff; padding:8px 12px; border-radius:12px; cursor:pointer;}
40
+ .hidden{display:none}
41
+ .incidents{padding:10px 16px}
42
+ .incident{display:flex; align-items:center; justify-content:space-between; background:#0f1730; border:1px solid #1b2542;
43
+ border-radius:12px; padding:10px 12px; margin-bottom:10px;}
44
+ .incident .down{color:#ff9f9f} .incident .ok{color:#9fffc7}
45
+ dialog{border:none; border-radius:18px; padding:0; background:#111a31; color:var(--text); box-shadow: var(--shadow);}
46
+ .dialog-card{padding:18px; width:380px}
47
+ .dialog-card h3{margin:0 0 10px}
48
+ .dialog-card label{display:block; margin:10px 0}
49
+ .dialog-card input{width:100%; padding:10px 12px; border-radius:12px; border:1px solid #283663; background:#0f1730; color:var(--text);}
50
+ .dialog-card small{color:var(--muted)}
51
+ .dialog-card .row{display:flex; gap:10px; margin-top:14px}
52
+ .dialog-card button{background:var(--accent); color:white; border:none; padding:10px 14px; border-radius:12px; cursor:pointer; font-weight:600;}
53
+ .dialog-card button.ghost{background:transparent; border:1px solid #27355f; color:#c9d7ff}
54
+ </style>
55
  </head>
56
  <body>
57
  <header>
 
105
  <form method="dialog" id="addForm" class="dialog-card">
106
  <h3>Add Site</h3>
107
  <label>Display Name
108
+ <input type="text" id="siteName" placeholder="e.g., Weather API"/>
109
  </label>
110
  <label>URL
111
+ <input type="url" id="siteUrl" placeholder="https://example.com/api/ping" required/>
112
+ </label>
113
+ <label>Hugging Face token (optional)
114
+ <input type="password" id="hfToken" placeholder="hf_..." autocomplete="off"/>
115
+ <small>Needed for private/Org Spaces. Paste only the token (without “Bearer”).</small>
116
  </label>
117
  <div class="row">
118
  <button type="submit" id="saveSite">Save</button>
 
121
  </form>
122
  </dialog>
123
 
124
+ <script>
125
+ const tbody = document.getElementById("statusTbody");
126
+ const lastRefresh = document.getElementById("lastRefresh");
127
+ const checkNowBtn = document.getElementById("checkNowBtn");
128
+ const addSiteBtn = document.getElementById("addSiteBtn");
129
+ const addDialog = document.getElementById("addDialog");
130
+ const addForm = document.getElementById("addForm");
131
+ const cancelAdd = document.getElementById("cancelAdd");
132
+ const siteName = document.getElementById("siteName");
133
+ const siteUrl = document.getElementById("siteUrl");
134
+ const hfToken = document.getElementById("hfToken");
135
+
136
+ const incidentPane = document.getElementById("incidentPane");
137
+ const incidentTitle = document.getElementById("incidentTitle");
138
+ const incidentsList = document.getElementById("incidentsList");
139
+ const closeIncidents = document.getElementById("closeIncidents");
140
+
141
+ function fmtTs(s){
142
+ if(!s) return "—";
143
+ const d = new Date(s);
144
+ return d.toLocaleString();
145
+ }
146
+ function fmtPct(v){
147
+ if(v === null || v === undefined) return "—";
148
+ return `${v.toFixed ? v.toFixed(2) : v}%`;
149
+ }
150
+ function dot(ok){
151
+ return `<span class="dot ${ok ? 'green':'red'}" title="${ok?'UP':'DOWN'}"></span>`;
152
+ }
153
+
154
+ async function fetchStatus(){
155
+ const res = await fetch("/api/status");
156
+ const data = await res.json();
157
+ tbody.innerHTML = "";
158
+ data.forEach(item => {
159
+ const last = item.last || {};
160
+ const tr = document.createElement("tr");
161
+ tr.innerHTML = `
162
+ <td>${dot(last.ok)}</td>
163
+ <td>${item.name}</td>
164
+ <td><a class="url" href="${item.url}" target="_blank" rel="noopener">${item.url}</a></td>
165
+ <td>${fmtTs(last.ts)}</td>
166
+ <td>${last.ms ?? "—"}</td>
167
+ <td>${last.status_code ?? "—"}</td>
168
+ <td><span class="badge">${fmtPct(item.uptime24h)}</span></td>
169
+ <td><span class="badge">${fmtPct(item.uptime7d)}</span></td>
170
+ <td class="row-actions">
171
+ <button class="ghost" data-action="incidents" data-url="${item.url}" data-name="${item.name}">Incidents</button>
172
+ <button class="ghost" data-action="delete" data-url="${item.url}">Delete</button>
173
+ </td>
174
+ `;
175
+ tbody.appendChild(tr);
176
+ });
177
+ lastRefresh.textContent = `Last refresh: ${new Date().toLocaleTimeString()}`;
178
+ }
179
+
180
+ async function checkNow(){
181
+ checkNowBtn.disabled = true;
182
+ try{
183
+ await fetch("/api/check-now", {method:"POST"});
184
+ await fetchStatus();
185
+ } finally {
186
+ checkNowBtn.disabled = false;
187
+ }
188
+ }
189
+
190
+ function openAdd(){
191
+ siteName.value = "";
192
+ siteUrl.value = "";
193
+ hfToken.value = "";
194
+ addDialog.showModal();
195
+ }
196
+ function closeAdd(){ addDialog.close(); }
197
+
198
+ addForm.addEventListener("submit", async (e) => {
199
+ e.preventDefault();
200
+ const body = { name: siteName.value || siteUrl.value, url: siteUrl.value };
201
+ const tok = (hfToken.value || "").trim();
202
+ if (tok) {
203
+ body.hf_token = tok.startsWith("Bearer ") ? tok.slice(7).trim() : tok;
204
+ }
205
+ const res = await fetch("/api/sites", {
206
+ method:"POST",
207
+ headers: { "Content-Type":"application/json" },
208
+ body: JSON.stringify(body)
209
+ });
210
+ if (res.ok){
211
+ closeAdd();
212
+ await fetchStatus();
213
+ } else {
214
+ const msg = await res.text();
215
+ alert("Failed to add site:\n" + msg);
216
+ }
217
+ });
218
+
219
+ cancelAdd.addEventListener("click", (e)=>{ e.preventDefault(); closeAdd(); });
220
+
221
+ tbody.addEventListener("click", async (e) => {
222
+ const btn = e.target.closest("button");
223
+ if(!btn) return;
224
+ const action = btn.dataset.action;
225
+ const url = btn.dataset.url;
226
+ if(action === "delete"){
227
+ if(confirm(`Delete monitor for:\n${url}?`)){
228
+ await fetch(`/api/sites?url=${encodeURIComponent(url)}`, { method: "DELETE" });
229
+ await fetchStatus();
230
+ }
231
+ }
232
+ if(action === "incidents"){
233
+ await loadIncidents(url, btn.dataset.name || url);
234
+ }
235
+ });
236
+
237
+ async function loadIncidents(url, name){
238
+ const res = await fetch(`/api/incidents?url=${encodeURIComponent(url)}`);
239
+ const data = await res.json();
240
+ incidentPane.classList.remove("hidden");
241
+ incidentTitle.textContent = `Incidents — ${name}`;
242
+ if(!data.length){
243
+ incidentsList.innerHTML = `<div class="muted" style="padding:8px 2px">No incidents recorded.</div>`;
244
+ return;
245
+ }
246
+ incidentsList.innerHTML = "";
247
+ data.forEach(x => {
248
+ const end = x.end_ts ? new Date(x.end_ts) : null;
249
+ const start = new Date(x.start_ts);
250
+ const durationMin = end ? Math.max(0, Math.round((end - start)/60000)) : null;
251
+ const div = document.createElement("div");
252
+ div.className = "incident";
253
+ div.innerHTML = `
254
+ <div>
255
+ <div><strong class="down">DOWN</strong> ${start.toLocaleString()}</div>
256
+ ${end ? `<div><strong class="ok">UP</strong> ${end.toLocaleString()}</div>` : `<div class="muted">ongoing...</div>`}
257
+ </div>
258
+ <div class="muted">${durationMin !== null ? durationMin + " min" : ""}</div>
259
+ `;
260
+ incidentsList.appendChild(div);
261
+ });
262
+ }
263
+ closeIncidents.addEventListener("click", ()=> incidentPane.classList.add("hidden"));
264
+
265
+ document.getElementById("checkNowBtn").addEventListener("click", checkNow);
266
+ document.getElementById("addSiteBtn").addEventListener("click", openAdd);
267
+
268
+ fetchStatus();
269
+ setInterval(fetchStatus, 30000);
270
+ </script>
271
  </body>
272
  </html>