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