Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00682e6420 | ||
|
|
95cdc3daea | ||
|
|
1d05f2c27a | ||
|
|
b3f16d07a6 | ||
|
|
14d2b37e99 | ||
|
|
f5b255ee68 | ||
|
|
6e8d90fb5f | ||
|
|
66c4b0d375 | ||
|
|
0f74ebfbbe | ||
|
|
ee11863ada | ||
|
|
86052d9552 | ||
|
|
047ef98987 | ||
|
|
c681472e15 | ||
|
|
7c0ba14a00 | ||
|
|
eacd74e1db | ||
|
|
34b68001d1 | ||
|
|
0fac51b9c5 | ||
|
|
4f0d123f27 | ||
|
|
13b1329c21 | ||
|
|
c4aa72dccc | ||
|
|
d35474f774 | ||
|
|
a03f340bd1 | ||
|
|
caaa2e729e |
@@ -26,7 +26,7 @@
|
||||
"headline": "Your node. Your source.",
|
||||
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
|
||||
"tag": "NGIT // NOSTR // NO SILO",
|
||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy",
|
||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
|
||||
"launchLabel": "Open GitWorkshop",
|
||||
"installLabel": "Install GitWorkshop",
|
||||
"detailsLabel": "How contribution works →"
|
||||
|
||||
@@ -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
|
||||
@@ -36,12 +36,26 @@ app:
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
|
||||
# month; cuprated's pruning support is not confirmed stable yet (the
|
||||
# `pruning` crate exists in the workspace but nothing in this config
|
||||
# surface toggles it), so this sizes for a full unpruned chain plus
|
||||
# headroom rather than assuming pruning is available.
|
||||
- storage: 300Gi
|
||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year.
|
||||
# Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09):
|
||||
# cuprated has NO on-disk pruning setting of any kind — the `pruning`
|
||||
# crate in its workspace is Monero's p2p *protocol* pruning, not a
|
||||
# smaller chain — so unlike bitcoin-knots this app CANNOT self-prune
|
||||
# when disk is scarce (see the DISK_GB branch in
|
||||
# apps/bitcoin-knots/manifest.yml). Left running on a too-small disk it
|
||||
# syncs until the filesystem fills and takes Archipelago down. The
|
||||
# disk-scarce equivalent is enforced in Rust instead: install, start,
|
||||
# restart and update refuse, and boot reconcile skips, on any node under
|
||||
# CUPRATE_MIN_DISK_GB (450GB — chain + headroom; refuses the 250GB VPS
|
||||
# class, allows 500GB-class disks). If upstream ever ships a prune flag,
|
||||
# replace that gate with the bitcoin-style entrypoint branch.
|
||||
#
|
||||
# 450Gi, not the chain size (~250GiB): every manifest-driven surface
|
||||
# (store size display, install pre-checks, docs) must show the number the
|
||||
# Rust gate actually enforces, or a user provisioned to the displayed
|
||||
# value gets refused at a different, unexplained one. Single source of
|
||||
# truth is crate::constants::CUPRATE_MIN_DISK_GB — keep in lockstep.
|
||||
- storage: 450Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
@@ -51,7 +65,9 @@ app:
|
||||
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
|
||||
# headroom above the 8GiB cache for the process itself.
|
||||
memory_limit: 10Gi
|
||||
disk_limit: 300Gi
|
||||
# Matches the storage dependency above (= the enforced disk floor),
|
||||
# not the raw chain size — see the CUPRATE_MIN_DISK_GB note.
|
||||
disk_limit: 450Gi
|
||||
|
||||
security:
|
||||
# FROM scratch, no package manager/shell, ownership fixed at build time
|
||||
|
||||
@@ -55,6 +55,10 @@ impl RpcHandler {
|
||||
"did": id.did,
|
||||
"created_at": id.created_at,
|
||||
"is_default": is_default,
|
||||
// The node's operational Nostr key is intentionally
|
||||
// distinguishable from user profile identities. Clients
|
||||
// must never offer it in app sign-in pickers.
|
||||
"is_node": is_node,
|
||||
"nostr_pubkey": nostr_pubkey,
|
||||
"nostr_npub": nostr_npub,
|
||||
"profile": id.profile,
|
||||
|
||||
@@ -55,6 +55,7 @@ impl RpcHandler {
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
|
||||
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
|
||||
|
||||
// Reject if already in a transitional lifecycle (prevents double-click
|
||||
// queuing two installs on the same package).
|
||||
@@ -294,6 +295,12 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
// Update is stop → pull → remove → recreate, i.e. a fresh start by
|
||||
// another name: on a disk that shrank since install it would resume
|
||||
// cuprate's unprunable sync unchecked. Same gate as install and
|
||||
// start, run BEFORE the Updating flip so a refusal leaves the app
|
||||
// cleanly in its previous state.
|
||||
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
|
||||
|
||||
// Reject if already in a transitional lifecycle.
|
||||
{
|
||||
|
||||
@@ -670,6 +670,50 @@ async fn detect_disk_gb() -> u64 {
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Smallest disk (GB, total) a cuprate node can live on. The value and its
|
||||
/// rationale live in ONE place — `crate::constants::CUPRATE_MIN_DISK_GB` —
|
||||
/// shared with the boot reconciler so install/start and boot can never
|
||||
/// disagree about where cuprate may run.
|
||||
use crate::constants::CUPRATE_MIN_DISK_GB;
|
||||
|
||||
/// The bitcoin apps pick `-prune` automatically when disk is scarce, because
|
||||
/// bitcoind supports pruning. Cuprate CANNOT: upstream has no pruning config
|
||||
/// at all (the `pruning` crate in its workspace is Monero's p2p *protocol*
|
||||
/// pruning, not on-disk pruning), so the disk-scarce equivalent is to refuse
|
||||
/// to run cuprate at all rather than let it sync until the filesystem fills —
|
||||
/// which took Archipelago itself down on nodes with too little disk.
|
||||
fn cuprate_insufficient_disk_message(disk_gb: u64) -> String {
|
||||
format!(
|
||||
"Cuprate needs a disk of at least {} GB and this node has {} GB. \
|
||||
A Monero node cannot run pruned — upstream cuprate has no pruning \
|
||||
support — so the chain (~250 GB and growing) would fill the disk and \
|
||||
take Archipelago down with it. Attach a larger disk (or move \
|
||||
/var/lib/archipelago to one) and try again. Bitcoin apps CAN run \
|
||||
pruned on smaller disks; Monero currently cannot.",
|
||||
CUPRATE_MIN_DISK_GB, disk_gb
|
||||
)
|
||||
}
|
||||
|
||||
/// Pure decision half of the cuprate disk gate — testable without df.
|
||||
pub(super) fn cuprate_disk_gate(disk_gb: u64) -> Option<String> {
|
||||
(disk_gb < CUPRATE_MIN_DISK_GB).then(|| cuprate_insufficient_disk_message(disk_gb))
|
||||
}
|
||||
|
||||
/// Install/start-time pre-check: refuse cuprate on disks too small to hold
|
||||
/// the Monero chain. Mirrors `check_bitcoin_pruning_compatibility`'s
|
||||
/// fail-open-on-unknown-disk behaviour (`detect_disk_gb` returns u64::MAX
|
||||
/// when df fails, so an unreadable disk never blocks an install).
|
||||
pub(super) async fn check_cuprate_disk_compatibility(package_id: &str) -> Result<()> {
|
||||
if package_id != "cuprate" {
|
||||
return Ok(());
|
||||
}
|
||||
let disk_gb = detect_disk_gb().await;
|
||||
if let Some(message) = cuprate_disk_gate(disk_gb) {
|
||||
anyhow::bail!(message);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Log informational messages about optional dependencies.
|
||||
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
||||
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
||||
@@ -873,9 +917,9 @@ pub(super) fn configure_fedimint_lnd(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
bitcoin_is_warming_up, dependency_list_declares_archival_bitcoin,
|
||||
bitcoin_is_warming_up, cuprate_disk_gate, dependency_list_declares_archival_bitcoin,
|
||||
manifest_declares_archival_bitcoin, order_present_containers, requires_unpruned_bitcoin,
|
||||
startup_order, BITCOIN_WARMUP_BUDGET,
|
||||
startup_order, BITCOIN_WARMUP_BUDGET, CUPRATE_MIN_DISK_GB,
|
||||
};
|
||||
use archipelago_container::Dependency;
|
||||
|
||||
@@ -1017,6 +1061,37 @@ mod tests {
|
||||
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuprate_disk_gate_refuses_disks_too_small_for_the_monero_chain() {
|
||||
// 250 GB VPS class: the ~250 GiB chain does not fit, full stop.
|
||||
assert!(cuprate_disk_gate(0).is_some());
|
||||
assert!(cuprate_disk_gate(250).is_some());
|
||||
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB - 1).is_some());
|
||||
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB).is_none());
|
||||
assert!(cuprate_disk_gate(1000).is_none());
|
||||
// df failure reads as u64::MAX — an unreadable disk must not block.
|
||||
assert!(cuprate_disk_gate(u64::MAX).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuprate_disk_gate_message_names_the_fix_not_just_the_problem() {
|
||||
let msg = cuprate_disk_gate(250).expect("250 GB must be refused");
|
||||
assert!(msg.contains("cannot run pruned"), "{msg}");
|
||||
assert!(msg.contains("larger disk"), "{msg}");
|
||||
assert!(msg.contains("250 GB"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cuprate_disk_gate_only_applies_to_cuprate() {
|
||||
// Every other package passes regardless of disk — including the
|
||||
// bitcoin apps, which self-prune via their manifest entrypoint.
|
||||
for package_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
|
||||
super::check_cuprate_disk_compatibility(package_id)
|
||||
.await
|
||||
.expect("non-cuprate installs must not be gated here");
|
||||
}
|
||||
}
|
||||
|
||||
mod dep_wait {
|
||||
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
@@ -3,10 +3,10 @@ use super::config::{
|
||||
is_readonly_compatible, is_valid_docker_image,
|
||||
};
|
||||
use super::dependencies::{
|
||||
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers,
|
||||
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
|
||||
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL,
|
||||
DEP_WAIT_MAX_ATTEMPTS,
|
||||
check_bitcoin_pruning_compatibility, check_cuprate_disk_compatibility, configure_fedimint_lnd,
|
||||
detect_existing_containers, detect_running_deps, detect_running_deps_from_package_data,
|
||||
log_optional_dep_info, needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps,
|
||||
DEP_WAIT_INTERVAL, DEP_WAIT_MAX_ATTEMPTS,
|
||||
};
|
||||
use super::progress::parse_pull_progress;
|
||||
use super::validation::validate_app_id;
|
||||
@@ -374,6 +374,7 @@ impl RpcHandler {
|
||||
// failing instantly.
|
||||
let deps = self.gate_install_deps(package_id).await?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
check_cuprate_disk_compatibility(package_id).await?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
// Materialise the RPC password file before any install path
|
||||
|
||||
@@ -60,6 +60,12 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
// A cuprate node that starts on a too-small disk fills it and takes
|
||||
// Archipelago down with it (no upstream pruning — see
|
||||
// dependencies::check_cuprate_disk_compatibility). Fail the start
|
||||
// before clearing user-stopped or flipping state, so the app stays
|
||||
// cleanly stopped and the error carries the actionable message.
|
||||
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
|
||||
|
||||
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
|
||||
vec![orchestrator_app_id(package_id).to_string()]
|
||||
@@ -251,6 +257,11 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
// Restart is stop + recreate, so on a disk that shrank below the cuprate
|
||||
// minimum after install it resumes the doomed unprunable sync just like
|
||||
// start would — same gate, same "fail before clearing user-stopped /
|
||||
// flipping state" contract (see handle_package_start).
|
||||
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
|
||||
|
||||
let single_orchestrator_app =
|
||||
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
|
||||
|
||||
@@ -9,3 +9,19 @@ pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
|
||||
|
||||
/// Tor SOCKS5 proxy for outbound onion connections.
|
||||
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
|
||||
|
||||
/// Smallest disk (GB, total) a cuprate node may be installed, started,
|
||||
/// restarted, updated, or boot-reconciled onto. Cuprate has no on-disk
|
||||
/// pruning (verified against upstream `cuprated/src/config.rs` — the
|
||||
/// `pruning` crate is Monero's p2p protocol pruning), so unlike the bitcoin
|
||||
/// apps it cannot self-shrink on a scarce disk; below this line the ~250 GiB
|
||||
/// Monero chain simply does not fit and running it would fill the filesystem
|
||||
/// and take Archipelago down. 450 = chain + growth/headroom: allows
|
||||
/// 500 GB-class disks, refuses the 250 GB VPS class.
|
||||
///
|
||||
/// SINGLE SOURCE OF TRUTH — the RPC gates
|
||||
/// (`api::rpc::package::dependencies`) and the boot reconciler
|
||||
/// (`container::prod_orchestrator`) both read this; a drift between them
|
||||
/// would silently reopen the disk-fill failure the gate exists to close.
|
||||
/// Keep `apps/cuprate/manifest.yml` (storage dependency + comments) aligned.
|
||||
pub const CUPRATE_MIN_DISK_GB: u64 = 450;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -47,8 +47,17 @@ use crate::update::host_sudo;
|
||||
///
|
||||
/// Keep in sync with the running fixture on .116. Centralized as a constant
|
||||
/// so the rule is visible in one place and unit-testable.
|
||||
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"];
|
||||
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui", "cuprate-ui"];
|
||||
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
|
||||
// The cuprate disk floor is `crate::constants::CUPRATE_MIN_DISK_GB` — one
|
||||
// value shared with the install/start/restart/update RPC gates so boot
|
||||
// reconcile can never resume below the line they refuse at.
|
||||
|
||||
use crate::constants::CUPRATE_MIN_DISK_GB;
|
||||
|
||||
fn requires_cuprate_disk(app_id: &str, disk_gb: u64) -> bool {
|
||||
app_id == "cuprate" && disk_gb < CUPRATE_MIN_DISK_GB
|
||||
}
|
||||
|
||||
/// Apps expected to exist from first boot on every node — the ONLY apps the
|
||||
/// boot reconciler may install from nothing. Every other app needs
|
||||
@@ -1944,6 +1953,23 @@ impl ProdContainerOrchestrator {
|
||||
crate::crash_recovery::pending_boot_start_done(&container_name);
|
||||
continue;
|
||||
}
|
||||
// Same shape as the archival-bitcoin skip above: recorded BEFORE
|
||||
// ensure_running_with_mode, so the "absent" desired-state recovery
|
||||
// below can never fire on this reason and undo it.
|
||||
if mode == ReconcileMode::ExistingOnly && requires_cuprate_disk(&app_id, disk_gb) {
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
disk_gb,
|
||||
"cuprate needs a larger disk (no pruning support) — skipping start"
|
||||
);
|
||||
report.record(
|
||||
&app_id,
|
||||
ReconcileAction::Left("cuprate-insufficient-disk".into()),
|
||||
);
|
||||
crate::crash_recovery::pending_boot_start_done(&app_id);
|
||||
crate::crash_recovery::pending_boot_start_done(&container_name);
|
||||
continue;
|
||||
}
|
||||
match self.ensure_running_with_mode(&lm, mode).await {
|
||||
// Desired-state recovery: the app has no container and was left
|
||||
// "absent" by boot reconcile, BUT it was running at the last
|
||||
@@ -5365,6 +5391,27 @@ app:
|
||||
assert_eq!(compute_container_name(&m), "archy-electrs-ui");
|
||||
let m = pull_manifest("lnd-ui", "foo:1");
|
||||
assert_eq!(compute_container_name(&m), "archy-lnd-ui");
|
||||
let m = pull_manifest("cuprate-ui", "foo:1");
|
||||
assert_eq!(compute_container_name(&m), "archy-cuprate-ui");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuprate_disk_gate_blocks_only_cuprate_on_small_disks() {
|
||||
// 250 GB VPS class: the ~250 GiB Monero chain cannot fit and cuprate
|
||||
// has no pruning — boot reconcile must leave it down.
|
||||
assert!(requires_cuprate_disk("cuprate", 250));
|
||||
assert!(requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB - 1));
|
||||
assert!(!requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB));
|
||||
assert!(!requires_cuprate_disk("cuprate", 1000));
|
||||
// df failure in detect_disk_gb reads as 0 → fail closed at boot: a
|
||||
// doomed sync is worse than a node that stays down until it can
|
||||
// measure (same direction as the archival-bitcoin skip).
|
||||
assert!(requires_cuprate_disk("cuprate", 0));
|
||||
// Nothing else is gated here: bitcoin apps self-prune, everything
|
||||
// else is irrelevant to the Monero chain.
|
||||
for app_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
|
||||
assert!(!requires_cuprate_disk(app_id, 0), "{app_id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
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,
|
||||
18081, 18083, 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,21 @@
|
||||
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/
|
||||
COPY assets/ /usr/share/nginx/html/assets/
|
||||
# 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;"]
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 869 KiB |
@@ -0,0 +1,407 @@
|
||||
<!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-image: url('assets/img/bg-network.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
opacity: .42;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
/* Match the Bitcoin dashboard's app-header rhythm: identity on the
|
||||
left, compact live status cards on the right. */
|
||||
.app-header { display:flex; align-items:center; justify-content:space-between; gap:1.5rem; flex-wrap:wrap; }
|
||||
.app-header-id { display:flex; align-items:center; gap:1rem; min-width:0; flex:1 1 auto; }
|
||||
.app-header-actions { display:flex; align-items:center; justify-content:flex-end; gap:.75rem; flex:0 0 auto; flex-wrap:nowrap; margin-left:auto; }
|
||||
.info-card { display:flex; align-items:center; gap:.75rem; background:rgba(0,0,0,.6); backdrop-filter:blur(24px); border-radius:.75rem; padding:.65rem .85rem; box-shadow:inset 0 1px 0 rgba(255,255,255,.16); min-height:3.25rem; }
|
||||
.info-card .card-icon { width:1.25rem; height:1.25rem; color:rgba(255,255,255,.6); flex:0 0 auto; }
|
||||
.info-card-copy { display:flex; flex-direction:column; gap:.15rem; }
|
||||
.info-card-copy .sub { margin:0; font-size:.6875rem; }
|
||||
.info-card-copy strong { font-size:.8125rem; font-weight:600; color:rgba(255,255,255,.95); white-space:nowrap; }
|
||||
.node-mark { width:3.25rem; height:3.25rem; border-radius:.85rem; display:grid; place-items:center; background:#050505; border:1px solid rgba(255,255,255,.18); box-shadow:0 8px 24px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.2); }
|
||||
.tabbar { display:flex; gap:.35rem; padding:.35rem; margin:1.25rem 0; background:rgba(0,0,0,.62); border:1px solid rgba(255,255,255,.12); border-radius:.85rem; overflow-x:auto; }
|
||||
.tab-btn { flex:1 0 auto; border:0; border-radius:.6rem; padding:.65rem 1rem; background:transparent; color:rgba(255,255,255,.55); cursor:pointer; font-size:.8rem; font-weight:600; }
|
||||
.tab-btn:hover { color:#fff; background:rgba(255,255,255,.06); }
|
||||
.tab-btn.active { color:#fff; background:linear-gradient(135deg,rgba(247,147,26,.3),rgba(255,255,255,.08)); box-shadow:inset 0 1px 0 rgba(255,255,255,.15); }
|
||||
[data-panel].tab-hidden { display:none; }
|
||||
@media (max-width:700px) {
|
||||
.wrap { padding:1rem 1rem calc(6.75rem + env(safe-area-inset-bottom, 0px)); }
|
||||
.app-header { flex-direction:column; align-items:stretch; gap:1rem; }
|
||||
.app-header-id { flex-direction:column; justify-content:center; text-align:center; }
|
||||
.app-header-text { text-align:center; }
|
||||
.app-header-actions { flex-direction:column; align-items:stretch; justify-content:center; gap:.6rem; margin-left:0; }
|
||||
.app-header-actions > .info-card { width:100%; }
|
||||
.tabbar { position:fixed; left:.75rem; right:.75rem; bottom:calc(.5rem + env(safe-area-inset-bottom, 0px)); z-index:40; margin:0; padding:.35rem; border-radius:1rem; box-shadow:0 10px 30px rgba(0,0,0,.65); }
|
||||
.tab-btn { min-width:4.5rem; padding:.7rem .5rem; font-size:.7rem; }
|
||||
}
|
||||
@media (min-width:701px) {
|
||||
.app-header { flex-wrap:nowrap; }
|
||||
.app-header-text { min-width:0; }
|
||||
}
|
||||
|
||||
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: inherit; }
|
||||
.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; }
|
||||
.dot.online { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.dot.syncing { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
|
||||
.dot.offline { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
|
||||
.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 class="glass-card" style="padding:1.5rem;">
|
||||
<div class="app-header">
|
||||
<div class="app-header-id">
|
||||
<div class="node-mark"><img src="assets/img/app-icons/cuprate.svg" alt="Cuprate" style="width:2.35rem;height:2.35rem;object-fit:contain"></div>
|
||||
<div class="app-header-text">
|
||||
<h1><span class="accent">Cuprate</span> Monero Node</h1>
|
||||
<div class="sub">Rust implementation of the Monero protocol, on Archipelago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-header-actions">
|
||||
<div class="info-card">
|
||||
<div class="relative"><span class="dot" id="headerStatusDot"></span><span class="absolute inset-0 dot animate-ping opacity-50"></span></div>
|
||||
<div class="info-card-copy"><div class="sub">Status</div><strong id="statusText">Connecting…</strong></div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
|
||||
<div class="info-card-copy"><div class="sub">Network</div><strong id="headerNetwork">—</strong></div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16M4 17h16" /></svg>
|
||||
<div class="info-card-copy"><div class="sub">Height</div><strong id="headerHeight">—</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="offlineNotice" class="notice error" style="display:none;">
|
||||
<b>Cuprate is not reachable.</b> The node may be stopped, still installing, or syncing.
|
||||
Check the service status and try again shortly.
|
||||
</div>
|
||||
|
||||
<nav class="tabbar" role="tablist" aria-label="Cuprate dashboard sections">
|
||||
<button class="tab-btn active" data-tab="node" role="tab">Node</button>
|
||||
<button class="tab-btn" data-tab="insights" role="tab">Insights</button>
|
||||
<button class="tab-btn" data-tab="peers" role="tab">Peers</button>
|
||||
<button class="tab-btn" data-tab="connect" role="tab">Connect</button>
|
||||
</nav>
|
||||
|
||||
<div class="grid" style="margin-top:1rem;">
|
||||
<div class="glass-card" data-panel="node">
|
||||
<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 tab-hidden" data-panel="peers">
|
||||
<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 tab-hidden" data-panel="insights">
|
||||
<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>
|
||||
|
||||
<div class="glass-card tab-hidden" data-panel="connect">
|
||||
<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) {
|
||||
document.getElementById('statusText').textContent = text;
|
||||
const dot = document.getElementById('headerStatusDot');
|
||||
dot.className = 'dot ' + kind;
|
||||
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;
|
||||
// Monero's get_info returns target_height == 0 when the node is
|
||||
// FULLY SYNCED — the field is the height being caught up to, not
|
||||
// the chain tip, so `??` cannot substitute for the 0 case (a
|
||||
// synced node would sit forever at "Syncing — 0.00%"). Treat
|
||||
// 0/absent as "target is our own height" — the same sentinel
|
||||
// core/archipelago/src/electrs_status.rs branches on.
|
||||
const rawTarget = info.target_height ?? info.target ?? 0;
|
||||
const target = rawTarget > 0 ? rawTarget : 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);
|
||||
document.getElementById('headerNetwork').textContent = nettype;
|
||||
document.getElementById('headerHeight').textContent = tabular(height);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const tab = button.dataset.tab;
|
||||
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b === button));
|
||||
document.querySelectorAll('[data-panel]').forEach((panel) => panel.classList.toggle('tab-hidden', panel.dataset.panel !== tab));
|
||||
});
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
"headline": "Your node. Your source.",
|
||||
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
|
||||
"tag": "NGIT // NOSTR // NO SILO",
|
||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy",
|
||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
|
||||
"launchLabel": "Open GitWorkshop",
|
||||
"installLabel": "Install GitWorkshop",
|
||||
"detailsLabel": "How contribution works →"
|
||||
|
||||
@@ -45,13 +45,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="identities.length === 0" class="text-center py-8">
|
||||
<div v-else-if="userIdentities.length === 0" class="text-center py-8">
|
||||
<p class="text-white/50 text-sm">No identities found.</p>
|
||||
<p class="text-white/30 text-xs mt-1">Create one in Settings → Credentials</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="identity in identities"
|
||||
v-for="identity in userIdentities"
|
||||
:key="identity.id"
|
||||
type="button"
|
||||
role="radio"
|
||||
@@ -130,6 +130,7 @@ interface Identity {
|
||||
is_default: boolean
|
||||
nostr_pubkey?: string
|
||||
nostr_npub?: string
|
||||
is_node?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -148,10 +149,23 @@ const selectedId = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
// The node key authenticates the appliance itself (mesh/discovery and other
|
||||
// platform operations), not the person's public profile. The API marks it
|
||||
// explicitly; keep a defensive name/purpose fallback for older nodes that do
|
||||
// not send is_node yet.
|
||||
const userIdentities = computed(() => identities.value.filter(identity =>
|
||||
// `node-<pubkey>` is the deterministic id used by older node APIs before
|
||||
// the explicit is_node marker was added. Keep this fallback so an older
|
||||
// backend can never expose the appliance key as a profile choice.
|
||||
!identity.is_node
|
||||
&& !identity.id.trim().toLowerCase().startsWith('node-')
|
||||
&& identity.name.trim().toLowerCase() !== 'node'
|
||||
))
|
||||
|
||||
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
|
||||
|
||||
const hasNostrKey = computed(() => {
|
||||
const selected = identities.value.find(i => i.id === selectedId.value)
|
||||
const selected = userIdentities.value.find(i => i.id === selectedId.value)
|
||||
return selected?.nostr_pubkey != null
|
||||
})
|
||||
|
||||
@@ -169,8 +183,8 @@ async function loadIdentities() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
|
||||
identities.value = res.identities || []
|
||||
const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey)
|
||||
|| identities.value.find(i => i.nostr_pubkey)
|
||||
const defaultId = userIdentities.value.find(i => i.is_default && i.nostr_pubkey)
|
||||
|| userIdentities.value.find(i => i.nostr_pubkey)
|
||||
if (defaultId) selectedId.value = defaultId.id
|
||||
} catch (error) {
|
||||
identities.value = []
|
||||
@@ -183,7 +197,7 @@ async function loadIdentities() {
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
const selected = identities.value.find(i => i.id === selectedId.value)
|
||||
const selected = userIdentities.value.find(i => i.id === selectedId.value)
|
||||
if (selected) emit('select', selected)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils
|
||||
import { directAppUrl, HOST_FRAME_APPS, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { resolveAppIcon, isAppReadyForLaunch } from '@/views/apps/appsConfig'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
import type { AppCredential, AppCredentialsResponse } from '@/types/api'
|
||||
import { resolveAppCredentials } from '@/views/apps/appCredentials'
|
||||
@@ -290,6 +291,14 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
* Previously each Apps view owned a private modal, so Home skipped the
|
||||
* Portainer first-run token entirely. */
|
||||
function openSession(appId: string, opts: LaunchOptions = {}) {
|
||||
// Home/goal/deep-link launchers do not pass through AppCard.canLaunch.
|
||||
// Apply the same readiness gate here so a container that has just entered
|
||||
// `running` cannot race nginx and show a transient 502 to the user.
|
||||
const pkg = useAppStore().data?.['package-data']?.[appId]
|
||||
if (pkg && pkg.state === 'running' && !isAppReadyForLaunch(pkg)) {
|
||||
useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`)
|
||||
return
|
||||
}
|
||||
if (!opts.skipCredentialPrompt && CREDENTIAL_INTERSTITIAL_APPS.has(appId)) {
|
||||
void prepareCredentialLaunch(appId, opts.path)
|
||||
return
|
||||
|
||||
@@ -365,6 +365,7 @@ import AppGrid from './discover/AppGrid.vue'
|
||||
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured, type CatalogStorefront } from './discover/curatedApps'
|
||||
import { isServiceContainer } from './apps/serviceNames'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
@@ -733,6 +734,16 @@ onBeforeUnmount(() => {
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
function installToast(app: MarketplaceApp) {
|
||||
const service = isServiceContainer(app.id)
|
||||
const destination = service ? 'Services' : 'My Apps'
|
||||
toast.action(
|
||||
`Installing ${app.title ?? app.id} — it will appear in ${destination}`,
|
||||
{ label: `View ${destination}`, onClick: () => router.push({ path: '/dashboard/apps', query: service ? { tab: 'services' } : {} }) },
|
||||
{ variant: 'info', duration: 15000 },
|
||||
)
|
||||
}
|
||||
|
||||
function installBlockedReason(appId: string): string | undefined {
|
||||
if (!bitcoinPruned.value) return undefined
|
||||
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
|
||||
@@ -766,8 +777,7 @@ function failInstall(app: MarketplaceApp, err: unknown) {
|
||||
async function installApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
installToast(app)
|
||||
try {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
|
||||
@@ -780,8 +790,7 @@ async function installApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
installToast(app)
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
|
||||
if ((app as Record<string, unknown>).containerConfig) {
|
||||
|
||||
@@ -185,6 +185,7 @@ import {
|
||||
getCuratedAppList,
|
||||
} from './marketplace/marketplaceData'
|
||||
import { fetchAppCatalog } from './discover/curatedApps'
|
||||
import { isServiceContainer } from './apps/serviceNames'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -207,6 +208,17 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||
const installingApps = server.installingApps
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
function installToast(app: MarketplaceApp) {
|
||||
const service = isServiceContainer(app.id)
|
||||
const tab = service ? 'services' : 'apps'
|
||||
const destination = service ? 'Services' : 'My Apps'
|
||||
toast.action(
|
||||
`Installing ${app.title ?? app.id} — it will appear in ${destination}`,
|
||||
{ label: `View ${destination}`, onClick: () => router.push({ path: '/dashboard/apps', query: service ? { tab } : {} }) },
|
||||
{ variant: 'info', duration: 15000 },
|
||||
)
|
||||
}
|
||||
|
||||
// Install progress tracking is now in serverStore (global watcher on WebSocket data)
|
||||
// so it works regardless of which page is active
|
||||
|
||||
@@ -518,7 +530,7 @@ async function installApp(app: MarketplaceApp) {
|
||||
// Stay on the store page: the tile itself shows install progress via the
|
||||
// global watcher, and a forced jump to My Apps yanked the user out of the
|
||||
// page they were deliberately browsing.
|
||||
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
|
||||
installToast(app)
|
||||
|
||||
try {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
@@ -543,7 +555,7 @@ async function installCommunityApp(app: MarketplaceApp) {
|
||||
|
||||
queueInstall(app)
|
||||
// Stay on the store page (see installApp).
|
||||
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
|
||||
installToast(app)
|
||||
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { HOST_FRAME_APPS, NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
|
||||
import { DEFAULT_GITWORKSHOP_REPO_PATH, HOST_FRAME_APPS, NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
|
||||
import { GENERATED_HOST_FRAME_APPS, GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
|
||||
import { __setSignedCatalogForTests } from '../../discover/curatedApps'
|
||||
|
||||
@@ -149,8 +149,8 @@ describe('appSessionConfig', () => {
|
||||
// A runtime port the gate does NOT front keeps plain http (https would
|
||||
// fail to connect outright).
|
||||
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
|
||||
// Cuprate's UI port is auth:none — plain HTTP stays plain.
|
||||
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090')
|
||||
// Cuprate's raw RPC is never a launch surface; use the companion UI.
|
||||
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('/app/cuprate-ui/')
|
||||
})
|
||||
|
||||
it('keeps the pre-catalog Source app on the dashboard origin', () => {
|
||||
@@ -159,9 +159,9 @@ describe('appSessionConfig', () => {
|
||||
// Source is intentionally absent from SIGNED until owner UAT passes. It
|
||||
// must follow the already-working dashboard ingress instead of assuming
|
||||
// that the same address also exposes a dedicated high port.
|
||||
expect(resolveAppUrl('archipelago-source')).toBe('/app/archipelago-source/')
|
||||
expect(resolveAppUrl('archipelago-source')).toBe(`/app/archipelago-source${DEFAULT_GITWORKSHOP_REPO_PATH}`)
|
||||
expect(resolveAppUrl('archipelago-source', undefined, 'http://localhost:8337'))
|
||||
.toBe('/app/archipelago-source/')
|
||||
.toBe(`/app/archipelago-source${DEFAULT_GITWORKSHOP_REPO_PATH}`)
|
||||
expect(resolveAppUrl('archipelago-source', '/search'))
|
||||
.toBe('/app/archipelago-source/search')
|
||||
})
|
||||
|
||||
@@ -34,6 +34,9 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'bitcoin-knots': 8334,
|
||||
'bitcoin-core': 8334,
|
||||
'bitcoin-ui': 8334,
|
||||
'cuprate': 18091,
|
||||
'cuprate-ui': 18091,
|
||||
'archy-cuprate-ui': 18091,
|
||||
'electrumx': 50002,
|
||||
'electrs': 50002,
|
||||
'archy-electrs-ui': 50002,
|
||||
@@ -61,6 +64,10 @@ export const PROXY_APPS: Record<string, string> = {
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
}
|
||||
|
||||
/** The repository shown when GitWorkshop is opened from the app launcher. */
|
||||
export const DEFAULT_GITWORKSHOP_REPO_PATH =
|
||||
'/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy'
|
||||
|
||||
/** App launches use direct ports. Do not route through /app/... path proxies. */
|
||||
export const HTTPS_PROXY_PATHS: Record<string, string> = {
|
||||
}
|
||||
@@ -137,8 +144,8 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
// high port is reachable through the same address.
|
||||
if (id === 'archipelago-source') {
|
||||
const base = PROXY_APPS['archipelago-source']!
|
||||
if (!routeQueryPath) return base
|
||||
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : `/${routeQueryPath}`)
|
||||
const path = routeQueryPath || DEFAULT_GITWORKSHOP_REPO_PATH
|
||||
return base.replace(/\/+$/, '') + (path.startsWith('/') ? path : `/${path}`)
|
||||
}
|
||||
|
||||
// Bitcoin UI is a host-network companion on :8334. Do not launch it via
|
||||
@@ -149,6 +156,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
return appOrigin(8334, id)
|
||||
}
|
||||
|
||||
// Cuprate UI is the same companion shape on :18091. The cuprate app itself
|
||||
// publishes only the restricted RPC (18090) — a raw JSON endpoint, not a
|
||||
// page — so cuprate launches must land on the companion, never on the
|
||||
// runtimeUrl a running cuprate reports.
|
||||
if (id === 'cuprate' || id === 'cuprate-ui' || id === 'archy-cuprate-ui') {
|
||||
if (import.meta.env.DEV) return '/app/cuprate-ui/'
|
||||
return appOrigin(18091, id)
|
||||
}
|
||||
|
||||
if (runtimeUrl && id !== 'netbird') {
|
||||
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
|
||||
// The backend reports runtime URLs as http:// because that is how the app
|
||||
|
||||
@@ -9,6 +9,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"bitcoin-ui": 8334,
|
||||
"botfights": 9100,
|
||||
"btcpay-server": 23000,
|
||||
"cuprate-ui": 18091,
|
||||
"electrs-ui": 50002,
|
||||
"electrumx": 50002,
|
||||
"fedimint": 8175,
|
||||
@@ -54,6 +55,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"btcpay-server": "BTCPay Server",
|
||||
"core-lightning": "Core Lightning (CLN)",
|
||||
"cuprate": "Cuprate",
|
||||
"cuprate-ui": "Cuprate UI",
|
||||
"electrs-ui": "Electrs UI",
|
||||
"electrumx": "ElectrumX",
|
||||
"fedimint": "Fedimint Guardian",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
|
||||
import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, isAppReadyForLaunch, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
|
||||
|
||||
function makePkg(id: string, title: string, category: string): PackageDataEntry {
|
||||
return {
|
||||
@@ -76,6 +76,16 @@ describe('appsConfig service filtering', () => {
|
||||
expect(services.map(([id]) => id)).toEqual(['core-lnd-ui'])
|
||||
})
|
||||
|
||||
it('shows Cuprate as one My Apps entry while hiding its daemon dependency', () => {
|
||||
const entries: Array<[string, PackageDataEntry]> = [
|
||||
['cuprate-ui', makePkg('cuprate-ui', 'Cuprate UI', 'money')],
|
||||
['cuprate', makePkg('cuprate', 'Cuprate daemon', 'money')],
|
||||
]
|
||||
;(entries[0][1].manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'http://localhost:18091' } }
|
||||
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate-ui'])
|
||||
expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual(['cuprate'])
|
||||
})
|
||||
|
||||
it('falls back to packaged app icon when static icon token is not a path', () => {
|
||||
const pkg = makePkg('gitea', 'Gitea', 'dev')
|
||||
pkg['static-files']!.icon = 'git-branch'
|
||||
@@ -141,6 +151,19 @@ describe('appsConfig service filtering', () => {
|
||||
expect(canLaunch(confirmedUi)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not launch a health-checked app during the running-before-ready race', () => {
|
||||
const pkg = makePkg('archipelago-source', 'GitWorkshop', 'development')
|
||||
;(pkg.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
|
||||
;(pkg.manifest as unknown as Record<string, unknown>).health_check = { path: '/healthz' }
|
||||
pkg.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8337' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
||||
pkg.health = null
|
||||
expect(isAppReadyForLaunch(pkg)).toBe(false)
|
||||
expect(canLaunch(pkg)).toBe(false)
|
||||
expect(launchBlockedReason(pkg.manifest.id, pkg)).toContain('Starting up')
|
||||
pkg.health = 'healthy'
|
||||
expect(canLaunch(pkg)).toBe(true)
|
||||
})
|
||||
|
||||
it('never offers Launch for curated service containers even with a UI flag', () => {
|
||||
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
|
||||
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
|
||||
|
||||
@@ -37,7 +37,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
|
||||
// Known app -> category mappings (matches App Store categorisation)
|
||||
export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
|
||||
'fedimint': 'money', 'fedimint-gateway': 'money',
|
||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||
@@ -259,11 +259,26 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
// the tile stays launchable while the backend is still 'starting' (ElectrumX
|
||||
// indexes for 10m+ on first run). A genuinely 'unhealthy' backend still
|
||||
// blocks. Apps that rely on a runtime interface-address keep the strict gate.
|
||||
const blockedByHealth =
|
||||
pkg.health === 'unhealthy' || (pkg.health === 'starting' && !hasKnownLaunchUrl)
|
||||
const blockedByHealth = !isAppReadyForLaunch(pkg) ||
|
||||
(pkg.health === 'starting' && !hasKnownLaunchUrl)
|
||||
return !!hasUI && pkg.state === 'running' && !blockedByHealth
|
||||
}
|
||||
|
||||
/**
|
||||
* A published port is not the same thing as a usable app. During the short
|
||||
* interval between the container entering `running` and its HTTP health check
|
||||
* passing, nginx quite correctly returns 502 because the upstream has not
|
||||
* bound its socket yet. Keep every app with a declared health check out of
|
||||
* the launch path until the platform has observed readiness. Apps without a
|
||||
* health check retain the legacy state/port behaviour.
|
||||
*/
|
||||
export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
|
||||
const manifest = pkg.manifest as unknown as Record<string, unknown>
|
||||
const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check'])
|
||||
if (!hasHealthCheck) return pkg.health !== 'unhealthy'
|
||||
return pkg.health === 'healthy'
|
||||
}
|
||||
|
||||
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
|
||||
const appId = pkg?.manifest?.id || id
|
||||
if (
|
||||
@@ -272,6 +287,9 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null):
|
||||
) {
|
||||
return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
|
||||
}
|
||||
if (pkg && pkg.state === PackageState.Running && !isAppReadyForLaunch(pkg)) {
|
||||
return 'Starting up — Launch will appear when the app is ready.'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
// SERVICE_NAMES set that used to live in appsConfig.ts verbatim.
|
||||
export const SERVICE_NAMES = new Set([
|
||||
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
||||
// Cuprate's daemon is the backend dependency of the Cuprate UI app.
|
||||
'cuprate',
|
||||
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
|
||||
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
|
||||
// built-in Mesh tab) belong in Services, not My Apps.
|
||||
|
||||
@@ -123,6 +123,8 @@ const CATALOG_APP_ID_ALIASES: Record<string, string> = {
|
||||
'archy-lnd-ui': 'lnd-ui',
|
||||
'bitcoin-knots': 'bitcoin-ui',
|
||||
'bitcoin-core': 'bitcoin-ui',
|
||||
'cuprate': 'cuprate-ui',
|
||||
'archy-cuprate-ui': 'cuprate-ui',
|
||||
'fedimintd': 'fedimint',
|
||||
'immich_server': 'immich',
|
||||
}
|
||||
@@ -308,6 +310,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
// Supporting containers (DBs, caches, workers) do NOT — having only a DB
|
||||
// without the main app should not mark the app as installed in the UI.
|
||||
export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
'cuprate-ui': ['cuprate-ui', 'cuprate'],
|
||||
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server'],
|
||||
|
||||
@@ -68,6 +68,7 @@ const REGISTRY = 'source.archipelago-foundation.org/lfg2025'
|
||||
|
||||
/** Marketplace app ID -> backend package keys (for "Already Installed" when first-boot/deploy created them) */
|
||||
export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
'cuprate-ui': ['cuprate-ui', 'cuprate'],
|
||||
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
|
||||
|
||||
@@ -173,6 +173,11 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/app/cuprate-ui': {
|
||||
target: process.env.BACKEND_URL || 'http://localhost:5959',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// Demo mock app UIs (electrumx, lnd, fedimint) + generic notice page.
|
||||
'/app/electrumx': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
'/app/electrs': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
|
||||
@@ -1315,13 +1315,13 @@
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "300Gi"
|
||||
"storage": "450Gi"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_limit": 0,
|
||||
"memory_limit": "10Gi",
|
||||
"disk_limit": "300Gi"
|
||||
"disk_limit": "450Gi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [],
|
||||
|
||||
@@ -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