repos
/ orchard main

orchard

mirror

Every 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

10.2 KB · 324 lines · JavaScript Raw History
  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});