Self-hostable uptime monitor and status page on Django: HTTP checks, Lighthouse audits, SEO crawls, and email and Discord alerts.
djangodockerhandcodedpythonself-hostedsqlitestatus-pageuptime-monitoringvite
1// Polls the crawl/lighthouse status endpoint and updates the monitoring
2// panel in-place. When a crawl or lighthouse run is active, polling is
3// fast (2s); when idle, it's slow (30s) so older tabs don't hammer the
4// server.
5
6const FAST_POLL_MS = 2000;
7const SLOW_POLL_MS = 30000;
8
9function $(root, selector) {
10 return root.querySelector(selector);
11}
12
13function setText(root, field, value) {
14 const el = root.querySelector(`[data-field="${field}"]`);
15 if (el) el.textContent = value;
16}
17
18function show(root, field, visible) {
19 const el = root.querySelector(`[data-field="${field}"]`);
20 if (!el) return;
21 el.classList.toggle("d-none", !visible);
22}
23
24function humanDuration(ms) {
25 if (ms == null) return "—";
26 if (ms < 1000) return `${ms} ms`;
27 const s = ms / 1000;
28 if (s < 60) return `${s.toFixed(1)} s`;
29 const m = Math.floor(s / 60);
30 const rs = Math.round(s - m * 60);
31 return `${m}m ${rs}s`;
32}
33
34function relativeTime(iso, now) {
35 if (!iso) return null;
36 const then = new Date(iso).getTime();
37 const diff = now - then;
38 const future = diff < 0;
39 const abs = Math.abs(diff);
40 const s = Math.round(abs / 1000);
41 let text;
42 if (s < 45) text = `${s}s`;
43 else if (s < 3600) text = `${Math.round(s / 60)}m`;
44 else if (s < 86400) text = `${Math.round(s / 3600)}h`;
45 else text = `${Math.round(s / 86400)}d`;
46 return future ? `in ${text}` : `${text} ago`;
47}
48
49function formatAbsolute(iso) {
50 if (!iso) return "";
51 const d = new Date(iso);
52 return d.toLocaleString();
53}
54
55function stateBadge(state) {
56 switch (state) {
57 case "running":
58 return { label: "Running", cls: "bg-primary" };
59 case "queued":
60 return { label: "Queued", cls: "bg-info text-dark" };
61 default:
62 return { label: "Idle", cls: "bg-secondary" };
63 }
64}
65
66function renderWhen(root, field, iso, now) {
67 const el = root.querySelector(`[data-field="${field}"]`);
68 if (!el) return;
69 if (!iso) {
70 el.textContent = "—";
71 el.removeAttribute("title");
72 return;
73 }
74 const rel = relativeTime(iso, now);
75 el.textContent = rel;
76 el.title = formatAbsolute(iso);
77}
78
79function renderCrawler(root, crawler, serverNow) {
80 const badge = root.querySelector('[data-field="crawler.state_badge"]');
81 const s = stateBadge(crawler.state);
82 badge.className = `badge ${s.cls}`;
83 badge.textContent = s.label;
84
85 show(root, "crawler.progress_wrap", crawler.state === "running");
86 if (crawler.state === "running") {
87 const bar = root.querySelector('[data-field="crawler.progress_bar"]');
88 const pct = Math.round((crawler.progress || 0) * 100);
89 bar.style.width = `${pct}%`;
90 bar.setAttribute("aria-valuenow", pct);
91 }
92
93 show(root, "crawler.error_box", !!crawler.last_error);
94 if (crawler.last_error) {
95 setText(root, "crawler.error_text", crawler.last_error);
96 }
97
98 renderWhen(root, "crawler.last_success", crawler.last_success_at, serverNow);
99 renderWhen(root, "crawler.last_attempt", crawler.last_attempt_at, serverNow);
100
101 const pagesEl = root.querySelector('[data-field="crawler.pages"]');
102 if (crawler.state === "running") {
103 pagesEl.textContent = `${crawler.pages_count || 0} so far…`;
104 } else if (crawler.pages_count != null) {
105 pagesEl.textContent = `${crawler.pages_count}`;
106 } else {
107 pagesEl.textContent = "—";
108 }
109
110 setText(root, "crawler.duration", humanDuration(crawler.last_duration_ms));
111
112 const ins = crawler.insights_by_severity || { error: 0, warning: 0, info: 0 };
113 const insEl = root.querySelector('[data-field="crawler.insights"]');
114 insEl.innerHTML = `
115 <span class="badge bg-danger me-1">${ins.error} err</span>
116 <span class="badge bg-warning text-dark me-1">${ins.warning} warn</span>
117 <span class="badge bg-info text-dark">${ins.info} info</span>
118 `;
119
120 const nextEl = root.querySelector('[data-field="crawler.next_run"]');
121 if (!crawler.next_run_at) {
122 nextEl.textContent = "—";
123 nextEl.removeAttribute("title");
124 } else if (crawler.state === "running" || crawler.state === "queued") {
125 nextEl.textContent = "— (running now)";
126 nextEl.title = formatAbsolute(crawler.next_run_at);
127 } else if (crawler.is_overdue) {
128 nextEl.innerHTML = `<span class="text-warning">due now</span>`;
129 nextEl.title = formatAbsolute(crawler.next_run_at);
130 } else {
131 nextEl.textContent = relativeTime(crawler.next_run_at, serverNow);
132 nextEl.title = formatAbsolute(crawler.next_run_at);
133 }
134}
135
136function renderLighthouse(root, lh, serverNow) {
137 const badge = root.querySelector('[data-field="lighthouse.state_badge"]');
138 const s = stateBadge(lh.state);
139 badge.className = `badge ${s.cls}`;
140 badge.textContent = s.label;
141
142 show(root, "lighthouse.error_box", !!lh.last_error);
143 if (lh.last_error) {
144 setText(root, "lighthouse.error_text", lh.last_error);
145 }
146
147 renderWhen(root, "lighthouse.last_success", lh.last_success_at, serverNow);
148 renderWhen(root, "lighthouse.last_attempt", lh.last_attempt_at, serverNow);
149 setText(root, "lighthouse.duration", humanDuration(lh.last_duration_ms));
150
151 const nextEl = root.querySelector('[data-field="lighthouse.next_run"]');
152 if (!lh.next_run_at) {
153 nextEl.textContent = "—";
154 nextEl.removeAttribute("title");
155 } else if (lh.state === "running" || lh.state === "queued") {
156 nextEl.textContent = "— (running now)";
157 nextEl.title = formatAbsolute(lh.next_run_at);
158 } else if (lh.is_overdue) {
159 nextEl.innerHTML = `<span class="text-warning">due now</span>`;
160 nextEl.title = formatAbsolute(lh.next_run_at);
161 } else {
162 nextEl.textContent = relativeTime(lh.next_run_at, serverNow);
163 nextEl.title = formatAbsolute(lh.next_run_at);
164 }
165}
166
167function updateRecrawlButton(data) {
168 const btn = document.getElementById("recrawl-btn");
169 if (!btn) return;
170 const state = data.crawler.state;
171 // "overdue + idle" means the user already requested a recrawl but the
172 // scheduler hasn't picked it up yet (up to ~30s).
173 const waitingForScheduler = state === "idle" && data.crawler.is_overdue;
174 const busy =
175 state === "queued" || state === "running" || waitingForScheduler;
176 btn.disabled = busy;
177 const label = btn.querySelector(".recrawl-btn-label");
178 const spinner = btn.querySelector(".recrawl-btn-spinner");
179 if (busy) {
180 spinner.classList.remove("d-none");
181 if (state === "running") {
182 const n = data.crawler.pages_count || 0;
183 label.textContent = n > 0 ? `Crawling (${n})` : "Crawling…";
184 } else if (state === "queued") {
185 label.textContent = "Queued…";
186 } else {
187 label.textContent = "Waiting for scheduler…";
188 }
189 } else {
190 spinner.classList.add("d-none");
191 label.textContent = "Recrawl";
192 }
193}
194
195function updateRerunLighthouseButton(data) {
196 const btn = document.getElementById("rerun-lighthouse-btn");
197 if (!btn) return;
198 const state = data.lighthouse.state;
199 const waitingForScheduler = state === "idle" && data.lighthouse.is_overdue;
200 const busy =
201 state === "queued" || state === "running" || waitingForScheduler;
202 btn.disabled = busy;
203 const label = btn.querySelector(".rerun-lh-label");
204 const spinner = btn.querySelector(".rerun-lh-spinner");
205 if (busy) {
206 spinner.classList.remove("d-none");
207 if (state === "running") label.textContent = "Running";
208 else if (state === "queued") label.textContent = "Queued";
209 else label.textContent = "Waiting…";
210 } else {
211 spinner.classList.add("d-none");
212 label.textContent = "Rerun";
213 }
214}
215
216function getCsrfToken() {
217 const input = document.querySelector("input[name=csrfmiddlewaretoken]");
218 return input ? input.value : "";
219}
220
221async function triggerPost(url, onDone) {
222 try {
223 const res = await fetch(url, {
224 method: "POST",
225 headers: {
226 "X-CSRFToken": getCsrfToken(),
227 "Accept": "application/json",
228 },
229 credentials: "same-origin",
230 });
231 if (!res.ok) {
232 console.error("POST failed", url, res.status);
233 return;
234 }
235 const data = await res.json();
236 if (onDone) onDone(data);
237 } catch (err) {
238 console.error("POST error", url, err);
239 }
240}
241
242document.addEventListener("DOMContentLoaded", function () {
243 const root = document.getElementById("monitoring-status");
244 if (!root) return;
245
246 const statusUrl = root.dataset.statusUrl;
247 const recrawlUrl = root.dataset.recrawlUrl;
248 const rerunLighthouseUrl = root.dataset.rerunLighthouseUrl;
249
250 let prevCrawlState = null;
251 let prevLhState = null;
252 let timer = null;
253
254 function schedule(data) {
255 const active =
256 data.crawler.state !== "idle" ||
257 data.lighthouse.state !== "idle" ||
258 data.crawler.is_overdue ||
259 data.lighthouse.is_overdue;
260 const delay = active ? FAST_POLL_MS : SLOW_POLL_MS;
261 clearTimeout(timer);
262 timer = setTimeout(poll, delay);
263 }
264
265 function applyData(data) {
266 const serverNow = data.server_time ? new Date(data.server_time).getTime() : Date.now();
267 renderCrawler(root, data.crawler, serverNow);
268 renderLighthouse(root, data.lighthouse, serverNow);
269 updateRecrawlButton(data);
270 updateRerunLighthouseButton(data);
271
272 // If either subsystem just went idle after being active, refresh the
273 // page once so server-rendered charts/insights update.
274 const crawlerFinished =
275 prevCrawlState && prevCrawlState !== "idle" && data.crawler.state === "idle";
276 const lhFinished =
277 prevLhState && prevLhState !== "idle" && data.lighthouse.state === "idle";
278 prevCrawlState = data.crawler.state;
279 prevLhState = data.lighthouse.state;
280 if (crawlerFinished || lhFinished) {
281 window.location.reload();
282 return;
283 }
284 schedule(data);
285 }
286
287 async function poll() {
288 try {
289 const res = await fetch(statusUrl, {
290 credentials: "same-origin",
291 headers: { Accept: "application/json" },
292 });
293 if (!res.ok) {
294 timer = setTimeout(poll, SLOW_POLL_MS);
295 return;
296 }
297 const data = await res.json();
298 applyData(data);
299 } catch (err) {
300 console.error("status poll failed", err);
301 timer = setTimeout(poll, SLOW_POLL_MS);
302 }
303 }
304
305 const recrawlBtn = document.getElementById("recrawl-btn");
306 if (recrawlBtn && recrawlUrl) {
307 recrawlBtn.addEventListener("click", function () {
308 recrawlBtn.disabled = true;
309 triggerPost(recrawlUrl, function (data) {
310 applyData(data);
311 });
312 });
313 }
314
315 const rerunLhBtn = document.getElementById("rerun-lighthouse-btn");
316 if (rerunLhBtn && rerunLighthouseUrl) {
317 rerunLhBtn.addEventListener("click", function () {
318 rerunLhBtn.disabled = true;
319 triggerPost(rerunLighthouseUrl, function (data) {
320 applyData(data);
321 });
322 });
323 }
324
325 poll();
326});