repos
/ status-django master

status-django

mirror archived upstream

Self-hostable uptime monitor and status page on Django: HTTP checks, Lighthouse audits, SEO crawls, and email and Discord alerts.

djangodockerhandcodedpythonself-hostedsqlitestatus-pageuptime-monitoringvite

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