diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 76e831a..a0577cc 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,3 +1,10 @@ +## Update - 2026-03-22 16:00 + +- Live frontend source of truth identified as /var/www/monitor. +- Working deployment workflow established: edit repo copy in /home/def/monitor/frontend and deploy to /var/www/monitor via rsync script. +- Quote calculator behavior corrected so the amount field no longer snaps back to the default during timed refresh cycles. +- Monitor page now refreshes market/oracle data without forcibly rebuilding the active quote panel state. + # PROJECT_STATE.md Project: monitor diff --git a/README.md b/README.md index dbd4f75..f359341 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +## v1.0.1 - 2026-03-22 + +- Fixed live quote calculator so the entered CAD value no longer resets to the default during timed page refreshes. +- Changed monitor refresh behavior so the quote panel remains stable until the user explicitly updates it. +- Confirmed live frontend is served from /var/www/monitor and synced working files back into repo frontend for future deployments. + # Monitor Monitor is the pricing and oracle dashboard for `monitor.outsidethebox.top`. diff --git a/VERSION b/VERSION index 1474d00..b18d465 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.2.0 +v1.0.1 diff --git a/deploy-monitor.sh b/deploy-monitor.sh new file mode 100755 index 0000000..bab297d --- /dev/null +++ b/deploy-monitor.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +SRC="/home/def/monitor/frontend/" +DST="/var/www/monitor/" + +echo "Deploying monitor frontend..." +rsync -rlptDv --delete --no-owner --no-group "$SRC" "$DST" + +echo +echo "Deploy complete:" +ls -lah "$DST" diff --git a/frontend/app.js b/frontend/app.js index 851b263..08afabe 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,7 +1,12 @@ // /home/def/monitor/frontend/app.js function el(tag, cls) { - const n = document.createElement(tag || "div"); + 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; } @@ -86,14 +91,28 @@ function ageTextFromIso(iso) { 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 = el("div", "spark-tip"); - const date = el("div", "spark-tip-date"); - const val = el("div", "spark-tip-val"); + 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); @@ -255,16 +274,22 @@ function attachSparkHover(canvas, line) { /* ---------------- DOM builders ---------------- */ function makeRow(line) { - const row = el("div", "row"); + const row = document.createElement("div"); + row.className = "row"; - const left = el("div", "left"); - const ic = el("div", "ic"); + const left = document.createElement("div"); + left.className = "left"; + const ic = document.createElement("div"); + ic.className = "ic"; ic.textContent = iconText(line.icon); - const mid = el("div", "mid"); - const sym = el("div", "val"); + const mid = document.createElement("div"); + mid.className = "mid"; + const sym = document.createElement("div"); + sym.className = "val"; sym.textContent = line.key || line.symbol || ""; - const name = el("div", "subline"); + const name = document.createElement("div"); + name.className = "subline"; name.textContent = line.name || ""; left.appendChild(ic); @@ -272,17 +297,21 @@ function makeRow(line) { mid.appendChild(name); left.appendChild(mid); - const center = el("div", "mid"); + const center = document.createElement("div"); + center.className = "mid"; center.style.textAlign = "right"; - const value = el("div", "val"); + const value = document.createElement("div"); + value.className = "val"; value.textContent = line.display != null ? String(line.display) : fmtNumber(line.value); - const vs = el("div", "subline"); + const vs = document.createElement("div"); + vs.className = "subline"; vs.textContent = line.vs ? `in ${line.vs}` : ""; center.appendChild(value); center.appendChild(vs); - const right = el("div", "right"); + const right = document.createElement("div"); + right.className = "right"; const spark = document.createElement("canvas"); spark.className = "spark"; right.appendChild(spark); @@ -297,35 +326,48 @@ function makeRow(line) { } function makeOracleAssetRow(asset) { - const row = el("div", "oracle-asset-row"); + const row = document.createElement("div"); + row.className = "oracle-asset-row"; + + const left = document.createElement("div"); + left.className = "oracle-asset-left"; - const left = el("div", "oracle-asset-left"); - const title = el("div", "oracle-asset-key"); + const title = document.createElement("div"); + title.className = "oracle-asset-key"; title.textContent = `${asset.symbol} • ${asset.chain}`; - const sub = el("div", "oracle-asset-sub"); + + 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 = el("div", "oracle-asset-right"); - const badges = el("div", "oracle-badges"); + const right = document.createElement("div"); + right.className = "oracle-asset-right"; - const freshBadge = el("span", `oracle-badge ${asset.stale ? "badge-stale" : "badge-fresh"}`); + 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 = el("span", "oracle-badge badge-billing"); + const billingBadge = document.createElement("span"); + billingBadge.className = "oracle-badge badge-billing"; billingBadge.textContent = "billing"; badges.appendChild(billingBadge); } right.appendChild(badges); - const price = el("div", "oracle-price"); + 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 { @@ -339,23 +381,31 @@ function makeOracleAssetRow(asset) { } function makeOraclePanel(pricesData) { - const wrap = el("div", "oracle-panel"); + const wrap = document.createElement("div"); + wrap.className = "oracle-panel"; + + const top = document.createElement("div"); + top.className = "oracle-top"; - const top = el("div", "oracle-top"); + const left = document.createElement("div"); - const left = el("div"); - const title = el("div", "section-title"); + const title = document.createElement("div"); + title.className = "section-title"; title.textContent = "Oracle Status"; title.style.margin = "0 0 8px 0"; - const updated = el("div", "oracle-updated"); + 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 = el("div", "oracle-summary"); - const overall = el("span", `oracle-badge ${pricesData.status === "fresh" ? "badge-fresh" : "badge-stale"}`); + 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); @@ -369,127 +419,139 @@ function makeOraclePanel(pricesData) { const billingEnabled = assets.filter(a => a.billing_enabled).length; const freshCount = assets.filter(a => !a.stale).length; - const meta = el("div", "oracle-meta"); + const meta = document.createElement("div"); + meta.className = "oracle-meta"; meta.innerHTML = `
Billing assets${billingEnabled}
Fresh assets${freshCount}/${assets.length}
`; 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 = `
Enter an amount and load a live quote.
`; - - quoteSection.appendChild(quoteTitle); - quoteSection.appendChild(quoteForm); - quoteSection.appendChild(quoteResults); - wrap.appendChild(quoteSection); - - const list = el("div", "oracle-assets"); + const list = document.createElement("div"); + list.className = "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 = `
Enter a valid CAD amount above 0.
`; - return; - } - - results.innerHTML = `
Loading quote…
`; - - 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 = `
Quote request failed.
`; - return; - } - - const rows = data.quotes.map((q) => { - const availability = q.available ? "available" : (q.reason || "unavailable"); - const recommended = q.recommended ? `recommended` : ""; - const stale = q.available ? `live` : `${availability}`; - - return ` -
-
-
${q.symbol} • ${q.chain}
-
1 ${q.symbol} = ${q.price_cad != null ? fmtNumber(q.price_cad) : "—"} CAD
-
-
-
${recommended}${stale}
-
${q.display_amount || "—"}
-
-
- `; - }).join(""); - - results.innerHTML = ` -
-
Quoted: ${fmtDateTime(data.quoted_at)}
-
Expires: ${fmtDateTime(data.expires_at)}
-
-
${rows}
- `; - } catch (_err) { - results.innerHTML = `
Quote request failed.
`; - } - } + return wrap; +} - if (btn && !btn.dataset.bound) { - btn.dataset.bound = "1"; - btn.addEventListener("click", loadQuote); - } +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; +} - if (input && !input.dataset.bound) { - input.dataset.bound = "1"; - input.addEventListener("keydown", (ev) => { - if (ev.key === "Enter") loadQuote(); - }); +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; } @@ -525,25 +587,39 @@ function setCycle(progress) { } /* ---------------- Main render ---------------- */ -async function refresh() { +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 [linesRes, oracleRes] = await Promise.all([ + 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(); - linesData = await linesRes.json(); - oraclePricesData = await oracleRes.json(); - } catch (_e) { + if (!keepQuote && responses[2]) { + quoteData = await responses[2].json(); + } + } catch (e) { setStatus(false, false, "Fetch failed"); root.innerHTML = ""; - const m = el("div", "subline"); + const m = document.createElement("div"); + m.className = "subline"; m.textContent = "Failed to load monitor/oracle data"; root.appendChild(m); return; @@ -555,32 +631,56 @@ async function refresh() { 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 (oraclePricesData && oraclePricesData.assets && Object.keys(oraclePricesData.assets).length > 0) { - root.appendChild(makeOraclePanel(oraclePricesData)); + 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); + } - const sep0 = el("div", "sep"); + 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 = el("div", "section-title"); + const fiatTitle = document.createElement("div"); + fiatTitle.className = "section-title"; fiatTitle.textContent = "Currency"; root.appendChild(fiatTitle); - const fiatWrap = el("div", "rows"); + 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 = el("div", "sep"); + const sep = document.createElement("div"); + sep.className = "sep"; root.appendChild(sep); - const cryptoTitle = el("div", "section-title"); + const cryptoTitle = document.createElement("div"); + cryptoTitle.className = "section-title"; cryptoTitle.textContent = "Cryptocurrency"; root.appendChild(cryptoTitle); - const cryptoWrap = el("div", "rows"); + 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); @@ -588,7 +688,7 @@ async function refresh() { (function boot() { window.addEventListener("DOMContentLoaded", () => { - refresh(); - setInterval(refresh, 10000); + refresh(false); + setInterval(() => refresh(true), 10000); }); })(); diff --git a/frontend/index.html b/frontend/index.html index 0a5603e..7c161c8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -28,7 +28,8 @@
-
+
+