repos
/ finance-rust master

finance-rust

mirror archived upstream

Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.

axumdockerfinancerustself-hostedsqlitestocksvite

10.4 KB · 304 lines · JavaScript Raw History
  1// Data-health page.
  2//
  3// The page ships with an embedded snapshot (#health-data) so it renders at
  4// once with no flash. From there it stays live off the shared market stream:
  5// base/stream.js re-broadcasts every SSE `health` nudge as a `finance:health`
  6// window event, and this script answers each one by pulling a fresh snapshot
  7// from /api/health and repainting. It also repaints when the tab regains
  8// focus, and re-renders every 30s so the relative times stay honest.
  9
 10const $ = (sel) => document.querySelector(sel);
 11
 12// ---- formatting (mirrors the server-side minijinja filters in templates.rs) ----
 13
 14const DASH = "·";
 15
 16function esc(s) {
 17  return String(s).replace(
 18    /[&<>"']/g,
 19    (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
 20  );
 21}
 22
 23// Epoch-ms in the past -> "4m ago" (mirrors the `ago` filter).
 24function ago(ms) {
 25  if (ms == null) return DASH;
 26  const s = Math.round((Date.now() - ms) / 1000);
 27  if (s < 5) return "just now";
 28  if (s < 60) return `${s}s ago`;
 29  if (s < 3600) return `${Math.floor(s / 60)}m ago`;
 30  if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
 31  return `${Math.floor(s / 86400)}d ago`;
 32}
 33
 34// Epoch-ms in the future -> "in 4m"; already elapsed -> "due now".
 35function until(ms) {
 36  if (ms == null) return DASH;
 37  const s = Math.round((ms - Date.now()) / 1000);
 38  if (s <= 0) return "due now";
 39  if (s < 60) return `in ${s}s`;
 40  if (s < 3600) return `in ${Math.floor(s / 60)}m`;
 41  if (s < 86400) return `in ${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
 42  return `in ${Math.floor(s / 86400)}d`;
 43}
 44
 45// Epoch-ms -> local 24-hour clock time, e.g. "14:03:21".
 46function clock(ms) {
 47  return new Date(ms).toLocaleTimeString("en-US", { hour12: false });
 48}
 49
 50function num(n) {
 51  return n == null ? DASH : n.toLocaleString("en-US");
 52}
 53
 54function dur(ms) {
 55  if (ms == null) return null;
 56  return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
 57}
 58
 59// ---- badges ----
 60
 61// A breaker / job / log status maps to one of four visual tones.
 62const BREAKER_TONE = { closed: "ok", half_open: "warn", open: "bad" };
 63const JOB_TONE = { ok: "ok", fetching: "warn", error: "bad", stale: "warn", idle: "idle" };
 64const LOG_TONE = { ok: "ok", skipped: "warn", error: "bad" };
 65
 66function badge(text, tone) {
 67  return `<span class="badge badge--${tone}">${esc(text)}</span>`;
 68}
 69
 70function errKv(label, msg, at) {
 71  if (!msg) return "";
 72  return `<div class="kv kv--err"><dt>${label}</dt>
 73    <dd>${esc(msg)} <span class="muted">${esc(ago(at))}</span></dd></div>`;
 74}
 75
 76// ---- region renderers ----
 77
 78function renderEndpoints(list) {
 79  if (!list.length) {
 80    return `<p class="health-empty">No endpoint has been contacted yet.</p>`;
 81  }
 82  const cards = list.map((e) => {
 83    const tone = BREAKER_TONE[e.state] || "idle";
 84    const breaker = e.state === "half_open" ? "half-open" : e.state;
 85    const fillTone =
 86      e.budget_pct >= 90 ? " track__fill--bad" : e.budget_pct >= 75 ? " track__fill--warn" : "";
 87    const resets =
 88      e.hour_start != null && e.hour_count > 0
 89        ? `<p class="meter__note">Budget window resets ${esc(until(e.hour_start + 3600000))}.</p>`
 90        : "";
 91    const probe =
 92      e.state === "open" && e.retry_at != null
 93        ? `<div class="kv"><dt>Probe</dt><dd>${esc(until(e.retry_at))}</dd></div>`
 94        : "";
 95    return `<article class="endpoint">
 96      <div class="endpoint__head">
 97        <h3>${esc(e.label)}</h3>
 98        ${badge(breaker, tone)}
 99      </div>
100      <div class="meter">
101        <div class="meter__row">
102          <span class="eyebrow">Hourly request budget</span>
103          <span class="num meter__count">${num(e.hour_count)} / ${num(e.hourly_budget)}</span>
104        </div>
105        <div class="track meter__track">
106          <span class="track__fill${fillTone}" style="width:${e.budget_pct}%"></span>
107        </div>
108        ${resets}
109      </div>
110      <dl class="kvs">
111        <div class="kv"><dt>Circuit trips</dt><dd class="num">${num(e.trip_count)}</dd></div>
112        <div class="kv"><dt>Failure streak</dt><dd class="num">${num(e.fail_streak)}</dd></div>
113        <div class="kv"><dt>Last success</dt><dd>${esc(ago(e.last_ok_at))}</dd></div>
114        ${probe}
115        ${errKv("Last error", e.last_error, e.last_error_at)}
116      </dl>
117    </article>`;
118  });
119  return `<div class="endpoint-grid">${cards.join("")}</div>`;
120}
121
122function renderJobs(list) {
123  if (!list.length) {
124    return `<p class="health-empty">No job has run yet.</p>`;
125  }
126  const rows = list.map((j) => {
127    const tone = JOB_TONE[j.state] || "idle";
128    const nextRun =
129      j.state === "fetching"
130        ? "running now"
131        : j.next_run_at != null
132          ? until(j.next_run_at)
133          : DASH;
134    return `<article class="job${j.state === "fetching" ? " job--active" : ""}">
135      <div class="job__head">
136        <div class="job__id">
137          <h3>${esc(j.label)}</h3>
138          ${j.description ? `<p>${esc(j.description)}</p>` : ""}
139        </div>
140        ${badge(j.state, tone)}
141      </div>
142      <dl class="kvs">
143        <div class="kv"><dt>Last success</dt><dd>${esc(ago(j.last_ok_at))}</dd></div>
144        <div class="kv"><dt>Next run</dt><dd>${esc(nextRun)}</dd></div>
145        ${errKv("Last error", j.last_error, j.last_error_at)}
146      </dl>
147    </article>`;
148  });
149  return `<div class="job-list">${rows.join("")}</div>`;
150}
151
152function renderLog(list) {
153  if (!list.length) {
154    return `<p class="health-empty">The fetch log is empty.</p>`;
155  }
156  const rows = list.map((r) => {
157    const tone = LOG_TONE[r.status] || "idle";
158    const meta = [r.rows != null ? `${num(r.rows)} rows` : null, dur(r.duration_ms)]
159      .filter(Boolean)
160      .join(" · ");
161    return `<li class="logrow logrow--${tone}">
162      <span class="logrow__time num">${clock(r.started_at)}</span>
163      <span class="logrow__job">${esc(r.job)}</span>
164      ${badge(r.status, tone)}
165      <span class="logrow__detail">${r.detail ? esc(r.detail) : ""}</span>
166      <span class="logrow__meta num">${esc(meta)}</span>
167    </li>`;
168  });
169  return `<div class="log"><ul class="logrows">${rows.join("")}</ul></div>`;
170}
171
172// The top systems verdict (Phase 7): distil the whole snapshot into one plain
173// read — overall tone, a headline, and a supporting clause. Tone is the worst
174// thing on the page: a tripped breaker or an errored job is bad; a recovering
175// breaker or a stale job is working; otherwise all-clear. A mid-fetch job is
176// normal and does not darken the tone (the live banner below names it).
177function renderVerdict(snap) {
178  const el = $('[data-role="verdict"]');
179  if (!el) return;
180  const eps = snap.endpoints || [];
181  const jobs = snap.jobs || [];
182  const log = snap.log || [];
183
184  const epOpen = eps.filter((e) => e.state === "open").length;
185  const epHalf = eps.filter((e) => e.state === "half_open").length;
186  const epHealthy = eps.filter((e) => e.state === "closed").length;
187  const jobErr = jobs.filter((j) => j.state === "error").length;
188  const jobStale = jobs.filter((j) => j.state === "stale").length;
189  const fetching = jobs.filter((j) => j.state === "fetching").length;
190
191  let tone, head;
192  if (epOpen || jobErr) {
193    tone = "bad";
194    head = "Data flow degraded";
195  } else if (epHalf || jobStale) {
196    tone = "warn";
197    head = "Recovering";
198  } else {
199    tone = "ok";
200    head = "All systems normal";
201  }
202
203  // Sources clause: "both data sources healthy" reads best at the usual two
204  // (Yahoo + SEC), with a fraction when any is down.
205  let srcPart;
206  if (!eps.length) {
207    srcPart = "no sources contacted yet";
208  } else if (epHealthy === eps.length) {
209    srcPart = eps.length === 2 ? "both data sources healthy" : `all ${eps.length} data sources healthy`;
210  } else {
211    srcPart = `${epHealthy}/${eps.length} data sources healthy`;
212  }
213
214  // Jobs clause: how many are on schedule (anything not errored or stale).
215  let jobPart;
216  if (!jobs.length) {
217    jobPart = "no jobs yet";
218  } else if (jobErr || jobStale) {
219    jobPart = `${jobs.length - jobErr - jobStale}/${jobs.length} jobs on schedule`;
220  } else {
221    const noun = jobs.length === 1 ? "job" : "jobs";
222    jobPart = `${jobs.length} ${noun} on schedule`;
223  }
224
225  const parts = [srcPart, jobPart];
226  if (fetching) parts.push("fetching now");
227  else if (log.length) parts.push(`last fetch ${ago(log[0].started_at)}`);
228
229  el.dataset.tone = tone;
230  el.hidden = false;
231  $('[data-role="verdict-head"]').textContent = head;
232  $('[data-role="verdict-detail"]').textContent = parts.join(` ${DASH} `);
233}
234
235function renderBanner(jobs) {
236  const banner = $('[data-role="banner"]');
237  if (!banner) return;
238  const active = jobs.filter((j) => j.state === "fetching");
239  if (!active.length) {
240    banner.hidden = true;
241    banner.innerHTML = "";
242    return;
243  }
244  const names = active.map((j) => esc(j.label)).join(", ");
245  banner.hidden = false;
246  banner.innerHTML = `<span class="health-banner__dot"></span>
247    <span>Fetching now — ${names}</span>`;
248}
249
250export function initHealth() {
251  const dataEl = document.getElementById("health-data");
252  if (!dataEl) return;
253
254  // The most recent snapshot, kept so the relative-time ticker can repaint
255  // without another request.
256  let current = null;
257
258  function render(snap) {
259    current = snap;
260    renderVerdict(snap);
261    $('[data-role="endpoints"]').innerHTML = renderEndpoints(snap.endpoints);
262    $('[data-role="jobs"]').innerHTML = renderJobs(snap.jobs);
263    $('[data-role="log"]').innerHTML = renderLog(snap.log);
264    renderBanner(snap.jobs);
265    $('[data-role="asof"]').textContent = `updated ${ago(snap.generated_at)}`;
266  }
267
268  try {
269    render(JSON.parse(dataEl.textContent));
270  } catch {
271    return; // a malformed embed: leave the empty shell rather than throw
272  }
273
274  let pending = false;
275  async function refresh() {
276    if (pending) return;
277    pending = true;
278    try {
279      const res = await fetch("/api/health", { headers: { Accept: "application/json" } });
280      if (res.ok) render(await res.json());
281    } catch {
282      /* a failed poll just leaves the last snapshot up */
283    } finally {
284      pending = false;
285    }
286  }
287
288  // A job's start and finish can land within a few ms of each other; debounce
289  // so a burst of nudges costs one /api/health pull.
290  let timer = null;
291  window.addEventListener("finance:health", () => {
292    clearTimeout(timer);
293    timer = setTimeout(refresh, 250);
294  });
295
296  // Catch up after the tab was hidden, and keep "4m ago" honest while idle.
297  document.addEventListener("visibilitychange", () => {
298    if (!document.hidden) refresh();
299  });
300  setInterval(() => {
301    if (current && !document.hidden) render(current);
302  }, 30000);
303}