Files
archy/docker/cuprate-ui/index.html
T
ssmithx 37fde6cfa7 feat(cuprate-ui): companion dashboard for the Cuprate Monero node
Same companion shape as bitcoin-ui/electrs-ui: host-networked nginx
bound to 127.0.0.1:18091 (auth: gated + session_passthrough), serving
a dark glass status page that polls the node's restricted RPC via a
session-gated /cuprate-rpc/ proxy — sync height/target with progress
bar, peers, mempool, chain size and free disk (from get_info), plus a
wallet 'remote node' endpoint. The offline state explains the disk gate
so a refused node says why.

No secret rendering: the restricted RPC is Monero's safe-for-public
subset, so nginx.conf is baked into the image (no pre_start hook, no
bind mount). companion.rs auto-provisions archy-cuprate-ui alongside
cuprate and reaps it when cuprate goes.

Catalog regenerated (cuprate-ui entry + manifest embed, 18091 into the
mesh launch-port list). NOTE: releases/app-catalog.json is UNSIGNED as
committed — run scripts/sign-catalog.sh before publishing.
2026-09-09 07:49:18 +00:00

340 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Cuprate Node - Archipelago</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
min-height: 100vh;
background: #000;
color: white;
overflow-x: hidden;
}
.bg-layer {
position: fixed;
inset: 0;
z-index: -10;
background:
radial-gradient(1200px 600px at 80% -10%, rgba(247, 147, 26, 0.16), transparent 60%),
radial-gradient(900px 500px at 10% 110%, rgba(112, 76, 240, 0.12), transparent 60%),
#000;
}
.glass-card {
position: relative;
background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
border-radius: 1rem;
padding: 1.5rem;
}
.glass-button {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.9);
border-radius: 0.5rem;
padding: 0.4rem 0.9rem;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.3s ease;
}
.glass-button:hover { color: white; background-color: rgba(0, 0, 0, 0.7); }
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem 3rem; }
header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; }
header h1 .accent { color: #f7931a; }
.sub { color: rgba(255, 255, 255, 0.55); font-size: 0.85rem; margin-top: 0.2rem; }
.pill {
display: inline-flex; align-items: center; gap: 0.45rem;
padding: 0.35rem 0.8rem; border-radius: 999px;
font-size: 0.78rem; font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.06);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
.pill.online .dot { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.pill.syncing .dot { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
.pill.offline .dot { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
.card-title {
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.12em;
text-transform: uppercase; color: rgba(255, 255, 255, 0.5);
margin-bottom: 1rem;
}
.stat { display: flex; justify-content: space-between; align-items: baseline; padding: 0.45rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
.stat:last-child { border-bottom: none; }
.stat .label { color: rgba(255, 255, 255, 0.55); font-size: 0.82rem; }
.stat .value { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.95rem; text-align: right; }
.stat .value.warn { color: #f7931a; }
.stat .value.err { color: #ef4444; }
.stat .value.ok { color: #22c55e; }
.height-hero { display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 0.75rem; }
.height-hero .big { font-size: 2.4rem; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.height-hero .of { color: rgba(255, 255, 255, 0.45); font-size: 1rem; font-variant-numeric: tabular-nums; }
.progress { height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; margin: 0.5rem 0 0.35rem; }
.progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, #f7931a, #ffc46b); transition: width 0.6s ease; }
.progress-label { font-size: 0.75rem; color: rgba(255, 255, 255, 0.55); font-variant-numeric: tabular-nums; }
.notice {
margin-top: 1rem; padding: 0.9rem 1.1rem; border-radius: 0.75rem;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.35);
font-size: 0.82rem; line-height: 1.5; color: rgba(255, 255, 255, 0.8);
}
.notice.error { background: rgba(239, 68, 68, 0.08); border-color: rgba(239, 68, 68, 0.4); }
.notice b { color: white; }
.endpoint {
display: flex; align-items: center; gap: 0.6rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.5rem; padding: 0.55rem 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.82rem; overflow-x: auto; white-space: nowrap;
}
.hint { font-size: 0.75rem; color: rgba(255, 255, 255, 0.45); margin-top: 0.6rem; line-height: 1.5; }
footer { margin-top: 2rem; text-align: center; color: rgba(255, 255, 255, 0.35); font-size: 0.72rem; }
</style>
</head>
<body>
<div class="bg-layer"></div>
<div class="wrap">
<header>
<div>
<h1><span class="accent">Cuprate</span> Monero Node</h1>
<div class="sub">Rust implementation of the Monero protocol, on Archipelago</div>
</div>
<span id="statusPill" class="pill"><span class="dot"></span><span id="statusText">Connecting…</span></span>
</header>
<div id="offlineNotice" class="notice error" style="display:none;">
<b>Cuprate is not reachable.</b> The node is stopped, still installing, or was refused
by the disk-space gate — a Monero node cannot run pruned, so Archipelago keeps it off
disks too small to hold the full chain (~250&nbsp;GB and growing) rather than let it fill
the drive. Attach a larger disk and start it again.
</div>
<div class="grid" style="margin-top:1rem;">
<div class="glass-card">
<div class="card-title">Blockchain Sync</div>
<div class="height-hero">
<span class="big" id="height">—</span>
<span class="of" id="targetOf">of —</span>
</div>
<div class="progress"><div class="progress-fill" id="syncBar"></div></div>
<div class="progress-label" id="syncLabel">Waiting for node…</div>
<div class="stat"><span class="label">Network</span><span class="value" id="nettype">—</span></div>
<div class="stat"><span class="label">Uptime</span><span class="value" id="uptime">—</span></div>
<div class="stat"><span class="label">Node time</span><span class="value" id="nodeTime">—</span></div>
</div>
<div class="glass-card">
<div class="card-title">Peers &amp; Traffic</div>
<div class="stat"><span class="label">Outgoing connections</span><span class="value" id="outConns">—</span></div>
<div class="stat"><span class="label">Incoming connections</span><span class="value" id="inConns">—</span></div>
<div class="stat"><span class="label">RPC connections</span><span class="value" id="rpcConns">—</span></div>
<div class="stat"><span class="label">Known peers (white)</span><span class="value" id="whitePeers">—</span></div>
<div class="stat"><span class="label">Known peers (gray)</span><span class="value" id="grayPeers">—</span></div>
<div class="stat"><span class="label">Mempool transactions</span><span class="value" id="txPool">—</span></div>
<div class="stat"><span class="label">Alt blocks</span><span class="value" id="altBlocks">—</span></div>
</div>
<div class="glass-card">
<div class="card-title">Chain &amp; Disk</div>
<div class="stat"><span class="label">Difficulty</span><span class="value" id="difficulty">—</span></div>
<div class="stat"><span class="label">Chain size</span><span class="value" id="chainSize">—</span></div>
<div class="stat"><span class="label">Free disk</span><span class="value" id="freeSpace">—</span></div>
<div class="notice">
<b>Monero has no pruned mode.</b> Unlike the Bitcoin apps on this node — which
drop old block data automatically when disk is scarce — a cuprate node stores the
full chain. Archipelago therefore only runs it on disks of 450&nbsp;GB or more and
refuses it (with this explanation) anywhere smaller.
</div>
</div>
<div class="glass-card">
<div class="card-title">Connect a Wallet</div>
<div class="endpoint">
<span id="walletEndpoint">—</span>
<button class="glass-button" onclick="copyEndpoint(this)">Copy</button>
</div>
<div class="hint">
Restricted RPC — Monero's own safe-for-public subset, what Feather,
monero-wallet-rpc and the GUI use for a “remote node”. Full (unrestricted) RPC
stays container-loopback only and is never published.
</div>
<div class="stat" style="margin-top:0.9rem;"><span class="label">P2P port</span><span class="value" id="p2pPort">18183</span></div>
<div class="stat"><span class="label">Restricted RPC port</span><span class="value">18090</span></div>
</div>
</div>
<footer>
Cuprate is work-in-progress software; it independently validates Monero consensus rules.
Data served from this node's restricted RPC, refreshed every 15 seconds.
</footer>
</div>
<script>
const RPC = 'cuprate-rpc/';
const POLL_MS = 15000;
function tabular(n) { return Number(n || 0).toLocaleString('en-US'); }
function formatBytes(bytes) {
const n = Number(bytes);
if (!Number.isFinite(n) || n <= 0) return '—';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let v = n, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
return `${v >= 100 || i === 0 ? tabular(Math.round(v)) : v.toFixed(1)} ${units[i]}`;
}
function formatUptime(secs) {
const s = Number(secs);
if (!Number.isFinite(s) || s < 0) return '—';
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function setStat(id, value, cls) {
const el = document.getElementById(id);
el.textContent = (value === null || value === undefined || value === '') ? '—' : value;
el.className = 'value' + (cls ? ' ' + cls : '');
}
function setStatus(kind, text) {
const pill = document.getElementById('statusPill');
pill.className = 'pill ' + kind;
document.getElementById('statusText').textContent = text;
document.getElementById('offlineNotice').style.display = (kind === 'offline') ? '' : 'none';
}
async function callRpc(endpoint) {
const response = await fetch(RPC + endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
cache: 'no-store',
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data && data.error) throw new Error(data.error);
return data;
}
function render(info, heightFallback) {
const height = info.height ?? heightFallback ?? 0;
const target = info.target_height ?? info.target ?? height;
document.getElementById('height').textContent = tabular(height);
document.getElementById('targetOf').textContent = `of ${tabular(target)}`;
const pct = target > 0 ? Math.min(100, (height / target) * 100) : 0;
document.getElementById('syncBar').style.width = pct.toFixed(2) + '%';
const synced = target > 0 && height >= target;
if (synced) {
document.getElementById('syncLabel').textContent = 'Fully synced';
setStatus('online', 'Synced');
} else {
const behind = Math.max(0, target - height);
document.getElementById('syncLabel').textContent =
`${pct.toFixed(2)}% — ${tabular(behind)} blocks behind`;
setStatus('syncing', 'Syncing');
}
const nettype = info.mainnet ? 'Mainnet'
: info.testnet ? 'Testnet'
: info.stagenet ? 'Stagenet'
: (info.net || info.nettype || '—');
setStat('nettype', nettype);
const nowSecs = info.time ?? info.adjusted_time ?? Math.floor(Date.now() / 1000);
const started = info.start_time ?? info.startup_time;
setStat('uptime', started ? formatUptime(nowSecs - started) : '—');
setStat('nodeTime', new Date(nowSecs * 1000).toLocaleString());
setStat('outConns', tabular(info.outgoing_connections_count));
setStat('inConns', tabular(info.incoming_connections_count));
setStat('rpcConns', tabular(info.rpc_connections_count));
setStat('whitePeers', tabular(info.white_peerlist_size));
setStat('grayPeers', tabular(info.grey_peerlist_size));
setStat('txPool', tabular(info.tx_pool_size));
const alt = Number(info.alt_blocks_count || 0);
setStat('altBlocks', tabular(alt), alt > 0 ? 'warn' : undefined);
setStat('difficulty', tabular(info.difficulty));
setStat('chainSize', formatBytes(info.blocks_size ?? info.block_sizes?.[0]));
const free = info.free_space;
const freeWarn = Number.isFinite(free) && free > 0 && free < 50 * 1024 * 1024 * 1024;
setStat('freeSpace', formatBytes(free), freeWarn ? 'warn' : undefined);
if (!document.getElementById('walletEndpoint').textContent.includes(':')) {
document.getElementById('walletEndpoint').textContent =
`${window.location.hostname}:18090`;
}
}
async function refresh() {
let height = null;
try {
const h = await callRpc('get_height');
height = h.height;
} catch { /* get_info below carries the real error */ }
try {
const info = await callRpc('get_info');
render(info, height);
} catch {
setStatus('offline', 'Node offline');
if (height !== null) document.getElementById('height').textContent = tabular(height);
}
}
function copyEndpoint(btn) {
const text = document.getElementById('walletEndpoint').textContent;
const done = () => { btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = 'Copy'; }, 1500); };
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done).catch(() => { done(); });
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch { /* best effort */ }
document.body.removeChild(ta);
done();
}
}
// Height alone answers even while get_info is warming up; if both
// fail the offline card explains the disk gate as a likely cause.
refresh();
setInterval(refresh, POLL_MS);
</script>
</body>
</html>