// /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 = `
`; 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); }); })();