You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

486 lines
13 KiB

// /home/def/monitor/frontend/app.js
function el(tag, cls) {
const n = document.createElement(tag);
if (cls) n.className = cls;
return n;
}
/* ---------------- Theme toggle (checkbox-driven) ---------------- */
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
const toggle = document.getElementById("themeToggle");
if (toggle) toggle.checked = (theme === "light");
}
function toggleThemeFromCheckbox() {
const toggle = document.getElementById("themeToggle");
const wantsLight = !!toggle?.checked;
applyTheme(wantsLight ? "light" : "dark");
}
(function initTheme() {
const saved = localStorage.getItem("theme") || "dark";
applyTheme(saved);
window.addEventListener("DOMContentLoaded", () => {
const toggle = document.getElementById("themeToggle");
if (toggle) {
toggle.addEventListener("change", toggleThemeFromCheckbox);
toggle.checked = (document.documentElement.getAttribute("data-theme") === "light");
}
});
})();
/* ---------------- Icons ---------------- */
function iconText(kind) {
const map = {
"flag-ca": "🇨🇦",
"flag-us": "🇺🇸",
"flag-eu": "🇪🇺",
"btc": "₿",
"eth": "Ξ",
"eti": "ETI",
"egaz": "EGAZ",
"etho": "ETHO",
"cat": "CAT"
};
return map[kind] || "•";
}
/* ---------------- Formatting ---------------- */
function fmtNumber(v) {
if (v == null || !isFinite(v)) return "—";
if (v >= 1000) return v.toFixed(0);
if (v >= 1) return v.toFixed(4);
return v.toFixed(6);
}
function fmtDate(ms) {
try {
const d = new Date(ms);
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "2-digit" });
} catch {
return String(ms);
}
}
function fmtDateTime(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString();
}
function ageTextFromIso(iso) {
if (!iso) return "unknown";
const ts = new Date(iso).getTime();
if (Number.isNaN(ts)) return "unknown";
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
/* ---------------- Tooltip (singleton) ---------------- */
let tipEl = null;
function ensureTip() {
if (tipEl) return tipEl;
tipEl = el("div", "spark-tip");
const date = el("div", "spark-tip-date");
const val = el("div", "spark-tip-val");
tipEl.appendChild(date);
tipEl.appendChild(val);
document.body.appendChild(tipEl);
return tipEl;
}
function showTip(x, y, dateText, valueText, vsText) {
const t = ensureTip();
t.children[0].textContent = dateText;
t.children[1].innerHTML = "";
const v = document.createElement("span");
v.textContent = valueText + " ";
const s = document.createElement("span");
s.className = "spark-tip-vs";
s.textContent = vsText || "";
t.children[1].appendChild(v);
t.children[1].appendChild(s);
t.style.display = "block";
const pad = 12;
const rect = t.getBoundingClientRect();
let left = x + 14;
let top = y + 14;
if (left + rect.width + pad > window.innerWidth) left = x - rect.width - 14;
if (top + rect.height + pad > window.innerHeight) top = y - rect.height - 14;
t.style.left = `${Math.max(pad, left)}px`;
t.style.top = `${Math.max(pad, top)}px`;
}
function hideTip() {
if (!tipEl) return;
tipEl.style.display = "none";
}
/* ---------------- Sparkline ---------------- */
function drawSpark(canvas, points, hoverIndex = -1) {
const ctx = canvas.getContext("2d");
const cssW = canvas.clientWidth || 96;
const cssH = canvas.clientHeight || 26;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = cssW;
const h = cssH;
ctx.clearRect(0, 0, w, h);
if (!points || points.length < 2) {
ctx.globalAlpha = 0.25;
ctx.beginPath();
ctx.moveTo(10, h / 2);
ctx.lineTo(w - 10, h / 2);
ctx.stroke();
ctx.globalAlpha = 1;
return;
}
const min = Math.min(...points);
const max = Math.max(...points);
const pad = 6;
const scaleY = (v) => {
if (max === min) return h / 2;
const t = (v - min) / (max - min);
return (h - pad) - t * (h - pad * 2);
};
const scaleX = (i) => {
const t = i / (points.length - 1);
return pad + t * (w - pad * 2);
};
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.strokeStyle = getComputedStyle(canvas).color || "#9ab0d1";
ctx.beginPath();
ctx.moveTo(scaleX(0), scaleY(points[0]));
for (let i = 1; i < points.length; i++) {
ctx.lineTo(scaleX(i), scaleY(points[i]));
}
ctx.stroke();
if (hoverIndex >= 0 && hoverIndex < points.length) {
const x = scaleX(hoverIndex);
const y = scaleY(points[hoverIndex]);
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--toggle-on").trim() || "#2f6fff";
ctx.beginPath();
ctx.arc(x, y, 3.2, 0, Math.PI * 2);
ctx.fill();
}
}
function attachSparkHover(canvas, line) {
const sp = Array.isArray(line.spark_points) ? line.spark_points : null;
const points = Array.isArray(line.spark) ? line.spark : [];
drawSpark(canvas, points, -1);
let lastHover = -1;
function getHoverIndex(clientX) {
if (!points || points.length < 2) return -1;
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const pad = 6;
const usableW = rect.width - pad * 2;
if (usableW <= 0) return -1;
let t = (x - pad) / usableW;
t = Math.max(0, Math.min(1, t));
return Math.round(t * (points.length - 1));
}
function onMove(ev) {
const idx = getHoverIndex(ev.clientX);
if (idx === -1) return;
if (idx !== lastHover) {
lastHover = idx;
drawSpark(canvas, points, idx);
}
let when = "";
let val = points[idx];
if (sp && sp[idx] && typeof sp[idx].t === "number") {
when = fmtDate(sp[idx].t);
if (typeof sp[idx].v === "number") val = sp[idx].v;
} else {
when = `Day ${idx + 1}`;
}
showTip(ev.clientX, ev.clientY, when, fmtNumber(val), line.vs || "");
}
function onLeave() {
lastHover = -1;
hideTip();
drawSpark(canvas, points, -1);
}
canvas.addEventListener("mousemove", onMove);
canvas.addEventListener("mouseleave", onLeave);
canvas._sparkCleanup = () => {
canvas.removeEventListener("mousemove", onMove);
canvas.removeEventListener("mouseleave", onLeave);
};
}
/* ---------------- DOM builders ---------------- */
function makeRow(line) {
const row = el("div", "row");
const left = el("div", "left");
const ic = el("div", "ic");
ic.textContent = iconText(line.icon);
const mid = el("div", "mid");
const sym = el("div", "val");
sym.textContent = line.key || line.symbol || "";
const name = el("div", "subline");
name.textContent = line.name || "";
left.appendChild(ic);
mid.appendChild(sym);
mid.appendChild(name);
left.appendChild(mid);
const center = el("div", "mid");
center.style.textAlign = "right";
const value = el("div", "val");
value.textContent = line.display != null ? String(line.display) : fmtNumber(line.value);
const vs = el("div", "subline");
vs.textContent = line.vs ? `in ${line.vs}` : "";
center.appendChild(value);
center.appendChild(vs);
const right = el("div", "right");
const spark = document.createElement("canvas");
spark.className = "spark";
right.appendChild(spark);
row.appendChild(left);
row.appendChild(center);
row.appendChild(right);
attachSparkHover(spark, line);
return row;
}
function makeOracleAssetRow(asset) {
const row = el("div", "oracle-asset-row");
const left = el("div", "oracle-asset-left");
const title = el("div", "oracle-asset-key");
title.textContent = `${asset.symbol}${asset.chain}`;
const sub = el("div", "oracle-asset-sub");
const source = asset.source || "—";
const sourceStatus = asset.source_status || "—";
const freshness = asset.freshness || "unknown";
sub.textContent = `source: ${source} (${sourceStatus}) • ${freshness}`;
left.appendChild(title);
left.appendChild(sub);
const right = el("div", "oracle-asset-right");
const badges = el("div", "oracle-badges");
const freshBadge = el("span", `oracle-badge ${asset.stale ? "badge-stale" : "badge-fresh"}`);
freshBadge.textContent = asset.stale ? "stale" : "fresh";
badges.appendChild(freshBadge);
if (asset.billing_enabled) {
const billingBadge = el("span", "oracle-badge badge-billing");
billingBadge.textContent = "billing";
badges.appendChild(billingBadge);
}
right.appendChild(badges);
const price = el("div", "oracle-price");
if (asset.price_cad != null && isFinite(asset.price_cad)) {
price.textContent = `${fmtNumber(asset.price_cad)} CAD`;
} else {
price.textContent = "—";
}
right.appendChild(price);
row.appendChild(left);
row.appendChild(right);
return row;
}
function makeOraclePanel(pricesData) {
const wrap = el("div", "oracle-panel");
const top = el("div", "oracle-top");
const left = el("div");
const title = el("div", "section-title");
title.textContent = "Oracle Status";
title.style.margin = "0 0 8px 0";
const updated = el("div", "oracle-updated");
updated.textContent = `Last update: ${fmtDateTime(pricesData.updated_at)}${ageTextFromIso(pricesData.updated_at)}`;
left.appendChild(title);
left.appendChild(updated);
const right = el("div", "oracle-summary");
const overall = el("span", `oracle-badge ${pricesData.status === "fresh" ? "badge-fresh" : "badge-stale"}`);
overall.textContent = pricesData.status || "unknown";
right.appendChild(overall);
top.appendChild(left);
top.appendChild(right);
wrap.appendChild(top);
const assets = Object.values(pricesData.assets || {})
.sort((a, b) => (a.quote_priority ?? 9999) - (b.quote_priority ?? 9999));
const billingEnabled = assets.filter(a => a.billing_enabled).length;
const freshCount = assets.filter(a => !a.stale).length;
const meta = el("div", "oracle-meta");
meta.innerHTML = `
<div class="oracle-meta-item"><span class="oracle-meta-label">Billing assets</span><span class="oracle-meta-value">${billingEnabled}</span></div>
<div class="oracle-meta-item"><span class="oracle-meta-label">Fresh assets</span><span class="oracle-meta-value">${freshCount}/${assets.length}</span></div>
`;
wrap.appendChild(meta);
const list = el("div", "oracle-assets");
for (const asset of assets) {
list.appendChild(makeOracleAssetRow(asset));
}
wrap.appendChild(list);
return wrap;
}
function setStatus(ok, warnings, text) {
const s = document.getElementById("status");
if (!s) return;
if (text) {
s.textContent = text;
} else {
s.textContent = ok ? (warnings ? "Data loaded (with warnings)" : "OK") : "ERROR";
}
}
function setCycle(progress) {
const c = document.getElementById("cycle");
if (!c) return;
if (!progress || typeof progress !== "object") {
c.textContent = "";
return;
}
const total = progress.total ?? "";
const done = progress.done ?? "";
const cycle = progress.cycle ?? "";
const cur = progress.current ? ` • updating ${progress.current}` : "";
if (total !== "" && done !== "") {
c.textContent = `updated ${done}/${total} this cycle=${cycle}${cur}`;
} else {
c.textContent = "";
}
}
/* ---------------- Main render ---------------- */
async function refresh() {
const root = document.getElementById("root");
if (!root) return;
let linesData;
let oraclePricesData;
try {
const [linesRes, oracleRes] = await Promise.all([
fetch("/api/lines", { cache: "no-store" }),
fetch("/api/oracle/prices", { cache: "no-store" })
]);
linesData = await linesRes.json();
oraclePricesData = await oracleRes.json();
} catch (e) {
setStatus(false, false, "Fetch failed");
root.innerHTML = "";
const m = el("div", "subline");
m.textContent = "Failed to load monitor/oracle data";
root.appendChild(m);
return;
}
const ok = !!linesData.ok;
const warnings = Array.isArray(linesData.errors) && linesData.errors.length > 0;
setStatus(ok, warnings);
setCycle(linesData.progress);
root.innerHTML = "";
if (oraclePricesData && oraclePricesData.assets) {
root.appendChild(makeOraclePanel(oraclePricesData));
const sep0 = el("div", "sep");
root.appendChild(sep0);
}
const fiatTitle = el("div", "section-title");
fiatTitle.textContent = "Currency";
root.appendChild(fiatTitle);
const fiatWrap = el("div", "rows");
const fiat = Array.isArray(linesData.fiat) ? linesData.fiat : [];
for (const line of fiat) fiatWrap.appendChild(makeRow(line));
root.appendChild(fiatWrap);
const sep = el("div", "sep");
root.appendChild(sep);
const cryptoTitle = el("div", "section-title");
cryptoTitle.textContent = "Cryptocurrency";
root.appendChild(cryptoTitle);
const cryptoWrap = el("div", "rows");
const crypto = Array.isArray(linesData.crypto) ? linesData.crypto : [];
for (const line of crypto) cryptoWrap.appendChild(makeRow(line));
root.appendChild(cryptoWrap);
}
(function boot() {
window.addEventListener("DOMContentLoaded", () => {
refresh();
setInterval(refresh, 10000);
});
})();