Browse Source

Add oracle status panel to monitor frontend

main
def 7 days ago
parent
commit
d87ff1e53f
  1. 163
      frontend/app.js
  2. 177
      frontend/styles.css

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

177
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;
}
}

Loading…
Cancel
Save