// /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 = `
Billing assets${billingEnabled}
Fresh assets${freshCount}/${assets.length}
`; 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); }); })();