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