diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index a0577cc..4c8d117 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,3 +1,15 @@ +# PROJECT_STATE - Monitor + +## v1.1.0 - 2026-05-17 + +Current state: +- Monitor web UI is active for monitor.outsidethebox.top. +- Version badge is shown beside the main Monitor page title. +- OTB Oracle live quote panel is active. +- Billing-facing assets currently include USDC, ETH, ETHO, EGAZ, and ETI. +- Project version bumped to v1.1.0. +- Previous git version noted by user: v1.0.1. + ## Update - 2026-03-22 16:00 - Live frontend source of truth identified as /var/www/monitor. diff --git a/README.md b/README.md index f359341..4d8e6a0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ +# Monitor v1.1.0 + +Build date: 2026-05-17 + +## v1.1.0 changes + +- Bumped monitor project from v1.0.1 to v1.1.0. +- Added visible version badge beside the Monitor page heading. +- Updated docs for the current OTB Oracle / monitor state. +- Current monitor page includes live OTB Oracle quote display and payment asset pricing context. + ## 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. diff --git a/VERSION b/VERSION index b18d465..795460f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.1 +v1.1.0 diff --git a/frontend/app.js b/frontend/app.js index 08afabe..7b60a61 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -16,12 +16,12 @@ function applyTheme(theme) { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("theme", theme); - const toggle = document.getElementById("themeToggle"); + const toggle = document.getElementById("otbThemeToggle"); if (toggle) toggle.checked = (theme === "light"); } function toggleThemeFromCheckbox() { - const toggle = document.getElementById("themeToggle"); + const toggle = document.getElementById("otbThemeToggle"); const wantsLight = !!toggle?.checked; applyTheme(wantsLight ? "light" : "dark"); } @@ -31,7 +31,7 @@ function toggleThemeFromCheckbox() { applyTheme(saved); window.addEventListener("DOMContentLoaded", () => { - const toggle = document.getElementById("themeToggle"); + const toggle = document.getElementById("otbThemeToggle"); if (toggle) { toggle.addEventListener("change", toggleThemeFromCheckbox); toggle.checked = (document.documentElement.getAttribute("data-theme") === "light"); @@ -692,3 +692,20 @@ async function refresh(keepQuote = true) { setInterval(() => refresh(true), 10000); }); })(); + +async function loadMonitorVersionBadge() { + const badge = document.getElementById("monitor-version-badge"); + if (!badge) return; + + try { + const res = await fetch(`/VERSION?ts=${Date.now()}`, { cache: "no-store" }); + if (!res.ok) throw new Error(`VERSION fetch failed: ${res.status}`); + const version = (await res.text()).trim(); + badge.textContent = version || ""; + } catch (err) { + console.warn("Monitor version badge unavailable:", err); + badge.textContent = ""; + } +} + +document.addEventListener("DOMContentLoaded", loadMonitorVersionBadge); diff --git a/frontend/brand.js b/frontend/brand.js new file mode 100644 index 0000000..9b37576 --- /dev/null +++ b/frontend/brand.js @@ -0,0 +1,44 @@ +(function () { + const STORAGE_KEY = "otb_theme"; + const root = document.documentElement; + + function getPreferredTheme() { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved === "light" || saved === "dark") return saved; + return "dark"; + } + + function applyTheme(theme) { + root.setAttribute("data-theme", theme); + const toggle = document.getElementById("otbThemeToggle"); + if (toggle) { + toggle.checked = theme === "dark"; + } + } + + function saveTheme(theme) { + localStorage.setItem(STORAGE_KEY, theme); + } + + function initThemeToggle() { + const toggle = document.getElementById("otbThemeToggle"); + if (!toggle) return; + + toggle.addEventListener("change", function () { + const theme = toggle.checked ? "dark" : "light"; + applyTheme(theme); + saveTheme(theme); + }); + } + + function init() { + applyTheme(getPreferredTheme()); + initThemeToggle(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/frontend/favicon.png b/frontend/favicon.png new file mode 100644 index 0000000..4f0f6bf Binary files /dev/null and b/frontend/favicon.png differ diff --git a/frontend/index.html b/frontend/index.html index 7c161c8..6478dcf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,32 +7,76 @@ + +
-
Monitor
+
Monitor
7-day snapshot • rotating refresh
Loading…
- - -
- -
-
+
- +
+
+ All billing is calculated in 🇨🇦 CAD + + Crypto conversions use the OTB Oracle + + + Methods: + Credit Card (via Square), + e-Transfer, + and enabled crypto assets + +
+
+ + + diff --git a/frontend/styles.css b/frontend/styles.css index 524dec8..f027772 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1,3 +1,305 @@ +/* ===== OTB shared branding ===== */ +html[data-theme="dark"]{ + --otb-bg:#081225; + --otb-bg2:#09172d; + --otb-text:#e8eefc; + --otb-muted:#aab6d6; + --otb-panel:rgba(18,24,37,.98); + --otb-panel-soft:rgba(18,24,37,.78); + --otb-line:rgba(255,255,255,.08); +} + +html[data-theme="light"]{ + --otb-bg:#f4f7fb; + --otb-bg2:#eef3f9; + --otb-text:#0f172a; + --otb-muted:#475569; + --otb-panel:rgba(255,255,255,.98); + --otb-panel-soft:rgba(255,255,255,.88); + --otb-line:rgba(15,23,42,.10); +} + +body{ + padding-bottom:56px; +} + +.site-container{ + max-width:1100px; + margin:0 auto; + padding:20px 18px 0 18px; +} + +.site-header{ + width:100%; +} + +.site-nav{ + display:flex; + align-items:center; + justify-content:space-between; + gap:18px; + padding:12px 0 22px 0; +} + +.site-brand{ + display:flex; + align-items:center; + gap:14px; + text-decoration:none; + color:inherit; +} + +.site-brand img{ + height:60px; + width:auto; + display:block; + object-fit:contain; + background:rgba(255,255,255,0.92); + padding:6px 12px; + border-radius:999px; + box-shadow:0 8px 24px rgba(0,0,0,0.35); +} + +.site-title{ + display:flex; + flex-direction:column; + line-height:1.1; +} + +.site-title strong{ + letter-spacing:.2px; + color:var(--otb-text); +} + +.site-title span{ + color:var(--otb-muted); + font-size:13px; + margin-top:2px; +} + +.site-nav-right{ + display:flex; + align-items:center; + gap:14px; + flex-wrap:wrap; + justify-content:flex-end; +} + +.site-navlinks{ + display:flex; + gap:12px; + flex-wrap:wrap; + justify-content:flex-end; + align-items:center; +} + +.site-navlinks > a, +.dropdown-toggle{ + text-decoration:none; + padding:8px 10px; + border-radius:12px; + color:var(--otb-muted); + border:1px solid transparent; +} + +.site-navlinks > a:hover, +.dropdown-toggle:hover{ + color:var(--otb-text); + border-color:var(--otb-line); + background:rgba(255,255,255,.03); +} + +.dropdown{ + position:relative; + display:inline-block; +} + +.dropdown-toggle{ + display:inline-block; + cursor:pointer; +} + +.dropdown-menu{ + position:absolute; + top:calc(100% + 8px); + right:0; + min-width:220px; + display:none; + padding:10px; + border-radius:14px; + background:var(--otb-panel); + border:1px solid var(--otb-line); + box-shadow:0 16px 40px rgba(0,0,0,.35); + z-index:9999; +} + +.dropdown:hover .dropdown-menu, +.dropdown:focus-within .dropdown-menu{ + display:block; +} + +.dropdown-menu a{ + display:block; + padding:9px 10px; + border-radius:10px; + color:var(--otb-muted); + text-decoration:none; + white-space:nowrap; + margin:0; +} + +.dropdown-menu a + a{ + margin-top:4px; +} + +.dropdown-menu a:hover{ + color:var(--otb-text); + background:rgba(255,255,255,.04); +} + +.otb-theme-switch{ + position:relative; + display:inline-block; + width:54px; + height:30px; + flex:0 0 auto; +} + +.otb-theme-switch input{ + opacity:0; + width:0; + height:0; +} + +.otb-theme-slider{ + position:absolute; + inset:0; + cursor:pointer; + background:rgba(255,255,255,.10); + border:1px solid var(--otb-line); + transition:.2s; + border-radius:999px; +} + +.otb-theme-slider:before{ + content:""; + position:absolute; + height:22px; + width:22px; + left:3px; + top:3px; + background:var(--otb-text); + transition:.2s; + border-radius:50%; +} + +.otb-theme-switch input:checked + .otb-theme-slider:before{ + transform:translateX(24px); +} + +.otb-statusbar{ + position:fixed; + left:0; + right:0; + bottom:0; + z-index:9999; + display:flex; + align-items:center; + justify-content:center; + min-height:42px; + padding:8px 14px; + background:var(--otb-panel); + border-top:1px solid var(--otb-line); + backdrop-filter:blur(8px); + box-shadow:0 -8px 24px rgba(0,0,0,.28); +} + +.otb-statusbar-inner{ + width:100%; + max-width:1100px; + display:flex; + gap:10px; + align-items:center; + justify-content:center; + flex-wrap:wrap; + text-align:center; + color:var(--otb-muted); + font-size:12px; + line-height:1.35; +} + +.otb-statusbar strong{ + color:var(--otb-text); + font-weight:700; +} + +.otb-statusbar a{ + color:#62e6b7; + text-decoration:none; + font-weight:600; +} + +.otb-statusbar a:hover{ + text-decoration:underline; +} + +.otb-dot{ + width:6px; + height:6px; + border-radius:999px; + display:inline-block; + background:rgba(255,255,255,.25); + flex:0 0 auto; +} + +@media (max-width: 900px){ + .site-nav{ + align-items:flex-start; + flex-direction:column; + } + + .site-nav-right{ + width:100%; + justify-content:space-between; + } + + .site-navlinks{ + justify-content:flex-start; + } + + .dropdown{ + width:100%; + } + + .dropdown-toggle{ + width:100%; + } + + .dropdown-menu{ + position:static; + right:auto; + top:auto; + min-width:100%; + margin-top:6px; + } + + .site-brand img{ + height:54px; + } +} + +@media (max-width: 700px){ + body{ + padding-bottom:72px; + } + + .otb-statusbar-inner{ + font-size:11px; + line-height:1.25; + } +} + + :root{ --bg:#0b0f19; --card:#101826; @@ -381,166 +683,11 @@ canvas.spark{ } } - -/* Quote calculator */ -.oracle-quote-wrap{ - display:flex; - flex-direction:column; - gap:10px; - border:1px solid rgba(255,255,255,.06); - border-radius:14px; - padding:12px; - background: rgba(0,0,0,.12); -} -[data-theme="light"] .oracle-quote-wrap{ - background: rgba(255,255,255,.55); - border:1px solid rgba(0,0,0,.06); -} - -.oracle-quote-title{ - font-size:13px; - letter-spacing:.08em; - text-transform:uppercase; - color:var(--muted); - font-weight:800; -} - -.oracle-quote-form{ - display:grid; - grid-template-columns:minmax(0,1fr) auto; - gap:10px; - align-items:end; -} - -.oracle-quote-field{ - display:flex; - flex-direction:column; - gap:6px; -} - -.oracle-quote-label{ - font-size:12px; - color:var(--muted); -} - -.oracle-quote-input{ - width:100%; - background: rgba(255,255,255,.04); - color: var(--text); - border:1px solid rgba(255,255,255,.10); - border-radius:10px; - padding:10px 12px; - outline:none; -} -[data-theme="light"] .oracle-quote-input{ - background: rgba(0,0,0,.03); - border:1px solid rgba(0,0,0,.10); -} - -.oracle-quote-actions{ - display:flex; - align-items:end; -} - -.oracle-quote-button{ - border:1px solid rgba(255,255,255,.10); - background: var(--toggle-on); - color:#fff; - border-radius:10px; - padding:10px 14px; - font-weight:800; - cursor:pointer; -} -.oracle-quote-button:hover{ - filter:brightness(1.06); -} - -.oracle-quote-results{ - display:flex; - flex-direction:column; - gap:8px; -} - -.oracle-quote-empty{ - font-size:12px; - color:var(--muted); -} - -.oracle-quote-meta{ - display:flex; - justify-content:space-between; - gap:10px; - font-size:12px; - color:var(--muted); -} - -.oracle-quote-list{ - display:flex; - flex-direction:column; - gap:8px; -} - -.oracle-quote-row{ - display:flex; - justify-content:space-between; - align-items:center; - gap:10px; - border:1px solid rgba(255,255,255,.06); - border-radius:12px; - padding:10px 12px; - background: rgba(255,255,255,.02); -} -[data-theme="light"] .oracle-quote-row{ - background: rgba(0,0,0,.02); - border:1px solid rgba(0,0,0,.06); -} - -.oracle-quote-row-left{ - min-width:0; -} - -.oracle-quote-asset{ - font-size:14px; - font-weight:800; -} - -.oracle-quote-sub{ - font-size:12px; - color:var(--muted); - margin-top:3px; -} - -.oracle-quote-row-right{ - display:flex; - flex-direction:column; - align-items:flex-end; - gap:6px; -} - -.oracle-quote-badges{ - display:flex; - gap:6px; - flex-wrap:wrap; - justify-content:flex-end; -} - -.oracle-quote-amount{ - font-size:15px; - font-weight:900; -} - -@media (max-width: 720px){ - .oracle-quote-form{ - grid-template-columns:1fr; - } - - .oracle-quote-row, - .oracle-quote-meta{ - flex-direction:column; - align-items:flex-start; - } - - .oracle-quote-row-right{ - align-items:flex-start; - } +.monitor-version-badge { + display: inline-block; + margin-left: 0.55rem; + font-size: 0.85rem; + font-weight: 600; + opacity: 0.72; + vertical-align: middle; } diff --git a/oracle/assets.json b/oracle/assets.json index bac41ff..5bdec6c 100644 --- a/oracle/assets.json +++ b/oracle/assets.json @@ -11,8 +11,9 @@ "decimals": 6, "billing_enabled": true, "quote_priority": 1, - "primary_source": "coingecko", - "fallback_source": "static_usd" + "primary_source": "coinpaprika", + "fallback_source": "coingecko", + "coinpaprika_id": "usdc-usdc" }, "ETH_ETH": { "symbol": "ETH", @@ -22,8 +23,9 @@ "decimals": 18, "billing_enabled": true, "quote_priority": 2, - "primary_source": "coingecko", - "fallback_source": "dexscreener" + "primary_source": "coinpaprika", + "fallback_source": "coingecko", + "coinpaprika_id": "eth-ethereum" }, "ETHO_ETHO": { "symbol": "ETHO", @@ -33,8 +35,9 @@ "decimals": 18, "billing_enabled": true, "quote_priority": 3, - "primary_source": "coingecko", - "fallback_source": "local" + "primary_source": "coinpaprika", + "fallback_source": "coingecko", + "coinpaprika_id": "etho-ethoprotocol" }, "EGAZ_ETICA": { "symbol": "EGAZ", @@ -42,7 +45,7 @@ "chain": "etica", "type": "native", "decimals": 18, - "billing_enabled": false, + "billing_enabled": true, "quote_priority": 4, "primary_source": "nonkyc", "fallback_source": "local" diff --git a/oracle/fetch_prices.js b/oracle/fetch_prices.js index 13b08f3..256b283 100644 --- a/oracle/fetch_prices.js +++ b/oracle/fetch_prices.js @@ -2,12 +2,13 @@ const { updateCachedPrice, loadCache } = require('./price_engine'); +const { fetchPair } = require('../backend/providers/klingex'); async function fetchJson(url) { const res = await fetch(url, { headers: { 'accept': 'application/json', - 'user-agent': 'otb-oracle/0.2' + 'user-agent': 'otb-oracle/0.3' } }); @@ -18,16 +19,10 @@ async function fetchJson(url) { return res.json(); } -async function fetchCoinGeckoSimplePrice() { - const ids = [ - 'usd-coin', - 'ethereum', - 'ether-1', - 'etica' - ].join(','); - +async function fetchCoinGeckoSimplePrice(ids) { + const joined = Array.isArray(ids) ? ids.join(',') : String(ids || ''); const url = - `https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent(ids)}&vs_currencies=usd,cad`; + `https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent(joined)}&vs_currencies=usd,cad`; return fetchJson(url); } @@ -37,16 +32,23 @@ async function fetchCoinPaprikaTicker(coinId) { return fetchJson(url); } -function getCadPerUsd(coingeckoData) { - if (coingeckoData?.['usd-coin']?.cad && Number.isFinite(Number(coingeckoData['usd-coin'].cad))) { - return Number(coingeckoData['usd-coin'].cad); - } +function hasFinitePrice(value) { + return value !== null && value !== undefined && Number.isFinite(Number(value)) && Number(value) > 0; +} +function getCachedCadPerUsd() { try { const cache = loadCache(); - const cachedCad = cache?.assets?.USDC_ARB?.price_cad; - if (cachedCad && Number.isFinite(Number(cachedCad))) { - return Number(cachedCad); + const usdc = cache?.assets?.USDC_ARB || {}; + const usd = Number(usdc.price_usd); + const cad = Number(usdc.price_cad); + + if (hasFinitePrice(usd) && hasFinitePrice(cad)) { + return cad / usd; + } + + if (hasFinitePrice(cad)) { + return cad; } } catch (_) { } @@ -54,11 +56,7 @@ function getCadPerUsd(coingeckoData) { return 1.38; } -function hasFinitePrice(value) { - return value !== null && value !== undefined && Number.isFinite(Number(value)) && Number(value) > 0; -} - -function maybeUpdateFromCoinGecko(pairKey, cgKey, sourceData, now) { +function maybeUpdateFromCoinGecko(pairKey, sourceData, now, sourceStatus = 'primary') { if (!sourceData || !hasFinitePrice(sourceData.usd) || !hasFinitePrice(sourceData.cad)) { return false; } @@ -67,14 +65,14 @@ function maybeUpdateFromCoinGecko(pairKey, cgKey, sourceData, now) { price_usd: Number(sourceData.usd), price_cad: Number(sourceData.cad), source: 'coingecko', - source_status: 'primary', + source_status: sourceStatus, updated_at: now }); return true; } -function maybeUpdateFromCoinPaprika(pairKey, tickerData, cadPerUsd, now) { +function maybeUpdateFromCoinPaprika(pairKey, tickerData, cadPerUsd, now, sourceStatus = 'primary') { const usd = Number(tickerData?.quotes?.USD?.price); if (!hasFinitePrice(usd)) { @@ -87,74 +85,85 @@ function maybeUpdateFromCoinPaprika(pairKey, tickerData, cadPerUsd, now) { price_usd: usd, price_cad: cad, source: 'coinpaprika', - source_status: 'fallback', + source_status: sourceStatus, updated_at: now }); return true; } -async function main() { - const now = new Date().toISOString(); - const updatedPairs = []; - - let cgData = {}; +async function updateFromPaprika(pairKey, coinId, cadPerUsd, now, updatedPairs, sourceStatus = 'primary') { try { - cgData = await fetchCoinGeckoSimplePrice(); + const ticker = await fetchCoinPaprikaTicker(coinId); + if (maybeUpdateFromCoinPaprika(pairKey, ticker, cadPerUsd, now, sourceStatus)) { + updatedPairs.push(pairKey); + return true; + } } catch (err) { - console.error(`CoinGecko fetch failed: ${err.message}`); - cgData = {}; + console.error(`CoinPaprika ${pairKey} fetch failed: ${err.message}`); } - const cadPerUsd = getCadPerUsd(cgData); - - if (maybeUpdateFromCoinGecko('USDC_ARB', 'usd-coin', cgData['usd-coin'], now)) { - updatedPairs.push('USDC_ARB'); - } + return false; +} - if (maybeUpdateFromCoinGecko('ETH_ETH', 'ethereum', cgData['ethereum'], now)) { - updatedPairs.push('ETH_ETH'); - } +async function updateETHOFromKlingEx(now, updatedPairs, cadPerUsd) { + try { + const r = await fetchPair('ETHO-USDT'); + const usd = Number(r.price); - let ethoUpdated = maybeUpdateFromCoinGecko('ETHO_ETHO', 'ether-1', cgData['ether-1'], now); - if (!ethoUpdated) { - try { - const pap = await fetchCoinPaprikaTicker('etho-ethoprotocol'); - if (maybeUpdateFromCoinPaprika('ETHO_ETHO', pap, cadPerUsd, now)) { - updatedPairs.push('ETHO_ETHO'); - ethoUpdated = true; - } - } catch (err) { - console.error(`Coinpaprika ETHO fallback failed: ${err.message}`); + if (!hasFinitePrice(usd)) { + throw new Error(`KlingEx returned non-usable ETHO price: ${r.price}`); } - } else { - updatedPairs.push('ETHO_ETHO'); - } - let etiUpdated = maybeUpdateFromCoinGecko('ETI_ETICA', 'etica', cgData['etica'], now); - if (!etiUpdated) { - try { - const pap = await fetchCoinPaprikaTicker('eti-etica'); - if (maybeUpdateFromCoinPaprika('ETI_ETICA', pap, cadPerUsd, now)) { - updatedPairs.push('ETI_ETICA'); - etiUpdated = true; - } - } catch (err) { - console.error(`Coinpaprika ETI fallback failed: ${err.message}`); - } - } else { - updatedPairs.push('ETI_ETICA'); + const cad = Number((usd * cadPerUsd).toFixed(8)); + + updateCachedPrice('ETHO_ETHO', { + price_usd: usd, + price_cad: cad, + source: r.source || 'klingex', + source_status: 'primary', + updated_at: now + }); + + updatedPairs.push('ETHO_ETHO'); + return true; + } catch (err) { + console.error(`KlingEx ETHO fetch failed: ${err.message}`); } + // Last-resort fallback only. try { - const papEgaz = await fetchCoinPaprikaTicker('egaz-egaz'); - if (maybeUpdateFromCoinPaprika('EGAZ_ETICA', papEgaz, cadPerUsd, now)) { - updatedPairs.push('EGAZ_ETICA'); + const cgData = await fetchCoinGeckoSimplePrice(['ether-1']); + if (maybeUpdateFromCoinGecko('ETHO_ETHO', cgData['ether-1'], now, 'fallback')) { + updatedPairs.push('ETHO_ETHO'); + return true; } + + console.error('CoinGecko ETHO fallback returned no usable ether-1 price'); } catch (err) { - console.error(`Coinpaprika EGAZ fetch failed: ${err.message}`); + console.error(`CoinGecko ETHO fallback failed: ${err.message}`); } + return false; +} + +async function main() { + const now = new Date().toISOString(); + const updatedPairs = []; + let cadPerUsd = getCachedCadPerUsd(); + + await updateFromPaprika('USDC_ARB', 'usdc-usd-coin', cadPerUsd, now, updatedPairs, 'primary'); + + cadPerUsd = getCachedCadPerUsd(); + + await updateFromPaprika('ETH_ETH', 'eth-ethereum', cadPerUsd, now, updatedPairs, 'primary'); + + // ETHO uses KlingEx primary; CoinGecko only as last fallback. + await updateETHOFromKlingEx(now, updatedPairs, cadPerUsd); + + await updateFromPaprika('ETI_ETICA', 'eti-etica', cadPerUsd, now, updatedPairs, 'fallback'); + await updateFromPaprika('EGAZ_ETICA', 'egaz-egaz', cadPerUsd, now, updatedPairs, 'fallback'); + console.log(JSON.stringify({ ok: true, fetched_at: now, diff --git a/package.json b/package.json index 23d36f7..78dc8e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "monitor", - "version": "1.0.0", + "version": "1.1.0", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1"