triflix commited on
Commit
ff2fd7f
·
verified ·
1 Parent(s): a1e95eb

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +269 -0
app.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ import os
3
+ import sqlite3
4
+ import threading
5
+ import time
6
+ import subprocess
7
+ import signal
8
+ from typing import List, Dict, Any
9
+ from fastapi import FastAPI, Request, Form, HTTPException, Depends, Response
10
+ from fastapi.templating import Jinja2Templates
11
+ from fastapi.staticfiles import StaticFiles
12
+ from huggingface_hub import HfApi, hf_hub_download
13
+ import pathlib
14
+
15
+ # Config from env
16
+ HF_TOKEN = os.environ.get("HF_TOKEN")
17
+ HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO") # e.g. "username/sqlite-backup"
18
+ ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "change-me")
19
+ DB_FILE = "/tmp/app.db"
20
+ BACKUP_INTERVAL_SECONDS = int(os.environ.get("BACKUP_INTERVAL_SECONDS", 3 * 3600)) # default 3 hours
21
+ PING_INTERVAL_SECONDS = int(os.environ.get("PING_INTERVAL_SECONDS", 15 * 60)) # 15 minutes default
22
+
23
+ templates = Jinja2Templates(directory="templates")
24
+ app = FastAPI(docs_url=None, redoc_url=None) # disable public docs by default
25
+
26
+ # Ensure static dir exists if used
27
+ if os.path.isdir("static"):
28
+ app.mount("/static", StaticFiles(directory="static"), name="static")
29
+
30
+ # Database helpers
31
+ def get_db_conn():
32
+ conn = sqlite3.connect(DB_FILE, check_same_thread=False)
33
+ conn.row_factory = sqlite3.Row
34
+ return conn
35
+
36
+ def init_db():
37
+ conn = get_db_conn()
38
+ cur = conn.cursor()
39
+ cur.execute("""
40
+ CREATE TABLE IF NOT EXISTS sites (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ url TEXT NOT NULL UNIQUE,
43
+ label TEXT,
44
+ active INTEGER NOT NULL DEFAULT 1,
45
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
46
+ )""")
47
+ cur.execute("""
48
+ CREATE TABLE IF NOT EXISTS pings (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ site_id INTEGER NOT NULL,
51
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
52
+ status_code INTEGER,
53
+ latency_ms INTEGER,
54
+ error TEXT,
55
+ FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE
56
+ )""")
57
+ conn.commit()
58
+ conn.close()
59
+
60
+ # Restore DB from Hugging Face dataset repo if exists
61
+ def restore_db_from_hub():
62
+ if not HF_TOKEN or not HF_DATASET_REPO:
63
+ print("HF_TOKEN or HF_DATASET_REPO not set; skipping restore.")
64
+ return
65
+ print("Attempting to restore DB from Hugging Face Hub:", HF_DATASET_REPO)
66
+ try:
67
+ path = hf_hub_download(repo_id=HF_DATASET_REPO, filename="app.db", repo_type="dataset", token=HF_TOKEN)
68
+ # copy into /tmp/app.db (hf_hub_download returns a path in the local cache)
69
+ import shutil
70
+ shutil.copy(path, DB_FILE)
71
+ print("Restored DB from hub to", DB_FILE)
72
+ except Exception as e:
73
+ print("No remote DB found or download failed:", e)
74
+
75
+ # Backup uploader
76
+ def upload_db_to_hub():
77
+ if not HF_TOKEN or not HF_DATASET_REPO:
78
+ print("HF_TOKEN or HF_DATASET_REPO not set; skipping upload.")
79
+ return
80
+ print("Uploading DB to Hugging Face Hub:", HF_DATASET_REPO)
81
+ api = HfApi(token=HF_TOKEN)
82
+ try:
83
+ api.upload_file(
84
+ path_or_fileobj=DB_FILE,
85
+ path_in_repo="app.db",
86
+ repo_id=HF_DATASET_REPO,
87
+ repo_type="dataset",
88
+ token=HF_TOKEN,
89
+ repo_type_arg="dataset"
90
+ )
91
+ print("Upload successful")
92
+ except Exception as e:
93
+ print("Failed to upload DB:", e)
94
+
95
+ def backup_loop():
96
+ # Periodic backing up thread
97
+ while True:
98
+ try:
99
+ if os.path.exists(DB_FILE):
100
+ upload_db_to_hub()
101
+ except Exception as e:
102
+ print("Backup loop error:", e)
103
+ time.sleep(BACKUP_INTERVAL_SECONDS)
104
+
105
+ # Supervisor that spawns worker subprocess per site
106
+ class Supervisor:
107
+ def __init__(self):
108
+ self.procs = {} # site_id -> subprocess.Popen
109
+ self.lock = threading.Lock()
110
+ self.monitor_thread = threading.Thread(target=self.monitor_loop, daemon=True)
111
+ self.running = True
112
+ self.monitor_thread.start()
113
+
114
+ def spawn_worker(self, site_id: int):
115
+ with self.lock:
116
+ if site_id in self.procs and self.procs[site_id].poll() is None:
117
+ # already running
118
+ return
119
+ cmd = ["python", "worker.py", str(site_id), DB_FILE, str(PING_INTERVAL_SECONDS)]
120
+ print("Spawning worker:", cmd)
121
+ proc = subprocess.Popen(cmd)
122
+ self.procs[site_id] = proc
123
+
124
+ def stop_worker(self, site_id: int):
125
+ with self.lock:
126
+ p = self.procs.get(site_id)
127
+ if p and p.poll() is None:
128
+ p.terminate()
129
+ try:
130
+ p.wait(timeout=5)
131
+ except Exception:
132
+ p.kill()
133
+ self.procs.pop(site_id, None)
134
+
135
+ def monitor_loop(self):
136
+ while self.running:
137
+ try:
138
+ conn = get_db_conn()
139
+ cur = conn.cursor()
140
+ cur.execute("SELECT id FROM sites WHERE active = 1")
141
+ rows = cur.fetchall()
142
+ active_ids = {r["id"] for r in rows}
143
+ # spawn missing workers
144
+ for sid in active_ids:
145
+ if sid not in self.procs or self.procs[sid].poll() is not None:
146
+ self.spawn_worker(sid)
147
+ # stop workers for removed sites
148
+ for sid in list(self.procs.keys()):
149
+ if sid not in active_ids:
150
+ self.stop_worker(sid)
151
+ conn.close()
152
+ except Exception as e:
153
+ print("Supervisor monitor loop error:", e)
154
+ time.sleep(10)
155
+ def shutdown(self):
156
+ self.running = False
157
+ with self.lock:
158
+ for sid, p in list(self.procs.items()):
159
+ try:
160
+ p.terminate()
161
+ except Exception:
162
+ pass
163
+
164
+ supervisor = None
165
+
166
+ @app.on_event("startup")
167
+ def startup_event():
168
+ # restore DB from hub if possible
169
+ restore_db_from_hub()
170
+ # ensure DB tables exist
171
+ init_db()
172
+ # start backup thread
173
+ t = threading.Thread(target=backup_loop, daemon=True)
174
+ t.start()
175
+ # start supervisor
176
+ global supervisor
177
+ supervisor = Supervisor()
178
+ print("Startup complete.")
179
+
180
+ @app.on_event("shutdown")
181
+ def shutdown_event():
182
+ global supervisor
183
+ if supervisor:
184
+ supervisor.shutdown()
185
+ # do a final backup attempt
186
+ try:
187
+ if os.path.exists(DB_FILE):
188
+ upload_db_to_hub()
189
+ except Exception as e:
190
+ print("Final backup failed:", e)
191
+ print("Shutdown complete.")
192
+
193
+ # --- API & UI routes ---
194
+
195
+ @app.get("/", include_in_schema=False)
196
+ def index(request: Request):
197
+ conn = get_db_conn()
198
+ cur = conn.cursor()
199
+ cur.execute("SELECT * FROM sites ORDER BY created_at DESC")
200
+ sites = cur.fetchall()
201
+ # latest ping for each site
202
+ data = []
203
+ for s in sites:
204
+ cur.execute("SELECT * FROM pings WHERE site_id = ? ORDER BY timestamp DESC LIMIT 1", (s["id"],))
205
+ last = cur.fetchone()
206
+ data.append({"site": s, "last": last})
207
+ conn.close()
208
+ return templates.TemplateResponse("index.html", {"request": request, "sites": data})
209
+
210
+ @app.post("/add", include_in_schema=False)
211
+ def add_site(url: str = Form(...), label: str = Form(None)):
212
+ conn = get_db_conn()
213
+ cur = conn.cursor()
214
+ try:
215
+ cur.execute("INSERT OR IGNORE INTO sites (url, label) VALUES (?, ?)", (url.strip(), label))
216
+ conn.commit()
217
+ # spawn immediately
218
+ cur.execute("SELECT id FROM sites WHERE url = ?", (url.strip(),))
219
+ row = cur.fetchone()
220
+ if row:
221
+ sid = row["id"]
222
+ supervisor.spawn_worker(sid)
223
+ except Exception as e:
224
+ conn.close()
225
+ raise HTTPException(status_code=400, detail=str(e))
226
+ conn.close()
227
+ return Response(status_code=303, headers={"Location": "/"})
228
+
229
+ @app.post("/remove", include_in_schema=False)
230
+ def remove_site(site_id: int = Form(...)):
231
+ conn = get_db_conn()
232
+ cur = conn.cursor()
233
+ cur.execute("UPDATE sites SET active = 0 WHERE id = ?", (site_id,))
234
+ conn.commit()
235
+ conn.close()
236
+ # supervisor will stop it
237
+ return Response(status_code=303, headers={"Location": "/"})
238
+
239
+ # Admin endpoint to download DB (token protected)
240
+ @app.get("/admin/download_db", include_in_schema=False)
241
+ def admin_download_db(token: str):
242
+ if token != ADMIN_TOKEN:
243
+ raise HTTPException(status_code=401, detail="Unauthorized")
244
+ if not os.path.exists(DB_FILE):
245
+ raise HTTPException(status_code=404, detail="DB not found")
246
+ # stream DB file
247
+ def iterfile():
248
+ with open(DB_FILE, "rb") as f:
249
+ while True:
250
+ chunk = f.read(4096)
251
+ if not chunk:
252
+ break
253
+ yield chunk
254
+ return Response(iterfile(), media_type="application/octet-stream")
255
+
256
+ # Small API for status (optional)
257
+ @app.get("/api/sites")
258
+ def api_sites():
259
+ conn = get_db_conn()
260
+ cur = conn.cursor()
261
+ cur.execute("SELECT * FROM sites WHERE active = 1")
262
+ sites = [dict(r) for r in cur.fetchall()]
263
+ for s in sites:
264
+ cur.execute("SELECT * FROM pings WHERE site_id = ? ORDER BY timestamp DESC LIMIT 1", (s["id"],))
265
+ last = cur.fetchone()
266
+ s["last_ping"] = dict(last) if last else None
267
+ conn.close()
268
+ return {"sites": sites}
269
+