repos
/ status-rust master

status-rust

mirror archived upstream

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

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