ntdservices commited on
Commit
50e0b07
·
verified ·
1 Parent(s): 23a8c68

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +290 -0
app.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, json, sqlite3, asyncio, time
2
+ 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
10
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
11
+ from pydantic import BaseModel, HttpUrl
12
+
13
+ # --------------------------
14
+ # Config & Paths
15
+ # --------------------------
16
+ PORT = int(os.getenv("PORT", "7860"))
17
+ DATA_DIR = "/data" if os.path.isdir("/data") else "."
18
+ DB_PATH = os.path.join(DATA_DIR, "uptime.db")
19
+ DEFAULT_SITES = [
20
+ {"url": "https://huggingface.co", "name": "Hugging Face"},
21
+ {"url": "https://www.google.com", "name": "Google"},
22
+ {"url": "https://www.bbc.com", "name": "BBC"},
23
+ ]
24
+ CHECK_INTERVAL_SECONDS = 300 # 5 minutes
25
+ HTTP_TIMEOUT_SECONDS = 10
26
+
27
+ # --------------------------
28
+ # DB setup
29
+ # --------------------------
30
+ os.makedirs(DATA_DIR, exist_ok=True)
31
+ 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()
38
+ c.execute("""
39
+ CREATE TABLE IF NOT EXISTS sites (
40
+ url TEXT PRIMARY KEY,
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("""
49
+ CREATE TABLE IF NOT EXISTS checks (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ ts TEXT NOT NULL,
52
+ url TEXT NOT NULL,
53
+ ok INT NOT NULL,
54
+ status_code INT,
55
+ ms REAL,
56
+ FOREIGN KEY (url) REFERENCES sites(url)
57
+ );
58
+ """)
59
+ c.execute("""
60
+ CREATE TABLE IF NOT EXISTS incidents (
61
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
62
+ url TEXT NOT NULL,
63
+ start_ts TEXT NOT NULL,
64
+ end_ts TEXT,
65
+ FOREIGN KEY (url) REFERENCES sites(url)
66
+ );
67
+ """)
68
+ conn.commit()
69
+
70
+ # Seed a few demo sites if empty
71
+ existing = c.execute("SELECT COUNT(*) AS n FROM sites").fetchone()["n"]
72
+ if existing == 0:
73
+ for s in DEFAULT_SITES:
74
+ try:
75
+ c.execute("INSERT OR IGNORE INTO sites(url, name) VALUES (?, ?)", (s["url"], s["name"]))
76
+ except sqlite3.Error:
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):
86
+ with db_lock:
87
+ r = conn.execute(
88
+ "SELECT ts, ok, status_code, ms FROM checks WHERE url=? ORDER BY id DESC LIMIT 1", (url,)
89
+ ).fetchone()
90
+ return dict(r) if r else None
91
+
92
+ def get_prev_check(url: str):
93
+ with db_lock:
94
+ r = conn.execute(
95
+ "SELECT ts, ok, status_code, ms FROM checks WHERE url=? ORDER BY id DESC LIMIT 1 OFFSET 1", (url,)
96
+ ).fetchone()
97
+ return dict(r) if r else None
98
+
99
+ def record_check(url: str, ok: int, status_code: int | None, ms: float | None, ts_iso: str):
100
+ with db_lock:
101
+ conn.execute("INSERT INTO checks(ts, url, ok, status_code, ms) VALUES (?,?,?,?,?)",
102
+ (ts_iso, url, ok, status_code, ms))
103
+ conn.commit()
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",
115
+ (now_iso, url)
116
+ )
117
+ conn.commit()
118
+
119
+ def incidents_for(url: str, limit: int = 50):
120
+ with db_lock:
121
+ rows = conn.execute(
122
+ "SELECT id, url, start_ts, end_ts FROM incidents WHERE url=? ORDER BY id DESC LIMIT ?",
123
+ (url, limit)
124
+ ).fetchall()
125
+ return [dict(r) for r in rows]
126
+
127
+ def uptime_ratio(url: str, since: datetime):
128
+ with db_lock:
129
+ rows = conn.execute(
130
+ "SELECT ok FROM checks WHERE url=? AND ts >= ?",
131
+ (url, since.replace(tzinfo=timezone.utc).isoformat())
132
+ ).fetchall()
133
+ if not rows:
134
+ return None
135
+ total = len(rows)
136
+ ups = sum(1 for r in rows if r["ok"] == 1)
137
+ return round(100.0 * ups / total, 2)
138
+
139
+ # --------------------------
140
+ # HTTP checker
141
+ # --------------------------
142
+ async def check_one(client: httpx.AsyncClient, site: dict):
143
+ url = site["url"]
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:
156
+ ok = 0
157
+ ms = round((time.perf_counter() - t0) * 1000.0, 2)
158
+ now_iso = datetime.now(timezone.utc).isoformat()
159
+
160
+ prev = get_last_check(url)
161
+ prev_ok = prev["ok"] if prev else None
162
+
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)
179
+ async with httpx.AsyncClient(limits=limits) as client:
180
+ tasks = [check_one(client, s) for s in sites]
181
+ return await asyncio.gather(*tasks)
182
+
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=["*"]
192
+ )
193
+
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()
208
+ app.state.scheduler = scheduler
209
+
210
+ @app.on_event("shutdown")
211
+ async def on_shutdown():
212
+ sched = getattr(app.state, "scheduler", None)
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("/")
220
+ def root():
221
+ return FileResponse("static/index.html")
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
+ with db_lock:
230
+ try:
231
+ conn.execute(
232
+ "INSERT OR IGNORE INTO sites(url, name) VALUES (?, ?)",
233
+ (site.url, site.name.strip() or site.url)
234
+ )
235
+ conn.commit()
236
+ except sqlite3.Error as e:
237
+ raise HTTPException(400, f"DB error: {e}")
238
+ return {"ok": True}
239
+
240
+ @app.delete("/api/sites")
241
+ def api_delete_site(url: str):
242
+ with db_lock:
243
+ conn.execute("DELETE FROM sites WHERE url=?", (url,))
244
+ conn.execute("DELETE FROM checks WHERE url=?", (url,))
245
+ conn.execute("DELETE FROM incidents WHERE url=?", (url,))
246
+ conn.commit()
247
+ return {"ok": True}
248
+
249
+ @app.post("/api/check-now")
250
+ async def api_check_now():
251
+ results = await run_checks()
252
+ return results
253
+
254
+ @app.get("/api/status")
255
+ def api_status():
256
+ sites = list_sites()
257
+ now = datetime.now(timezone.utc)
258
+ since_24h = now - timedelta(hours=24)
259
+ since_7d = now - timedelta(days=7)
260
+
261
+ out = []
262
+ for s in sites:
263
+ last = get_last_check(s["url"])
264
+ u24 = uptime_ratio(s["url"], since_24h)
265
+ u7 = uptime_ratio(s["url"], since_7d)
266
+ open_incident = False
267
+ with db_lock:
268
+ open_inc = conn.execute(
269
+ "SELECT 1 FROM incidents WHERE url=? AND end_ts IS NULL LIMIT 1",
270
+ (s["url"],)
271
+ ).fetchone()
272
+ open_incident = bool(open_inc)
273
+
274
+ out.append({
275
+ "url": s["url"],
276
+ "name": s["name"],
277
+ "last": last, # {ts, ok, status_code, ms} or None
278
+ "uptime24h": u24, # float or None
279
+ "uptime7d": u7, # float or None
280
+ "open_incident": open_incident
281
+ })
282
+ return out
283
+
284
+ @app.get("/api/incidents")
285
+ def api_incidents(url: str):
286
+ return incidents_for(url, limit=100)
287
+
288
+ if __name__ == "__main__":
289
+ import uvicorn
290
+ uvicorn.run(app, host="0.0.0.0", port=PORT)