Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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}