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.
 
 
 
 

694 lines
20 KiB

// /home/def/monitor/frontend/app.js
function el(tag, cls) {
const n = document.createElement("div");
if (tag && tag !== "div") {
const real = document.createElement(tag);
if (cls) real.className = cls;
return real;
}
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`;
}
function fmtCad(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: "CAD",
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(n);
}
/* ---------------- Tooltip (singleton) ---------------- */
let tipEl = null;
function ensureTip() {
if (tipEl) return tipEl;
tipEl = document.createElement("div");
tipEl.className = "spark-tip";
const date = document.createElement("div");
date.className = "spark-tip-date";
const val = document.createElement("div");
val.className = "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 = document.createElement("div");
row.className = "row";
const left = document.createElement("div");
left.className = "left";
const ic = document.createElement("div");
ic.className = "ic";
ic.textContent = iconText(line.icon);
const mid = document.createElement("div");
mid.className = "mid";
const sym = document.createElement("div");
sym.className = "val";
sym.textContent = line.key || line.symbol || "";
const name = document.createElement("div");
name.className = "subline";
name.textContent = line.name || "";
left.appendChild(ic);
mid.appendChild(sym);
mid.appendChild(name);
left.appendChild(mid);
const center = document.createElement("div");
center.className = "mid";
center.style.textAlign = "right";
const value = document.createElement("div");
value.className = "val";
value.textContent = line.display != null ? String(line.display) : fmtNumber(line.value);
const vs = document.createElement("div");
vs.className = "subline";
vs.textContent = line.vs ? `in ${line.vs}` : "";
center.appendChild(value);
center.appendChild(vs);
const right = document.createElement("div");
right.className = "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 = document.createElement("div");
row.className = "oracle-asset-row";
const left = document.createElement("div");
left.className = "oracle-asset-left";
const title = document.createElement("div");
title.className = "oracle-asset-key";
title.textContent = `${asset.symbol}${asset.chain}`;
const sub = document.createElement("div");
sub.className = "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 = document.createElement("div");
right.className = "oracle-asset-right";
const badges = document.createElement("div");
badges.className = "oracle-badges";
const freshBadge = document.createElement("span");
freshBadge.className = `oracle-badge ${asset.stale ? "badge-stale" : "badge-fresh"}`;
freshBadge.textContent = asset.stale ? "stale" : "fresh";
badges.appendChild(freshBadge);
if (asset.billing_enabled) {
const billingBadge = document.createElement("span");
billingBadge.className = "oracle-badge badge-billing";
billingBadge.textContent = "billing";
badges.appendChild(billingBadge);
}
right.appendChild(badges);
const price = document.createElement("div");
price.className = "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 = document.createElement("div");
wrap.className = "oracle-panel";
const top = document.createElement("div");
top.className = "oracle-top";
const left = document.createElement("div");
const title = document.createElement("div");
title.className = "section-title";
title.textContent = "Oracle Status";
title.style.margin = "0 0 8px 0";
const updated = document.createElement("div");
updated.className = "oracle-updated";
updated.textContent = `Last update: ${fmtDateTime(pricesData.updated_at)}${ageTextFromIso(pricesData.updated_at)}`;
left.appendChild(title);
left.appendChild(updated);
const right = document.createElement("div");
right.className = "oracle-summary";
const overall = document.createElement("span");
overall.className = `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 = document.createElement("div");
meta.className = "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 = document.createElement("div");
list.className = "oracle-assets";
for (const asset of assets) {
list.appendChild(makeOracleAssetRow(asset));
}
wrap.appendChild(list);
return wrap;
}
function makeQuoteRow(q) {
const row = document.createElement("div");
row.className = "quote-row";
const left = document.createElement("div");
left.className = "quote-left";
const symbol = document.createElement("div");
symbol.className = "quote-symbol";
symbol.textContent = `${q.symbol}${q.chain}`;
const detail = document.createElement("div");
detail.className = "quote-detail";
detail.textContent = q.price_cad != null && isFinite(q.price_cad)
? `1 ${q.symbol} = ${fmtNumber(q.price_cad)} CAD`
: "price unavailable";
left.appendChild(symbol);
left.appendChild(detail);
const right = document.createElement("div");
right.className = "quote-right";
const amount = document.createElement("div");
amount.className = "quote-amount";
amount.textContent = q.display_amount || "—";
const badges = document.createElement("div");
badges.className = "quote-badges";
if (q.recommended) {
const rec = document.createElement("span");
rec.className = "oracle-badge badge-billing";
rec.textContent = "recommended";
badges.appendChild(rec);
}
const avail = document.createElement("span");
avail.className = `oracle-badge ${q.available ? "badge-fresh" : "badge-stale"}`;
avail.textContent = q.available ? "available" : (q.reason || "unavailable");
badges.appendChild(avail);
right.appendChild(amount);
right.appendChild(badges);
row.appendChild(left);
row.appendChild(right);
return row;
}
function makeQuotePanel(quoteData, currentAmount) {
const wrap = document.createElement("div");
wrap.className = "quote-panel";
const top = document.createElement("div");
top.className = "quote-top";
const left = document.createElement("div");
const title = document.createElement("div");
title.className = "section-title";
title.textContent = "Live Quote";
title.style.margin = "0 0 8px 0";
const sub = document.createElement("div");
sub.className = "oracle-updated";
sub.textContent = `Quote for ${fmtCad(currentAmount)} • expires ${fmtDateTime(quoteData.expires_at)}`;
left.appendChild(title);
left.appendChild(sub);
const controls = document.createElement("div");
controls.className = "quote-controls";
const input = document.createElement("input");
input.type = "number";
input.min = "0.01";
input.step = "0.01";
input.value = String(currentAmount);
input.id = "quoteAmountInput";
input.className = "quote-input";
const button = document.createElement("button");
button.type = "button";
button.className = "quote-button";
button.textContent = "Update";
button.addEventListener("click", () => {
const v = Number(input.value);
if (!Number.isFinite(v) || v <= 0) return;
refresh(v);
});
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
button.click();
}
});
controls.appendChild(input);
controls.appendChild(button);
top.appendChild(left);
top.appendChild(controls);
wrap.appendChild(top);
const list = document.createElement("div");
list.className = "quote-list";
for (const q of (quoteData.quotes || [])) {
list.appendChild(makeQuoteRow(q));
}
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(keepQuote = true) {
const root = document.getElementById("root");
if (!root) return;
const currentAmount = Number(localStorage.getItem("quoteAmountCad") || "25");
let linesData;
let oraclePricesData;
let quoteData = null;
try {
const requests = [
fetch("/api/lines", { cache: "no-store" }),
fetch("/api/oracle/prices", { cache: "no-store" })
];
if (!keepQuote) {
requests.push(fetch(`/api/oracle/quote?fiat=CAD&amount=${encodeURIComponent(currentAmount)}`, { cache: "no-store" }));
}
const responses = await Promise.all(requests);
linesData = await responses[0].json();
oraclePricesData = await responses[1].json();
if (!keepQuote && responses[2]) {
quoteData = await responses[2].json();
}
} catch (e) {
setStatus(false, false, "Fetch failed");
root.innerHTML = "";
const m = document.createElement("div");
m.className = "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);
const existingQuotePanel = keepQuote ? root.querySelector(".quote-panel") : null;
const existingQuoteSep = keepQuote ? (existingQuotePanel ? existingQuotePanel.nextElementSibling : null) : null;
root.innerHTML = "";
if (keepQuote && existingQuotePanel) {
root.appendChild(existingQuotePanel);
if (existingQuoteSep && existingQuoteSep.classList && existingQuoteSep.classList.contains("sep")) {
root.appendChild(existingQuoteSep);
} else {
const sepQ = document.createElement("div");
sepQ.className = "sep";
root.appendChild(sepQ);
}
} else if (quoteData && Array.isArray(quoteData.quotes)) {
root.appendChild(makeQuotePanel(quoteData, currentAmount));
const sepQ = document.createElement("div");
sepQ.className = "sep";
root.appendChild(sepQ);
}
if (oraclePricesData && oraclePricesData.assets && Object.keys(oraclePricesData.assets).length) {
root.appendChild(makeOraclePanel(oraclePricesData));
const sep0 = document.createElement("div");
sep0.className = "sep";
root.appendChild(sep0);
}
const fiatTitle = document.createElement("div");
fiatTitle.className = "section-title";
fiatTitle.textContent = "Currency";
root.appendChild(fiatTitle);
const fiatWrap = document.createElement("div");
fiatWrap.className = "rows";
const fiat = Array.isArray(linesData.fiat) ? linesData.fiat : [];
for (const line of fiat) fiatWrap.appendChild(makeRow(line));
root.appendChild(fiatWrap);
const sep = document.createElement("div");
sep.className = "sep";
root.appendChild(sep);
const cryptoTitle = document.createElement("div");
cryptoTitle.className = "section-title";
cryptoTitle.textContent = "Cryptocurrency";
root.appendChild(cryptoTitle);
const cryptoWrap = document.createElement("div");
cryptoWrap.className = "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(false);
setInterval(() => refresh(true), 10000);
});
})();