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.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
app:
|
||||
id: cuprate-ui
|
||||
name: Cuprate UI
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx
|
||||
inside a container, serves a static status dashboard, and proxies
|
||||
/cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the
|
||||
published host port for the container's 18089). No credentials are
|
||||
injected — the restricted RPC is Monero's own safe-for-public subset — so
|
||||
the nginx.conf is baked into the image and there is no rendered-config
|
||||
bind-mount like bitcoin-ui's.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/cuprate-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/cuprate-ui:local
|
||||
|
||||
dependencies:
|
||||
- app_id: cuprate
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 18091 directly on the host IP.
|
||||
# Declared so the APP GATE can see this port. Host networking means Podman
|
||||
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
|
||||
# is a statement of where the container's own nginx listens — 127.0.0.1 —
|
||||
# not a publish instruction. Without this declaration the gate would have no
|
||||
# idea the port existed: neither protected nor listed as unprotected.
|
||||
ports:
|
||||
- host: 18091
|
||||
container: 18091
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:18091
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/cuprate.svg
|
||||
category: money
|
||||
tier: optional
|
||||
author: Archipelago
|
||||
repo: https://github.com/Cuprate/cuprate
|
||||
@@ -10,6 +10,7 @@
|
||||
//! | lnd | archy-lnd-ui | wallet/channel UI |
|
||||
//! | electrumx | archy-electrs-ui | indexer status UI |
|
||||
//! | fedimint | archy-fedimint-ui | wait/proxy Guardian UI |
|
||||
//! | cuprate | archy-cuprate-ui | Monero node status UI |
|
||||
//!
|
||||
//! Lifecycle: `install` writes a Quadlet `.container` unit to
|
||||
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the
|
||||
@@ -97,6 +98,7 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
"lnd" => LND_UI,
|
||||
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
|
||||
"fedimint" | "fedimintd" => FEDIMINT_UI,
|
||||
"cuprate" => CUPRATE_UI,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -104,7 +106,8 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
/// Every companion this build knows how to provision. Kept beside
|
||||
/// `companions_for` — a new companion must be added to both, or the reaper
|
||||
/// will not recognise it as one of ours and will leave it running forever.
|
||||
const ALL_COMPANIONS: &[&[CompanionSpec]] = &[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI];
|
||||
const ALL_COMPANIONS: &[&[CompanionSpec]] =
|
||||
&[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI, CUPRATE_UI];
|
||||
|
||||
const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-bitcoin-ui",
|
||||
@@ -172,6 +175,24 @@ const FEDIMINT_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
const CUPRATE_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-cuprate-ui",
|
||||
image_base: "cuprate-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/cuprate-ui",
|
||||
"/home/archipelago/archy/docker/cuprate-ui",
|
||||
"/home/archipelago/Projects/archy/docker/cuprate-ui",
|
||||
],
|
||||
// No pre-start hook and no bind mounts: unlike bitcoin-ui there is no
|
||||
// secret to inject. Cuprate's restricted RPC (the only thing this UI
|
||||
// proxies) is unauthenticated by design — Monero's safe-for-public
|
||||
// subset — so the nginx.conf is baked into the image.
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
|
||||
Box::pin(async {
|
||||
let paths = crate::container::bitcoin_ui::RenderPaths::default();
|
||||
@@ -869,6 +890,7 @@ mod tests {
|
||||
"mempool-electrs",
|
||||
"fedimint",
|
||||
"fedimintd",
|
||||
"cuprate",
|
||||
];
|
||||
let known: std::collections::HashSet<&str> = ALL_COMPANIONS
|
||||
.iter()
|
||||
@@ -893,6 +915,7 @@ mod tests {
|
||||
names(&orphan_companions(&[])),
|
||||
vec![
|
||||
"archy-bitcoin-ui",
|
||||
"archy-cuprate-ui",
|
||||
"archy-electrs-ui",
|
||||
"archy-fedimint-ui",
|
||||
"archy-lnd-ui"
|
||||
@@ -906,7 +929,10 @@ mod tests {
|
||||
// electrumx installed, fedimint and lnd not — yet all four companions
|
||||
// were running because the reconciler was fed the manifest list.
|
||||
let orphans = orphan_companions(&ids(&["bitcoin-knots", "electrumx"]));
|
||||
assert_eq!(names(&orphans), vec!["archy-fedimint-ui", "archy-lnd-ui"]);
|
||||
assert_eq!(
|
||||
names(&orphans),
|
||||
vec!["archy-cuprate-ui", "archy-fedimint-ui", "archy-lnd-ui"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -926,12 +952,18 @@ mod tests {
|
||||
#[test]
|
||||
fn apps_without_companions_orphan_everything_and_panic_nothing() {
|
||||
let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"]));
|
||||
assert_eq!(orphans.len(), 4);
|
||||
assert_eq!(orphans.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_backend_installed_leaves_no_orphans() {
|
||||
let orphans = orphan_companions(&ids(&["bitcoin-knots", "lnd", "electrumx", "fedimint"]));
|
||||
let orphans = orphan_companions(&ids(&[
|
||||
"bitcoin-knots",
|
||||
"lnd",
|
||||
"electrumx",
|
||||
"fedimint",
|
||||
"cuprate",
|
||||
]));
|
||||
assert!(
|
||||
names(&orphans).is_empty(),
|
||||
"unexpected orphans: {:?}",
|
||||
@@ -970,7 +1002,12 @@ mod tests {
|
||||
let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE);
|
||||
assert_eq!(
|
||||
names(&due),
|
||||
vec!["archy-electrs-ui", "archy-fedimint-ui", "archy-lnd-ui"]
|
||||
vec![
|
||||
"archy-cuprate-ui",
|
||||
"archy-electrs-ui",
|
||||
"archy-fedimint-ui",
|
||||
"archy-lnd-ui"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1024,6 +1061,7 @@ mod tests {
|
||||
assert_eq!(companions_for("mempool-electrs").len(), 1);
|
||||
assert_eq!(companions_for("fedimint").len(), 1);
|
||||
assert_eq!(companions_for("fedimintd").len(), 1);
|
||||
assert_eq!(companions_for("cuprate").len(), 1);
|
||||
assert_eq!(companions_for("nextcloud").len(), 0);
|
||||
assert_eq!(companions_for("not-a-real-app").len(), 0);
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||
"bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"),
|
||||
"lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"),
|
||||
"electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"),
|
||||
"cuprate-ui" | "archy-cuprate-ui" => Some("CUPRATE_UI_IMAGE"),
|
||||
|
||||
// Mempool stack (primary = web)
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
|
||||
|
||||
@@ -6,7 +6,42 @@
|
||||
//! no listener, so allowing them is inert.
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090,
|
||||
8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434,
|
||||
18081, 18083, 23000, 32838, 50002,
|
||||
2283,
|
||||
2342,
|
||||
3000,
|
||||
3001,
|
||||
3002,
|
||||
4080,
|
||||
5180,
|
||||
7778,
|
||||
8080,
|
||||
8081,
|
||||
8082,
|
||||
8083,
|
||||
8084,
|
||||
8085,
|
||||
8087,
|
||||
8090,
|
||||
8096,
|
||||
8123,
|
||||
8175,
|
||||
8176,
|
||||
8187,
|
||||
8240,
|
||||
8334,
|
||||
8336,
|
||||
8337,
|
||||
8888,
|
||||
8999,
|
||||
9000,
|
||||
9100,
|
||||
10380,
|
||||
11434,
|
||||
18081,
|
||||
18083,
|
||||
18091,
|
||||
18091,
|
||||
23000,
|
||||
32838,
|
||||
50002,
|
||||
];
|
||||
|
||||
@@ -53,8 +53,8 @@ fn container_tier(name: &str) -> StartupTier {
|
||||
| "indeedhub-api" => StartupTier::DependentService,
|
||||
|
||||
// Tier 4: Frontend/UI
|
||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "penpot-frontend"
|
||||
| "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
|
||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "cuprate-ui"
|
||||
| "penpot-frontend" | "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
|
||||
|
||||
// Tier 3: Application layer (everything else)
|
||||
_ => StartupTier::Application,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Error</title>
|
||||
<style>
|
||||
html { color-scheme: light dark; }
|
||||
body { width: 35em; margin: 0 auto;
|
||||
font-family: Tahoma, Verdana, Arial, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>An error occurred.</h1>
|
||||
<p>Sorry, the page you are looking for is currently unavailable.<br/>
|
||||
Please try again later.</p>
|
||||
<p>If you are the system administrator of this resource then you should check
|
||||
the error log for details.</p>
|
||||
<p><em>Faithfully yours, nginx.</em></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
# Static site content.
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY 50x.html /usr/share/nginx/html/
|
||||
# Unlike bitcoin-ui, the nginx.conf is baked into the image, not
|
||||
# bind-mounted: there is no secret to render in. Cuprate's restricted RPC
|
||||
# (the only upstream this UI proxies) is unauthenticated by design —
|
||||
# Monero's safe-for-public subset — so there is nothing to substitute at
|
||||
# start time and no rotation to follow.
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
#
|
||||
# Run nginx as root to avoid chown failures in rootless Podman user
|
||||
# namespaces. The rest of the nginx image is unchanged.
|
||||
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||
/var/cache/nginx/scgi_temp
|
||||
EXPOSE 18091
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,339 @@
|
||||
<!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 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 & 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 & 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 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>
|
||||
@@ -0,0 +1,56 @@
|
||||
server {
|
||||
# Loopback ONLY — same rule as docker/bitcoin-ui and docker/electrs-ui.
|
||||
# This container is host-networked, so nginx binds the HOST's address
|
||||
# directly; a bare `listen` would expose the page on LAN, Tailscale and
|
||||
# the mesh with the app gate nowhere in front of it. Binding loopback lets
|
||||
# the daemon claim the external addresses and authenticate them;
|
||||
# see appgate::listener and apps/cuprate-ui/manifest.yml (auth: gated).
|
||||
listen 127.0.0.1:18091;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Session gate for the RPC proxy below. Internal: reachable only by
|
||||
# nginx's own auth_request subrequest, never by a client.
|
||||
location = /_session_check {
|
||||
internal;
|
||||
proxy_pass http://127.0.0.1:5678/auth/session-check;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
}
|
||||
|
||||
# Cuprate's restricted RPC (host-published on 127.0.0.1:18090, auth: open
|
||||
# — Monero's own safe-for-public subset, what remote-node wallets use).
|
||||
# It injects no credentials the caller lacks, but it is still session-
|
||||
# gated here so the whole companion behaves as one authenticated surface
|
||||
# (same defence-in-depth bitcoin-ui applies to its credential-injecting
|
||||
# proxy: loopback reaches it without the gate's challenge).
|
||||
location /cuprate-rpc/ {
|
||||
# Preflight carries no cookies by design — answer it before the gate,
|
||||
# otherwise the browser reports an opaque CORS failure instead of a 401.
|
||||
if ($request_method = OPTIONS) { return 204; }
|
||||
auth_request /_session_check;
|
||||
proxy_pass http://127.0.0.1:18090/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Access-Control-Allow-Origin $scheme://$http_host always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
add_header Vary "Origin" always;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||
}
|
||||
|
||||
# no-cache (revalidate), not no-store — same reasoning as docker/bitcoin-ui:
|
||||
# a rebuilt companion image must actually be seen by the browser, while the
|
||||
# ETag still saves the transfer when nothing changed.
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ INTERNAL_MANIFEST_IDS = {
|
||||
"archy-nbxplorer",
|
||||
"bitcoin-ui",
|
||||
"core-lightning",
|
||||
"cuprate-ui",
|
||||
"electrs-ui",
|
||||
"fips-ui",
|
||||
"lnd-ui",
|
||||
|
||||
@@ -65,6 +65,7 @@ SINGLE = {
|
||||
"bitcoin-ui": "BITCOIN_UI_IMAGE",
|
||||
"lnd-ui": "LND_UI_IMAGE",
|
||||
"electrs-ui": "ELECTRS_UI_IMAGE",
|
||||
"cuprate-ui": "CUPRATE_UI_IMAGE",
|
||||
"homeassistant": "HOMEASSISTANT_IMAGE",
|
||||
"grafana": "GRAFANA_IMAGE",
|
||||
"uptime-kuma": "UPTIME_KUMA_IMAGE",
|
||||
|
||||
@@ -126,6 +126,7 @@ IMMICH_SERVER_IMAGE="$ARCHY_REGISTRY/immich-server:release"
|
||||
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha"
|
||||
LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:1.7.123-alpha"
|
||||
ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:1.7.123-alpha"
|
||||
CUPRATE_UI_IMAGE="$ARCHY_REGISTRY/cuprate-ui:1.7.123-alpha"
|
||||
|
||||
# Base images
|
||||
NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine"
|
||||
|
||||
Reference in New Issue
Block a user