From d87ff1e53f7ab3523ec431dfb92abdf7ef947c99 Mon Sep 17 00:00:00 2001 From: def Date: Sun, 15 Mar 2026 01:14:50 +0000 Subject: [PATCH] Add oracle status panel to monitor frontend --- frontend/app.js | 163 ++++++++++++++++++++++++++++++++-------- frontend/styles.css | 177 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 306 insertions(+), 34 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 34e7f94..c6fcaa9 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -53,7 +53,6 @@ function iconText(kind) { /* ---------------- Formatting ---------------- */ function fmtNumber(v) { if (v == null || !isFinite(v)) return "—"; - // match backend "display" style but usable for tooltip if (v >= 1000) return v.toFixed(0); if (v >= 1) return v.toFixed(4); return v.toFixed(6); @@ -62,13 +61,31 @@ function fmtNumber(v) { function fmtDate(ms) { try { const d = new Date(ms); - // Nice short format, local timezone 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; @@ -87,7 +104,6 @@ function showTip(x, y, dateText, valueText, vsText) { const t = ensureTip(); t.children[0].textContent = dateText; - // Value line: "123.45 USD" t.children[1].innerHTML = ""; const v = document.createElement("span"); v.textContent = valueText + " "; @@ -99,7 +115,6 @@ function showTip(x, y, dateText, valueText, vsText) { t.style.display = "block"; - // keep inside viewport const pad = 12; const rect = t.getBoundingClientRect(); let left = x + 14; @@ -121,7 +136,6 @@ function hideTip() { function drawSpark(canvas, points, hoverIndex = -1) { const ctx = canvas.getContext("2d"); - // HiDPI aware sizing const cssW = canvas.clientWidth || 96; const cssH = canvas.clientHeight || 26; const dpr = window.devicePixelRatio || 1; @@ -157,7 +171,6 @@ function drawSpark(canvas, points, hoverIndex = -1) { return pad + t * (w - pad * 2); }; - // line ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.lineCap = "round"; @@ -170,7 +183,6 @@ function drawSpark(canvas, points, hoverIndex = -1) { } ctx.stroke(); - // hover dot if (hoverIndex >= 0 && hoverIndex < points.length) { const x = scaleX(hoverIndex); const y = scaleY(points[hoverIndex]); @@ -183,11 +195,9 @@ function drawSpark(canvas, points, hoverIndex = -1) { } function attachSparkHover(canvas, line) { - // Prefer spark_points (t/v pairs) for tooltip, fallback to spark values only const sp = Array.isArray(line.spark_points) ? line.spark_points : null; const points = Array.isArray(line.spark) ? line.spark : []; - // initial draw drawSpark(canvas, points, -1); let lastHover = -1; @@ -204,8 +214,7 @@ function attachSparkHover(canvas, line) { let t = (x - pad) / usableW; t = Math.max(0, Math.min(1, t)); - const idx = Math.round(t * (points.length - 1)); - return idx; + return Math.round(t * (points.length - 1)); } function onMove(ev) { @@ -217,14 +226,12 @@ function attachSparkHover(canvas, line) { drawSpark(canvas, points, idx); } - // Tooltip content 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 { - // fallback: approximate day label when = `Day ${idx + 1}`; } @@ -240,7 +247,6 @@ function attachSparkHover(canvas, line) { canvas.addEventListener("mousemove", onMove); canvas.addEventListener("mouseleave", onLeave); - // store cleanup hooks if you ever re-render frequently canvas._sparkCleanup = () => { canvas.removeEventListener("mousemove", onMove); canvas.removeEventListener("mouseleave", onLeave); @@ -285,12 +291,100 @@ function makeRow(line) { row.appendChild(center); row.appendChild(right); - // Spark render + tooltip 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 = ` +
Billing assets${billingEnabled}
+
Fresh assets${freshCount}/${assets.length}
+ `; + 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; @@ -327,59 +421,66 @@ async function refresh() { const root = document.getElementById("root"); if (!root) return; - let data; + let linesData; + let oraclePricesData; + try { - const res = await fetch("/api/lines", { cache: "no-store" }); - data = await res.json(); + 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 /api/lines"; + m.textContent = "Failed to load monitor/oracle data"; root.appendChild(m); return; } - const ok = !!data.ok; - const warnings = Array.isArray(data.errors) && data.errors.length > 0; + const ok = !!linesData.ok; + const warnings = Array.isArray(linesData.errors) && linesData.errors.length > 0; setStatus(ok, warnings); - setCycle(data.progress); + setCycle(linesData.progress); - // Clear + rebuild root.innerHTML = ""; - // Currency section + 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(data.fiat) ? data.fiat : []; + const fiat = Array.isArray(linesData.fiat) ? linesData.fiat : []; for (const line of fiat) fiatWrap.appendChild(makeRow(line)); root.appendChild(fiatWrap); - // Separator const sep = el("div", "sep"); root.appendChild(sep); - // Crypto section const cryptoTitle = el("div", "section-title"); cryptoTitle.textContent = "Cryptocurrency"; root.appendChild(cryptoTitle); const cryptoWrap = el("div", "rows"); - const crypto = Array.isArray(data.crypto) ? data.crypto : []; + const crypto = Array.isArray(linesData.crypto) ? linesData.crypto : []; for (const line of crypto) cryptoWrap.appendChild(makeRow(line)); root.appendChild(cryptoWrap); - - // If errors exist, keep them out of the UI clutter; status already says warnings. } (function boot() { window.addEventListener("DOMContentLoaded", () => { refresh(); - // keep it light: refresh view every 10s; backend rotates pricing independently setInterval(refresh, 10000); }); })(); diff --git a/frontend/styles.css b/frontend/styles.css index 5654d39..68ad9f6 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -6,6 +6,7 @@ --muted:#9ab0d1; --good:#41d17d; --warn:#ffcc66; + --bad:#ff7676; --toggle-off:#1c2a40; --toggle-on:#2f6fff; @@ -19,6 +20,7 @@ --muted:#5b6472; --good:#1f9d55; --warn:#b26a00; + --bad:#c63d3d; --toggle-off:#dfe6f2; --toggle-on:#2f6fff; @@ -126,7 +128,6 @@ body{ .subline{font-size:12px;color:var(--muted);margin-top:2px} .right{display:flex;align-items:center;justify-content:flex-end} -/* Spark canvas uses current 'color' for stroke + hover dot */ canvas.spark{ width:96px; height:26px; @@ -134,7 +135,145 @@ canvas.spark{ color: var(--muted); } -/* Theme switch (no text) */ +/* Oracle panel */ +.oracle-panel{ + display:flex; + flex-direction:column; + gap:12px; +} + +.oracle-top{ + display:flex; + justify-content:space-between; + align-items:flex-start; + gap:12px; +} + +.oracle-updated{ + font-size:12px; + color:var(--muted); +} + +.oracle-summary{ + display:flex; + align-items:center; + gap:8px; +} + +.oracle-meta{ + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:10px; +} + +.oracle-meta-item{ + border:1px solid rgba(255,255,255,.06); + border-radius:14px; + padding:10px 12px; + background: rgba(0,0,0,.12); +} +[data-theme="light"] .oracle-meta-item{ + background: rgba(255,255,255,.55); + border:1px solid rgba(0,0,0,.06); +} + +.oracle-meta-label{ + display:block; + font-size:12px; + color:var(--muted); + margin-bottom:4px; +} + +.oracle-meta-value{ + display:block; + font-size:18px; + font-weight:800; +} + +.oracle-assets{ + display:flex; + flex-direction:column; + gap:10px; +} + +.oracle-asset-row{ + display:flex; + justify-content:space-between; + align-items:center; + gap:12px; + padding:10px 12px; + border:1px solid rgba(255,255,255,.06); + border-radius:14px; + background: rgba(0,0,0,.12); +} +[data-theme="light"] .oracle-asset-row{ + background: rgba(255,255,255,.55); + border:1px solid rgba(0,0,0,.06); +} + +.oracle-asset-left{ + min-width:0; +} + +.oracle-asset-key{ + font-size:15px; + font-weight:800; +} + +.oracle-asset-sub{ + font-size:12px; + color:var(--muted); + margin-top:3px; +} + +.oracle-asset-right{ + display:flex; + flex-direction:column; + align-items:flex-end; + gap:6px; +} + +.oracle-badges{ + display:flex; + gap:6px; + flex-wrap:wrap; + justify-content:flex-end; +} + +.oracle-badge{ + display:inline-flex; + align-items:center; + justify-content:center; + font-size:11px; + font-weight:800; + padding:4px 8px; + border-radius:999px; + border:1px solid rgba(255,255,255,.10); + background: rgba(255,255,255,.04); +} +[data-theme="light"] .oracle-badge{ + border:1px solid rgba(0,0,0,.10); + background: rgba(0,0,0,.04); +} + +.badge-fresh{ + color:var(--good); +} + +.badge-stale{ + color:var(--bad); +} + +.badge-billing{ + color:var(--toggle-on); +} + +.oracle-price{ + font-size:14px; + font-weight:800; +} + +/* Theme switch */ .switch{ position:relative; display:inline-block; @@ -178,7 +317,7 @@ canvas.spark{ transform: translateY(-50%) translateX(20px); } -/* Tooltip for spark hover */ +/* Tooltip */ .spark-tip{ position:fixed; z-index:9999; @@ -209,3 +348,35 @@ canvas.spark{ color: var(--toggle-on); margin-left:4px; } + +@media (max-width: 720px){ + .top{ + flex-direction:column; + gap:10px; + } + + .top-right{ + width:100%; + justify-content:space-between; + } + + .cycle{ + max-width:220px; + } + + .row, + .oracle-asset-row{ + align-items:flex-start; + flex-direction:column; + } + + .right, + .oracle-asset-right{ + width:100%; + align-items:flex-start; + } + + .oracle-meta{ + grid-template-columns:1fr; + } +}