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.
594 lines
17 KiB
594 lines
17 KiB
// /home/def/monitor/frontend/app.js |
|
|
|
function el(tag, cls) { |
|
const n = document.createElement(tag || "div"); |
|
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 quoteSection = el("div", "oracle-quote-wrap"); |
|
const quoteTitle = el("div", "oracle-quote-title"); |
|
quoteTitle.textContent = "Live Quote Calculator"; |
|
|
|
const quoteForm = el("div", "oracle-quote-form"); |
|
|
|
const amountWrap = el("div", "oracle-quote-field"); |
|
const amountLabel = el("label", "oracle-quote-label"); |
|
amountLabel.setAttribute("for", "oracleQuoteAmount"); |
|
amountLabel.textContent = "Amount (CAD)"; |
|
const amountInput = document.createElement("input"); |
|
amountInput.id = "oracleQuoteAmount"; |
|
amountInput.className = "oracle-quote-input"; |
|
amountInput.type = "number"; |
|
amountInput.min = "0.01"; |
|
amountInput.step = "0.01"; |
|
amountInput.value = "100.00"; |
|
amountWrap.appendChild(amountLabel); |
|
amountWrap.appendChild(amountInput); |
|
|
|
const buttonWrap = el("div", "oracle-quote-actions"); |
|
const quoteBtn = document.createElement("button"); |
|
quoteBtn.className = "oracle-quote-button"; |
|
quoteBtn.type = "button"; |
|
quoteBtn.textContent = "Get Quote"; |
|
buttonWrap.appendChild(quoteBtn); |
|
|
|
quoteForm.appendChild(amountWrap); |
|
quoteForm.appendChild(buttonWrap); |
|
|
|
const quoteResults = el("div", "oracle-quote-results"); |
|
quoteResults.id = "oracleQuoteResults"; |
|
quoteResults.innerHTML = `<div class="oracle-quote-empty">Enter an amount and load a live quote.</div>`; |
|
|
|
quoteSection.appendChild(quoteTitle); |
|
quoteSection.appendChild(quoteForm); |
|
quoteSection.appendChild(quoteResults); |
|
wrap.appendChild(quoteSection); |
|
|
|
const list = el("div", "oracle-assets"); |
|
for (const asset of assets) { |
|
list.appendChild(makeOracleAssetRow(asset)); |
|
} |
|
wrap.appendChild(list); |
|
|
|
requestAnimationFrame(() => { |
|
const btn = document.querySelector(".oracle-quote-button"); |
|
const input = document.getElementById("oracleQuoteAmount"); |
|
const results = document.getElementById("oracleQuoteResults"); |
|
|
|
async function loadQuote() { |
|
const raw = input?.value; |
|
const amount = Number(raw); |
|
|
|
if (!Number.isFinite(amount) || amount <= 0) { |
|
results.innerHTML = `<div class="oracle-quote-empty">Enter a valid CAD amount above 0.</div>`; |
|
return; |
|
} |
|
|
|
results.innerHTML = `<div class="oracle-quote-empty">Loading quote…</div>`; |
|
|
|
try { |
|
const res = await fetch(`/api/oracle/quote?fiat=CAD&amount=${encodeURIComponent(amount)}`, { cache: "no-store" }); |
|
const data = await res.json(); |
|
|
|
if (!res.ok || !data || !Array.isArray(data.quotes)) { |
|
results.innerHTML = `<div class="oracle-quote-empty">Quote request failed.</div>`; |
|
return; |
|
} |
|
|
|
const rows = data.quotes.map((q) => { |
|
const availability = q.available ? "available" : (q.reason || "unavailable"); |
|
const recommended = q.recommended ? `<span class="oracle-badge badge-billing">recommended</span>` : ""; |
|
const stale = q.available ? `<span class="oracle-badge badge-fresh">live</span>` : `<span class="oracle-badge badge-stale">${availability}</span>`; |
|
|
|
return ` |
|
<div class="oracle-quote-row"> |
|
<div class="oracle-quote-row-left"> |
|
<div class="oracle-quote-asset">${q.symbol} • ${q.chain}</div> |
|
<div class="oracle-quote-sub">1 ${q.symbol} = ${q.price_cad != null ? fmtNumber(q.price_cad) : "—"} CAD</div> |
|
</div> |
|
<div class="oracle-quote-row-right"> |
|
<div class="oracle-quote-badges">${recommended}${stale}</div> |
|
<div class="oracle-quote-amount">${q.display_amount || "—"}</div> |
|
</div> |
|
</div> |
|
`; |
|
}).join(""); |
|
|
|
results.innerHTML = ` |
|
<div class="oracle-quote-meta"> |
|
<div>Quoted: ${fmtDateTime(data.quoted_at)}</div> |
|
<div>Expires: ${fmtDateTime(data.expires_at)}</div> |
|
</div> |
|
<div class="oracle-quote-list">${rows}</div> |
|
`; |
|
} catch (_err) { |
|
results.innerHTML = `<div class="oracle-quote-empty">Quote request failed.</div>`; |
|
} |
|
} |
|
|
|
if (btn && !btn.dataset.bound) { |
|
btn.dataset.bound = "1"; |
|
btn.addEventListener("click", loadQuote); |
|
} |
|
|
|
if (input && !input.dataset.bound) { |
|
input.dataset.bound = "1"; |
|
input.addEventListener("keydown", (ev) => { |
|
if (ev.key === "Enter") loadQuote(); |
|
}); |
|
} |
|
}); |
|
|
|
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 && Object.keys(oraclePricesData.assets).length > 0) { |
|
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); |
|
}); |
|
})();
|
|
|