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