@@ -500,6 +514,12 @@
if (el) el.textContent = text;
}
+ function formatSats(sats) {
+ if (sats >= 1_000_000) return (sats / 1_000_000).toFixed(2).replace(/\.00$/, '') + 'M sats';
+ if (sats >= 1_000) return (sats / 1_000).toFixed(1).replace(/\.0$/, '') + 'k sats';
+ return sats.toLocaleString() + ' sats';
+ }
+
async function loadLogs() {
const logsContent = document.getElementById('logsContent');
const backendUrl = getBackendUrl();
@@ -532,6 +552,29 @@
data.channelCount = (ch.channels && ch.channels.length) || 0;
}
} catch (_) {}
+ // Balances — cards stay hidden when the endpoints are unavailable.
+ try {
+ const wRes = await fetch(backendUrl + '/proxy/lnd/v1/balance/blockchain', { credentials: 'include' });
+ if (wRes.ok) {
+ const w = await wRes.json();
+ const sats = Number(w.confirmed_balance ?? w.total_balance ?? 0);
+ if (!isNaN(sats)) {
+ setText('onchainBalance', formatSats(sats));
+ document.getElementById('onchainBalanceCard').style.display = '';
+ }
+ }
+ } catch (_) {}
+ try {
+ const cRes = await fetch(backendUrl + '/proxy/lnd/v1/balance/channels', { credentials: 'include' });
+ if (cRes.ok) {
+ const c = await cRes.json();
+ const sats = Number((c.local_balance && c.local_balance.sat) ?? c.balance ?? 0);
+ if (!isNaN(sats)) {
+ setText('channelBalance', formatSats(sats));
+ document.getElementById('channelBalanceCard').style.display = '';
+ }
+ }
+ } catch (_) {}
data.grpcReachable = data.restReachable;
applyLiveData(data);
}
diff --git a/docs/demo-build-info.md b/docs/demo-build-info.md
index ce73e129..4992b7c9 100644
--- a/docs/demo-build-info.md
+++ b/docs/demo-build-info.md
@@ -1,9 +1,9 @@
# Archipelago Public Demo — build info & status
-**Status:** implemented & deployable (2026-06-22)
-**Branch:** `demo-build` (worktree `../archy-demo-build`), pushed to
+**Status:** implemented & deployable (2026-07-14)
+**Branch:** `main` — the demo machinery was merged from the old `demo-build`
+branch and now lives on main, pushed to
`gitea-vps2` = `http://146.59.87.168:3000/lfg2025/archy.git`.
-**Main/prod is untouched** — all demo work lives only on `demo-build`.
A public, click-to-play demo of the Archipelago UI, 100% mock-data driven,
multi-visitor, deployed via Portainer. See also `docs/archive/demo-deployment-design.md`
@@ -18,7 +18,7 @@ Build-from-repo (works today, no registry needed):
| Field | Value |
|-------|-------|
| Repository URL | `http://146.59.87.168:3000/lfg2025/archy.git` |
-| Reference | `refs/heads/demo-build` |
+| Reference | `refs/heads/main` |
| Compose path | `docker-compose.demo.yml` |
| Auth | user `lfg2025`, password = Gitea token |
| UI port | **2100** · Login password: **`entertoexit`** |
diff --git a/neode-ui/Dockerfile.backend b/neode-ui/Dockerfile.backend
index f9ed0339..22ed353c 100644
--- a/neode-ui/Dockerfile.backend
+++ b/neode-ui/Dockerfile.backend
@@ -15,12 +15,14 @@ RUN npm install
COPY neode-ui/ ./
# Sibling assets the mock backend reads relative to /app (../docker, ../demo):
-# the Bitcoin UI mock shell and any curated cloud files dropped into demo/files.
+# the Bitcoin UI mock shell and the curated cloud files (demo/files drop-ins +
+# the committed demo/content library — both are scanned by loadDemoDiskFiles).
COPY docker/bitcoin-ui /docker/bitcoin-ui
COPY docker/electrs-ui /docker/electrs-ui
COPY docker/lnd-ui /docker/lnd-ui
COPY docker/fedimint-ui /docker/fedimint-ui
COPY demo/files /demo/files
+COPY demo/content /demo/content
# Expose port
EXPOSE 5959
diff --git a/neode-ui/docker/nginx-demo.conf b/neode-ui/docker/nginx-demo.conf
index 01bfa11e..123dd4eb 100644
--- a/neode-ui/docker/nginx-demo.conf
+++ b/neode-ui/docker/nginx-demo.conf
@@ -17,6 +17,12 @@ http {
# Allow large uploads globally (filebrowser, etc.)
client_max_body_size 0;
+ # WebSocket upgrade passthrough (mempool live data, etc.)
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
server {
listen 80 default_server;
server_name _;
@@ -116,12 +122,15 @@ http {
sub_filter 'url(/' 'url(/app/indeedhub/';
}
- # Mempool: same approach. NOTE mempool.space is a strict third-party app —
- # its data/websocket calls may still be blocked; iframe is best-effort.
+ # Mempool: same approach. mempool.guide (not mempool.space, which blocks
+ # proxied embedding) — strip framing headers and rewrite absolute paths.
location /app/mempool/ {
- proxy_pass https://mempool.space/;
+ proxy_pass https://mempool.guide/;
proxy_http_version 1.1;
- proxy_set_header Host mempool.space;
+ proxy_set_header Host mempool.guide;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_read_timeout 3600;
proxy_set_header Accept-Encoding "";
proxy_ssl_server_name on;
proxy_hide_header X-Frame-Options;
diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js
index 2831631a..75b4ae66 100755
--- a/neode-ui/mock-backend.js
+++ b/neode-ui/mock-backend.js
@@ -233,6 +233,14 @@ function seedMockState() {
{ federation_id: 'fed1' + randomHex(28), name: 'Archipelago Federation', balance_sats: 180_000 },
{ federation_id: 'fed1' + randomHex(28), name: 'Bitcoin Park Mint', balance_sats: 42_500 },
],
+ // Networking-profits pricing (mirrors crate::streaming::pricing defaults,
+ // with a couple enabled so the demo page looks alive).
+ streamingServices: [
+ { service_id: 'content-download', name: 'Content Downloads', metric: 'bytes', step_size: 1_048_576, price_per_step: 1, min_steps: 0, enabled: true, description: 'Pay-per-byte content downloads from this node', accepted_mints: [] },
+ { service_id: 'federation-sync', name: 'Federation Sync Access', metric: 'milliseconds', step_size: 60_000, price_per_step: 1, min_steps: 5, enabled: false, description: 'Timed access to federation sync endpoint', accepted_mints: [] },
+ { service_id: 'api-access', name: 'API Access', metric: 'requests', step_size: 1, price_per_step: 1, min_steps: 10, enabled: false, description: 'Per-request API access for external consumers', accepted_mints: [] },
+ { service_id: 'nostr-relay', name: 'Nostr Relay Access', metric: 'milliseconds', step_size: 3_600_000, price_per_step: 10, min_steps: 1, enabled: true, description: 'Timed access to the local Nostr relay', accepted_mints: [] },
+ ],
}
}
@@ -333,7 +341,7 @@ async function getDockerContainers() {
const appMetadata = {
'bitcoin': {
title: 'Bitcoin Core',
- icon: '/assets/img/app-icons/bitcoin.svg',
+ icon: '/assets/img/app-icons/bitcoin-core.png',
description: 'Full Bitcoin node implementation'
},
'btcpay-server': {
@@ -353,7 +361,7 @@ async function getDockerContainers() {
},
'endurain': {
title: 'Endurain',
- icon: '/assets/img/endurain.png',
+ icon: '/assets/img/app-icons/endurain.png',
description: 'Application platform'
},
'fedimint': {
@@ -363,22 +371,22 @@ async function getDockerContainers() {
},
'morphos-server': {
title: 'MorphOS Server',
- icon: '/assets/img/morphos.png',
+ icon: '/assets/img/app-icons/morphos.png',
description: 'Server platform'
},
'lightning-stack': {
title: 'Lightning Stack',
- icon: '/assets/img/app-icons/lightning-stack.png',
+ icon: '/assets/img/app-icons/lnd.png',
description: 'Lightning Network (LND)'
},
'mempool': {
title: 'Mempool',
- icon: '/assets/img/app-icons/mempool.png',
+ icon: '/assets/img/app-icons/mempool.webp',
description: 'Bitcoin blockchain explorer'
},
'mempool-electrs': {
title: 'Electrs',
- icon: '/assets/img/app-icons/electrs.svg',
+ icon: '/assets/img/app-icons/electrumx.webp',
description: 'Electrum protocol indexer for Bitcoin'
},
'ollama': {
@@ -393,7 +401,7 @@ async function getDockerContainers() {
},
'penpot': {
title: 'Penpot',
- icon: '/assets/img/penpot.webp',
+ icon: '/assets/img/app-icons/archipelago-a.svg',
description: 'Open-source design and prototyping'
}
}
@@ -480,15 +488,15 @@ async function isContainerRuntimeAvailable() {
// Marketplace metadata lookup for install (title, description, icon, version)
const marketplaceMetadata = {
'bitcoin-knots': { title: 'Bitcoin Knots', shortDesc: 'Full Bitcoin node — validate and relay blocks and transactions', icon: '/assets/img/app-icons/bitcoin-knots.webp' },
- 'electrs': { title: 'Electrs', shortDesc: 'Electrum protocol indexer for Bitcoin', icon: '/assets/img/app-icons/electrs.svg' },
+ 'electrs': { title: 'Electrs', shortDesc: 'Electrum protocol indexer for Bitcoin', icon: '/assets/img/app-icons/electrumx.webp' },
'btcpay-server': { title: 'BTCPay Server', shortDesc: 'Self-hosted Bitcoin payment processor', icon: '/assets/img/app-icons/btcpay-server.png' },
- 'lnd': { title: 'LND', shortDesc: 'Lightning Network Daemon', icon: '/assets/img/app-icons/lnd.svg' },
+ 'lnd': { title: 'LND', shortDesc: 'Lightning Network Daemon', icon: '/assets/img/app-icons/lnd.png' },
'mempool': { title: 'Mempool Explorer', shortDesc: 'Bitcoin blockchain and mempool visualizer', icon: '/assets/img/app-icons/mempool.webp' },
'homeassistant': { title: 'Home Assistant', shortDesc: 'Open-source home automation platform', icon: '/assets/img/app-icons/homeassistant.png' },
'grafana': { title: 'Grafana', shortDesc: 'Analytics and monitoring dashboards', icon: '/assets/img/app-icons/grafana.png' },
'searxng': { title: 'SearXNG', shortDesc: 'Privacy-respecting metasearch engine', icon: '/assets/img/app-icons/searxng.png' },
'ollama': { title: 'Ollama', shortDesc: 'Run large language models locally', icon: '/assets/img/app-icons/ollama.png' },
- 'penpot': { title: 'Penpot', shortDesc: 'Open-source design and prototyping platform', icon: '/assets/img/app-icons/penpot.webp' },
+ 'penpot': { title: 'Penpot', shortDesc: 'Open-source design and prototyping platform', icon: '/assets/img/app-icons/archipelago-a.svg' },
'nextcloud': { title: 'Nextcloud', shortDesc: 'Self-hosted cloud storage and collaboration', icon: '/assets/img/app-icons/nextcloud.webp' },
'vaultwarden': { title: 'Vaultwarden', shortDesc: 'Self-hosted password manager (Bitwarden-compatible)', icon: '/assets/img/app-icons/vaultwarden.webp' },
'jellyfin': { title: 'Jellyfin', shortDesc: 'Free media server for movies, music, and photos', icon: '/assets/img/app-icons/jellyfin.webp' },
@@ -502,10 +510,10 @@ const marketplaceMetadata = {
'fedimint': { title: 'Fedimint', shortDesc: 'Federated Bitcoin mint with Guardian UI', icon: '/assets/img/app-icons/fedimint.png' },
'indeedhub': { title: 'Indeehub', shortDesc: 'Bitcoin documentary streaming platform', icon: '/assets/img/app-icons/indeedhub.png' },
'dwn': { title: 'Decentralized Web Node', shortDesc: 'Store and sync personal data with DID-based access', icon: '/assets/img/app-icons/dwn.svg' },
- 'nostr-rs-relay': { title: 'Nostr Relay', shortDesc: 'Run your own Nostr relay', icon: '/assets/img/app-icons/nostr-rs-relay.svg' },
- 'syncthing': { title: 'Syncthing', shortDesc: 'Peer-to-peer file synchronization', icon: '/assets/img/app-icons/syncthing.png' },
+ 'nostr-rs-relay': { title: 'Nostr Relay', shortDesc: 'Run your own Nostr relay', icon: '/assets/img/app-icons/nostrudel.svg' },
+ 'syncthing': { title: 'Syncthing', shortDesc: 'Peer-to-peer file synchronization', icon: '/assets/img/app-icons/archipelago-a.svg' },
'thunderhub': { title: 'ThunderHub', shortDesc: 'Lightning node management UI with channel management and payments', icon: '/assets/img/app-icons/thunderhub.svg' },
- 'tor': { title: 'Tor', shortDesc: 'Anonymous communication over the Tor network', icon: '/assets/img/app-icons/tor.png' },
+ 'tor': { title: 'Tor', shortDesc: 'Anonymous communication over the Tor network', icon: '/assets/img/app-icons/tor.svg' },
'amin': { title: 'Amin', shortDesc: 'Administrative interface for Archipelago', icon: '/assets/icon/pwa-192x192-v2.png' },
}
@@ -720,6 +728,9 @@ const SEED_MOCKDATA = {
updated: false,
'backup-progress': null,
'update-progress': null,
+ // The mock has no container scanner — report the scan as done so app
+ // cards never sit on "Checking..." in demo/dev.
+ 'containers-scanned': true,
},
'lan-address': 'localhost',
'tor-address': 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion',
@@ -812,7 +823,7 @@ const staticDevApps = {
longDesc: 'Instant Bitcoin payments with near-zero fees. Open channels, route payments, earn sats.',
state: 'running',
lanPort: 8080,
- icon: '/assets/img/app-icons/lnd.svg',
+ icon: '/assets/img/app-icons/lnd.png',
}),
electrumx: staticApp({
id: 'electrumx',
@@ -872,6 +883,92 @@ const staticDevApps = {
longDesc: 'Federated Chaumian e-cash mint with Guardian UI. Community custody, private payments, and Lightning gateways.',
state: 'running',
lanPort: 8175,
+ icon: '/assets/img/app-icons/fedimint.png',
+ }),
+ 'btcpay-server': staticApp({
+ id: 'btcpay-server',
+ title: 'BTCPay Server',
+ version: '2.0.4',
+ shortDesc: 'Self-hosted Bitcoin payment processor',
+ longDesc: 'Accept Bitcoin payments in your store — no fees, no middlemen, no KYC.',
+ state: 'running',
+ lanPort: 23000,
+ icon: '/assets/img/app-icons/btcpay-server.png',
+ }),
+ grafana: staticApp({
+ id: 'grafana',
+ title: 'Grafana',
+ version: '11.4.0',
+ shortDesc: 'Analytics and monitoring dashboards',
+ longDesc: 'Visualize node metrics, Bitcoin sync progress, and Lightning routing on custom dashboards.',
+ license: 'AGPL-3.0',
+ state: 'running',
+ lanPort: 3000,
+ icon: '/assets/img/app-icons/grafana.png',
+ }),
+ nextcloud: staticApp({
+ id: 'nextcloud',
+ title: 'Nextcloud',
+ version: '29.0.0',
+ shortDesc: 'Self-hosted cloud storage and collaboration',
+ longDesc: 'Files, calendar, contacts, and collaboration — replace Google Drive and Dropbox entirely.',
+ license: 'AGPL-3.0',
+ state: 'running',
+ lanPort: 8082,
+ icon: '/assets/img/app-icons/nextcloud.webp',
+ }),
+ jellyfin: staticApp({
+ id: 'jellyfin',
+ title: 'Jellyfin',
+ version: '10.10.3',
+ shortDesc: 'Free media server for movies, music, and photos',
+ longDesc: 'Stream your media collection to any device — no subscription, no tracking.',
+ license: 'GPL-2.0',
+ state: 'running',
+ lanPort: 8096,
+ icon: '/assets/img/app-icons/jellyfin.webp',
+ }),
+ vaultwarden: staticApp({
+ id: 'vaultwarden',
+ title: 'Vaultwarden',
+ version: '1.32.5',
+ shortDesc: 'Self-hosted password manager',
+ longDesc: 'Bitwarden-compatible password manager. Keep your credentials under your own roof.',
+ license: 'AGPL-3.0',
+ state: 'running',
+ lanPort: 8222,
+ icon: '/assets/img/app-icons/vaultwarden.webp',
+ }),
+ 'nostr-rs-relay': staticApp({
+ id: 'nostr-rs-relay',
+ title: 'Nostr Relay',
+ version: '0.9.0',
+ shortDesc: 'Run your own Nostr relay',
+ longDesc: 'Sovereign social networking — publish notes and serve your community, censorship-free.',
+ state: 'running',
+ lanPort: 7000,
+ icon: '/assets/img/app-icons/nostrudel.svg',
+ }),
+ searxng: staticApp({
+ id: 'searxng',
+ title: 'SearXNG',
+ version: '2024.12.16',
+ shortDesc: 'Privacy-respecting metasearch engine',
+ longDesc: 'Aggregate search results from dozens of engines with zero tracking.',
+ license: 'AGPL-3.0',
+ state: 'running',
+ lanPort: 8888,
+ icon: '/assets/img/app-icons/searxng.png',
+ }),
+ 'uptime-kuma': staticApp({
+ id: 'uptime-kuma',
+ title: 'Uptime Kuma',
+ version: '1.23.16',
+ shortDesc: 'Self-hosted monitoring tool',
+ longDesc: 'Monitor uptime for your services with alerts and status pages.',
+ state: 'running',
+ lanPort: 3001,
+ icon: '/assets/img/app-icons/uptime-kuma.webp',
}),
}
@@ -969,6 +1066,47 @@ function mockBitcoinNetworkInfo() {
}
}
+// One monitoring MetricSnapshot in the exact shape Monitoring.vue expects:
+// epoch-second timestamps, nested system metrics, per-container stats with
+// limits + block IO, rpc_latency_ms and ws_connections at the top level.
+function monitoringSnapshot(tsSecs, seed = 0) {
+ const wave = Math.sin((tsSecs / 60 + seed) / 6)
+ const mkContainer = (name, cpuPct, mem, limit) => ({
+ name,
+ cpu_percent: cpuPct,
+ mem_used_bytes: mem,
+ mem_limit_bytes: limit,
+ net_rx_bytes: Math.floor(Math.random() * 4_000_000),
+ net_tx_bytes: Math.floor(Math.random() * 2_500_000),
+ block_read_bytes: Math.floor(Math.random() * 9_000_000),
+ block_write_bytes: Math.floor(Math.random() * 5_000_000),
+ })
+ return {
+ timestamp: tsSecs,
+ system: {
+ cpu_percent: +(14 + wave * 6 + Math.random() * 8).toFixed(1),
+ mem_used_bytes: Math.floor(6_000_000_000 + wave * 600_000_000 + Math.random() * 300_000_000),
+ mem_total_bytes: 16_000_000_000,
+ disk_used_bytes: 620_000_000_000,
+ disk_total_bytes: 1_800_000_000_000,
+ net_rx_bytes: Math.floor(2_000_000 + Math.random() * 6_000_000),
+ net_tx_bytes: Math.floor(1_000_000 + Math.random() * 4_000_000),
+ load_avg_1: +(0.5 + Math.random() * 1.2).toFixed(2),
+ load_avg_5: +(0.7 + Math.random() * 0.9).toFixed(2),
+ load_avg_15: +(0.6 + Math.random() * 0.7).toFixed(2),
+ },
+ containers: [
+ mkContainer('bitcoin-knots', +(6 + Math.random() * 5).toFixed(1), 1_200_000_000, 4_000_000_000),
+ mkContainer('lnd', +(2 + Math.random() * 3).toFixed(1), 480_000_000, 2_000_000_000),
+ mkContainer('electrs', +(9 + Math.random() * 7).toFixed(1), 890_000_000, 2_000_000_000),
+ mkContainer('mempool', +(3 + Math.random() * 4).toFixed(1), 320_000_000, 1_000_000_000),
+ mkContainer('filebrowser', +(0.4 + Math.random()).toFixed(1), 45_000_000, 512_000_000),
+ ],
+ rpc_latency_ms: +(2 + Math.random() * 9).toFixed(1),
+ ws_connections: 2,
+ }
+}
+
function bitcoinRelayStatusPayload() {
return {
settings: bitcoinRelayMockState.settings,
@@ -1155,8 +1293,23 @@ function lndChannels() {
}
app.get('/proxy/lnd/v1/getinfo', (_req, res) => res.json(lndGetinfo()))
app.get('/proxy/lnd/v1/channels', (_req, res) => res.json(lndChannels()))
+// Balances polled by the lnd-ui shell's stat cards (LND REST shapes).
+app.get('/proxy/lnd/v1/balance/blockchain', (_req, res) => res.json({
+ total_balance: '2450000', confirmed_balance: '2350000', unconfirmed_balance: '100000',
+}))
+app.get('/proxy/lnd/v1/balance/channels', (_req, res) => res.json({
+ balance: '8250000',
+ local_balance: { sat: '8250000', msat: '8250000000' },
+ remote_balance: { sat: '11750000', msat: '11750000000' },
+ pending_open_local_balance: { sat: '500000', msat: '500000000' },
+}))
+// The shell only renders the "Connect Your Wallet" QR + details when
+// cert_base64url/macaroon_base64url and the ports are present.
app.get('/lnd-connect-info', (_req, res) => res.json({
- getinfo: lndGetinfo(), channelCount: 4, cert_base: 'demo',
+ getinfo: lndGetinfo(), channelCount: 4,
+ cert_base64url: Buffer.from('demo-tls-cert-' + randomHex(24)).toString('base64url'),
+ macaroon_base64url: Buffer.from('demo-admin-macaroon-' + randomHex(32)).toString('base64url'),
+ grpc_port: 10009, rest_port: 8080,
grpcReachable: true, restReachable: true,
tor_onion: 'lnd' + randomHex(16) + '.onion', error: null,
}))
@@ -1458,6 +1611,13 @@ app.post('/rpc/v1', (req, res) => {
return res.json({ result: { message: params?.message || 'Hello from Archipelago!' } })
}
+ case 'server.get-state': {
+ // Full state snapshot — the sync store polls this every 30s and after
+ // every WebSocket reconnect. Without it the UI can never resync (and
+ // logged a "Method not found" error each time).
+ return res.json({ result: { data: mockData } })
+ }
+
case 'server.time': {
return res.json({
result: {
@@ -1485,7 +1645,7 @@ app.post('/rpc/v1', (req, res) => {
title: 'Bitcoin Core',
description: 'Full Bitcoin node — validate transactions, enforce consensus rules, and support the network. The foundation of sovereignty.',
version: '27.1',
- icon: '/assets/img/app-icons/bitcoin.png',
+ icon: '/assets/img/app-icons/bitcoin-core.png',
author: 'Bitcoin Core',
license: 'MIT',
category: 'Bitcoin',
@@ -1505,7 +1665,7 @@ app.post('/rpc/v1', (req, res) => {
title: 'Electrs',
description: 'Electrum Server in Rust — index the blockchain for fast wallet lookups. Connect your hardware wallets privately.',
version: '0.10.6',
- icon: '/assets/img/app-icons/electrs.png',
+ icon: '/assets/img/app-icons/electrumx.webp',
author: 'Roman Zeyde',
license: 'MIT',
category: 'Bitcoin',
@@ -1515,7 +1675,7 @@ app.post('/rpc/v1', (req, res) => {
title: 'Mempool',
description: 'Bitcoin blockchain explorer and mempool visualizer. Monitor transactions, fees, and network activity in real time.',
version: '3.0.0',
- icon: '/assets/img/app-icons/mempool.png',
+ icon: '/assets/img/app-icons/mempool.webp',
author: 'Mempool Space',
license: 'AGPL-3.0',
category: 'Bitcoin',
@@ -1525,7 +1685,7 @@ app.post('/rpc/v1', (req, res) => {
title: 'BTCPay Server',
description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments in your store — no fees, no middlemen, no KYC.',
version: '2.0.4',
- icon: '/assets/img/app-icons/btcpay.png',
+ icon: '/assets/img/app-icons/btcpay-server.png',
author: 'BTCPay Server',
license: 'MIT',
category: 'Commerce',
@@ -1555,7 +1715,7 @@ app.post('/rpc/v1', (req, res) => {
title: 'Vaultwarden',
description: 'Self-hosted password manager compatible with Bitwarden clients. Keep your credentials under your own roof.',
version: '1.32.5',
- icon: '/assets/img/app-icons/vaultwarden.png',
+ icon: '/assets/img/app-icons/vaultwarden.webp',
author: 'Vaultwarden',
license: 'AGPL-3.0',
category: 'Privacy',
@@ -1565,7 +1725,7 @@ app.post('/rpc/v1', (req, res) => {
title: 'Nextcloud',
description: 'Your personal cloud — files, calendar, contacts, and collaboration. Replace Google Drive and Dropbox entirely.',
version: '29.0.0',
- icon: '/assets/img/app-icons/nextcloud.png',
+ icon: '/assets/img/app-icons/nextcloud.webp',
author: 'Nextcloud GmbH',
license: 'AGPL-3.0',
category: 'Cloud',
@@ -1575,7 +1735,7 @@ app.post('/rpc/v1', (req, res) => {
title: 'Nostr Relay',
description: 'Run your own Nostr relay — sovereign social networking. Publish notes, follow friends, no censorship possible.',
version: '0.34.0',
- icon: '/assets/img/app-icons/nostr-relay.png',
+ icon: '/assets/img/app-icons/nostrudel.svg',
author: 'nostr-rs-relay',
license: 'MIT',
category: 'Social',
@@ -1585,7 +1745,7 @@ app.post('/rpc/v1', (req, res) => {
title: 'Home Assistant',
description: 'Open-source home automation — control lights, locks, cameras, and sensors. Smart home without the cloud dependency.',
version: '2024.12.0',
- icon: '/assets/img/app-icons/home-assistant.png',
+ icon: '/assets/img/app-icons/homeassistant.png',
author: 'Home Assistant',
license: 'Apache-2.0',
category: 'IoT',
@@ -1595,7 +1755,7 @@ app.post('/rpc/v1', (req, res) => {
title: 'Syncthing',
description: 'Continuous peer-to-peer file synchronization. Sync folders across devices without any cloud service.',
version: '1.28.1',
- icon: '/assets/img/app-icons/syncthing.png',
+ icon: '/assets/img/app-icons/archipelago-a.svg',
author: 'Syncthing Foundation',
license: 'MPL-2.0',
category: 'Cloud',
@@ -1605,7 +1765,7 @@ app.post('/rpc/v1', (req, res) => {
title: 'Tor',
description: 'Anonymous communication — route traffic through the Tor network. Access your node from anywhere, privately.',
version: '0.4.8.13',
- icon: '/assets/img/app-icons/tor.png',
+ icon: '/assets/img/app-icons/tor.svg',
author: 'Tor Project',
license: 'BSD-3',
category: 'Privacy',
@@ -2015,6 +2175,20 @@ app.post('/rpc/v1', (req, res) => {
return res.json({ result: { paid } })
}
+ // ── Networking profits: per-service pricing (interactive) ──────────────
+ case 'streaming.list-services': {
+ return res.json({ result: { services: mockState.streamingServices || [] } })
+ }
+ case 'streaming.configure-service': {
+ const p = params || {}
+ const list = mockState.streamingServices || []
+ const idx = list.findIndex(s => s.service_id === p.service_id)
+ if (idx >= 0) list[idx] = { ...list[idx], ...p }
+ else list.push({ accepted_mints: [], ...p })
+ mockState.streamingServices = list
+ return res.json({ result: { success: true } })
+ }
+
// ── Wallet settings: Cashu mints + Fedimint federations (interactive) ──
case 'streaming.list-mints': {
return res.json({ result: { mints: mockState.cashuMints } })
@@ -3363,51 +3537,24 @@ app.post('/rpc/v1', (req, res) => {
// ── Monitoring ──────────────────────────────────────────────
case 'monitoring.current': {
- return res.json({
- result: {
- system: {
- cpu_percent: +(12 + Math.random() * 18).toFixed(1),
- load_avg_1: +(0.5 + Math.random() * 1.5).toFixed(2),
- load_avg_5: +(0.8 + Math.random()).toFixed(2),
- load_avg_15: +(0.6 + Math.random()).toFixed(2),
- mem_used_bytes: 6_200_000_000 + Math.floor(Math.random() * 500_000_000),
- mem_total_bytes: 16_000_000_000,
- disk_used_bytes: 620_000_000_000,
- disk_total_bytes: 1_800_000_000_000,
- net_rx_bytes: 12_400_000_000 + Math.floor(Math.random() * 100_000_000),
- net_tx_bytes: 8_900_000_000 + Math.floor(Math.random() * 50_000_000),
- uptime_secs: Math.floor(process.uptime()) + 604800,
- },
- rpc: { avg_latency_ms: +(2 + Math.random() * 8).toFixed(1), requests_per_minute: Math.floor(30 + Math.random() * 40) },
- },
- })
+ return res.json({ result: monitoringSnapshot(Math.floor(Date.now() / 1000)) })
}
case 'monitoring.history': {
- const points = 60
- const history = []
- for (let i = 0; i < points; i++) {
- history.push({
- timestamp: new Date(Date.now() - (points - i) * 60000).toISOString(),
- system: {
- cpu_percent: +(10 + Math.random() * 25).toFixed(1),
- mem_used_bytes: 5_800_000_000 + Math.floor(Math.random() * 1_000_000_000),
- mem_total_bytes: 16_000_000_000,
- net_rx_bytes: Math.floor(Math.random() * 5_000_000),
- net_tx_bytes: Math.floor(Math.random() * 3_000_000),
- },
- rpc: { avg_latency_ms: +(1 + Math.random() * 10).toFixed(1) },
- })
- }
- return res.json({ result: { history } })
+ const count = Math.min(params?.count || 60, 1440)
+ const nowSecs = Math.floor(Date.now() / 1000)
+ const data = Array.from({ length: count }, (_, i) => monitoringSnapshot(nowSecs - (count - 1 - i) * 60, i))
+ return res.json({ result: { resolution: params?.resolution || 'minute', count: data.length, data } })
}
case 'monitoring.alerts': {
+ const nowSecs = Math.floor(Date.now() / 1000)
return res.json({
result: {
alerts: [
- { id: 'a1', kind: 'cpu_high', message: 'CPU usage exceeded 80% for 5 minutes', timestamp: new Date(Date.now() - 7200000).toISOString(), acknowledged: false },
- { id: 'a2', kind: 'disk_high', message: 'Disk usage at 85%', timestamp: new Date(Date.now() - 86400000).toISOString(), acknowledged: true },
+ { id: 'al-1', kind: 'ram_usage', message: 'Memory usage exceeded 85% (LND DB compaction)', value: 87.2, threshold: 85, timestamp: nowSecs - 7200, acknowledged: false },
+ { id: 'al-2', kind: 'disk_usage', message: 'Disk usage reached 80% on /var/lib/archipelago', value: 80.4, threshold: 80, timestamp: nowSecs - 86400, acknowledged: true },
+ { id: 'al-3', kind: 'backend_error_spike', message: 'RPC latency spiked to 620ms', value: 620, threshold: 500, timestamp: nowSecs - 172800, acknowledged: true },
],
},
})
@@ -3417,10 +3564,11 @@ app.post('/rpc/v1', (req, res) => {
return res.json({
result: {
rules: [
- { kind: 'cpu_high', enabled: true, threshold: 80, description: 'Alert when CPU usage exceeds threshold' },
- { kind: 'mem_high', enabled: true, threshold: 85, description: 'Alert when memory usage exceeds threshold' },
- { kind: 'disk_high', enabled: true, threshold: 90, description: 'Alert when disk usage exceeds threshold' },
- { kind: 'backend_error_spike', enabled: false, threshold: 500, description: 'Alert when RPC latency exceeds threshold' },
+ { kind: 'disk_usage', enabled: true, threshold: 80, description: 'Disk usage above threshold (%)' },
+ { kind: 'ram_usage', enabled: true, threshold: 85, description: 'Memory usage above threshold (%)' },
+ { kind: 'container_crash', enabled: true, threshold: 1, description: 'Container restarted unexpectedly' },
+ { kind: 'backend_error_spike', enabled: false, threshold: 500, description: 'RPC latency above threshold (ms)' },
+ { kind: 'ssl_cert_expiry', enabled: true, threshold: 14, description: 'TLS certificate expires within N days' },
],
},
})
@@ -3432,7 +3580,16 @@ app.post('/rpc/v1', (req, res) => {
}
case 'monitoring.export': {
- return res.json({ result: { data: 'timestamp,cpu,mem\n2026-04-10T12:00:00Z,15.2,38.5\n' } })
+ const count = Math.min(params?.count || 1440, 1440)
+ const nowSecs = Math.floor(Date.now() / 1000)
+ const data = Array.from({ length: count }, (_, i) => monitoringSnapshot(nowSecs - (count - 1 - i) * 60, i))
+ if (params?.format === 'csv') {
+ const csv = ['timestamp,cpu_percent,mem_used_bytes,mem_total_bytes,net_rx_bytes,net_tx_bytes,rpc_latency_ms']
+ .concat(data.map(s => [s.timestamp, s.system.cpu_percent, s.system.mem_used_bytes, s.system.mem_total_bytes, s.system.net_rx_bytes, s.system.net_tx_bytes, s.rpc_latency_ms].join(',')))
+ .join('\n')
+ return res.json({ result: { csv, count: data.length } })
+ }
+ return res.json({ result: { data, count: data.length } })
}
case 'monitoring.container-metrics': {
@@ -3537,7 +3694,6 @@ const SEED_FILES = {
{ name: 'Music', path: '/Music', size: 0, modified: '2025-03-01T10:00:00Z', isDir: true, type: '' },
{ name: 'Documents', path: '/Documents', size: 0, modified: '2025-02-28T14:30:00Z', isDir: true, type: '' },
{ name: 'Photos', path: '/Photos', size: 0, modified: '2025-02-20T09:15:00Z', isDir: true, type: '' },
- { name: 'Videos', path: '/Videos', size: 0, modified: '2025-01-15T18:00:00Z', isDir: true, type: '' },
],
'/Music': [
{ name: 'Bad Actors Reveal.mp3', path: '/Music/Bad Actors Reveal.mp3', size: 8_400_000, modified: '2025-01-10T12:00:00Z', isDir: false, type: 'audio' },
@@ -3572,11 +3728,8 @@ const SEED_FILES = {
{ name: 'home-server-build.jpg', path: '/Photos/home-server-build.jpg', size: 2_900_000, modified: '2024-11-20T16:45:00Z', isDir: false, type: 'image' },
{ name: 'sunset-from-balcony.jpg', path: '/Photos/sunset-from-balcony.jpg', size: 4_200_000, modified: '2025-02-14T18:30:00Z', isDir: false, type: 'image' },
],
- '/Videos': [
- { name: 'node-unboxing-timelapse.mp4', path: '/Videos/node-unboxing-timelapse.mp4', size: 85_000_000, modified: '2024-11-01T10:00:00Z', isDir: false, type: 'video' },
- { name: 'bitcoin-explained-5min.mp4', path: '/Videos/bitcoin-explained-5min.mp4', size: 42_000_000, modified: '2024-10-15T14:00:00Z', isDir: false, type: 'video' },
- { name: 'lightning-payment-demo.mp4', path: '/Videos/lightning-payment-demo.mp4', size: 28_000_000, modified: '2025-01-20T12:00:00Z', isDir: false, type: 'video' },
- ],
+ // NOTE: no seeded Videos folder — every seeded file must be backed by real,
+ // playable content (curated videos can still be added via demo/files/Videos).
}
const SEED_FILE_CONTENTS = {
@@ -3587,19 +3740,35 @@ const SEED_FILE_CONTENTS = {
'/Documents/backup-log.json': JSON.stringify({ backups: [{ id: 'bkp-2025-03-01', timestamp: '2025-03-01T02:00:00Z', type: 'full', apps: ['bitcoin-knots', 'lnd', 'mempool'], size_mb: 2340, status: 'success' }] }, null, 2),
}
-// Curated real files: drop files into
/demo/files// and they
-// replace the seeded cloud content for every visitor (read-only — visitors can
-// view/download/buy them but only maintainers add them, via git = the "private
-// login"). If the folder is absent/empty the hardcoded seeds above are kept.
-// Binary files are streamed from disk on demand (diskFilePaths); text is inlined.
+// Curated real files: drop files into /demo/files// (or the
+// committed library in /demo/content/) and they replace the seeded cloud
+// content for every visitor (read-only — visitors can view/download/buy them but
+// only maintainers add them, via git = the "private login"). If both are
+// absent/empty the hardcoded seeds above are kept. Binary files are streamed
+// from disk on demand (diskFilePaths); text is inlined.
const diskFilePaths = {}
function loadDemoDiskFiles() {
- const root = path.join(__dirname, '..', 'demo', 'files')
- let top
- try { top = fsSync.readdirSync(root, { withFileTypes: true }) } catch { return }
+ // demo/files (drop-in overrides) wins over demo/content (committed library)
+ // when both provide the same top-level folder.
+ const roots = [
+ path.join(__dirname, '..', 'demo', 'files'),
+ path.join(__dirname, '..', 'demo', 'content'),
+ ]
const tree = { '/': [] }
const contents = {}
const TEXT_EXT = new Set(['txt', 'md', 'json', 'csv', 'log', 'yaml', 'yml', 'xml', 'conf', 'ini'])
+ const addFile = (abs, relDir, name) => {
+ let st; try { st = fsSync.statSync(abs) } catch { return }
+ const ext = (name.includes('.') ? name.split('.').pop() : '').toLowerCase()
+ const type = fbType(name)
+ const rel = relDir === '/' ? `/${name}` : `${relDir}/${name}`
+ tree[relDir].push({ name, path: rel, size: st.size, modified: st.mtime.toISOString(), isDir: false, type })
+ if (TEXT_EXT.has(ext) && st.size < 1_000_000) {
+ try { contents[rel] = fsSync.readFileSync(abs, 'utf-8') } catch { /* skip */ }
+ } else {
+ diskFilePaths[rel] = abs // streamed from disk by the raw handler
+ }
+ }
const walk = (absDir, relDir) => {
let entries
try { entries = fsSync.readdirSync(absDir, { withFileTypes: true }) } catch { return }
@@ -3607,25 +3776,36 @@ function loadDemoDiskFiles() {
for (const e of entries) {
if (e.name.startsWith('.')) continue
const abs = path.join(absDir, e.name)
- const rel = relDir === '/' ? `/${e.name}` : `${relDir}/${e.name}`
if (e.isDirectory()) {
+ const rel = relDir === '/' ? `/${e.name}` : `${relDir}/${e.name}`
tree[relDir].push({ name: e.name, path: rel, size: 0, modified: new Date().toISOString(), isDir: true, type: '' })
walk(abs, rel)
} else {
- let st; try { st = fsSync.statSync(abs) } catch { continue }
- const ext = (e.name.includes('.') ? e.name.split('.').pop() : '').toLowerCase()
- const type = fbType(e.name)
- tree[relDir].push({ name: e.name, path: rel, size: st.size, modified: st.mtime.toISOString(), isDir: false, type })
- if (TEXT_EXT.has(ext) && st.size < 1_000_000) {
- try { contents[rel] = fsSync.readFileSync(abs, 'utf-8') } catch { /* skip */ }
- } else {
- diskFilePaths[rel] = abs // streamed from disk by the raw handler
- }
+ addFile(abs, relDir, e.name)
}
}
}
- walk(root, '/')
- if (!tree['/'].length) return // empty folder → keep the hardcoded seeds
+ const claimed = new Set()
+ for (const root of roots) {
+ let top
+ try { top = fsSync.readdirSync(root, { withFileTypes: true }) } catch { continue }
+ for (const e of top) {
+ if (e.name.startsWith('.')) continue
+ if (e.isDirectory()) {
+ // Normalize top-level folder names for display/merge (music → Music).
+ const display = e.name.charAt(0).toUpperCase() + e.name.slice(1)
+ if (claimed.has(display)) continue
+ claimed.add(display)
+ const rel = `/${display}`
+ tree['/'].push({ name: display, path: rel, size: 0, modified: new Date().toISOString(), isDir: true, type: '' })
+ tree[rel] = tree[rel] || []
+ walk(path.join(root, e.name), rel)
+ } else if (!tree['/'].some(x => x.name === e.name)) {
+ addFile(path.join(root, e.name), '/', e.name)
+ }
+ }
+ }
+ if (!tree['/'].length) return // nothing curated → keep the hardcoded seeds
// Per-folder MERGE: a top-level folder present in demo/files replaces that
// seed folder; seed folders the curator didn't provide (e.g. sample Documents)
// are kept. So dropping real Music/ doesn't wipe the sample Documents.
@@ -3828,11 +4008,72 @@ app.get('/app/filebrowser/api/raw/*', (req, res) => {
.on('error', () => { if (!res.headersSent) res.status(404).send('File not found') }).pipe(res)
}
const content = currentStore().files.contents[full]
- if (content === undefined) return res.status(404).send('File not found')
+ if (content === undefined) {
+ // Seeded media entries have no backing file (only curated demo/content or
+ // demo/files do). Serve generated-but-real media so nothing 404s and the
+ // players never hit "no supported source".
+ const type = fbType(fbBase(full))
+ if (type === 'audio') {
+ const wav = demoToneWav()
+ res.type('audio/wav')
+ res.setHeader('Content-Length', wav.length)
+ return res.send(wav)
+ }
+ if (type === 'image') {
+ res.type('image/svg+xml')
+ return res.send(demoPlaceholderSvg(fbBase(full)))
+ }
+ return res.status(404).send('File not found')
+ }
res.type(fbContentType(fbBase(full)))
res.send(Buffer.isBuffer(content) ? content : String(content))
})
+// A short, real WAV (two-note ambient pad) used for seeded audio entries that
+// have no curated file behind them — so pressing play always plays something.
+let __demoToneWav = null
+function demoToneWav() {
+ if (__demoToneWav) return __demoToneWav
+ const rate = 22050
+ const secs = 20
+ const n = rate * secs
+ const buf = Buffer.alloc(44 + n * 2)
+ buf.write('RIFF', 0)
+ buf.writeUInt32LE(36 + n * 2, 4)
+ buf.write('WAVEfmt ', 8)
+ buf.writeUInt32LE(16, 16) // fmt chunk size
+ buf.writeUInt16LE(1, 20) // PCM
+ buf.writeUInt16LE(1, 22) // mono
+ buf.writeUInt32LE(rate, 24)
+ buf.writeUInt32LE(rate * 2, 28) // byte rate
+ buf.writeUInt16LE(2, 32) // block align
+ buf.writeUInt16LE(16, 34) // bits per sample
+ buf.write('data', 36)
+ buf.writeUInt32LE(n * 2, 40)
+ for (let i = 0; i < n; i++) {
+ const t = i / rate
+ const swell = 0.6 + 0.4 * Math.sin(2 * Math.PI * t / 8)
+ const v = (Math.sin(2 * Math.PI * 220 * t) * 0.28 + Math.sin(2 * Math.PI * 277.18 * t) * 0.2) * swell
+ const fade = Math.min(1, t / 0.1, (secs - t) / 0.5)
+ buf.writeInt16LE(Math.round(v * fade * 16000), 44 + i * 2)
+ }
+ __demoToneWav = buf
+ return buf
+}
+
+// Neutral placeholder for seeded image entries without a curated file.
+function demoPlaceholderSvg(name) {
+ const label = String(name).replace(/[<>&"]/g, '')
+ return `
+
+
+
+
+ ${label}
+ Archipelago demo sample
+ `
+}
+
// A compact description of the current (mock) node, fed to the AI assistant as
// system context in the demo so it can answer questions about this node.
function demoNodeContext() {
@@ -4231,6 +4472,126 @@ app.get('/app/thunderhub/api/invoices', (req, res) => res.json(MOCK_LND_DATA.inv
app.get('/app/thunderhub/api/payments', (req, res) => res.json(MOCK_LND_DATA.payments))
app.get('/app/thunderhub/api/forwards', (req, res) => res.json(MOCK_LND_DATA.forwarding))
+// ── Demo placeholder UIs for the extra installed apps ────────────────────────
+// Static dashboards rendered with demoAppShell so every installed app opens to
+// something plausible in the in-app iframe. Registered before the generic
+// /app/:id notice handler so these win.
+const DEMO_APP_PAGES = {
+ 'btcpay-server': () => demoAppShell('BTCPay Server', 'Self-hosted payment processor · signet', '/assets/img/app-icons/btcpay-server.png', `
+
+
+ Invoice Amount Status Created
+ INV-0047 · Sticker pack 21,000 sats Settled 2h ago
+ INV-0046 · Node consult 250,000 sats Settled 9h ago
+ INV-0045 · Coffee 5,500 sats Settled 1d ago
+ INV-0044 · Merch bundle 84,000 sats Expired 2d ago
+
`),
+ grafana: () => demoAppShell('Grafana', 'Dashboards · node metrics', '/assets/img/app-icons/grafana.png', `
+
+
+ Dashboard Panels Last viewed
+ Bitcoin Node Overview 14 12m ago
+ Lightning Routing 9 1h ago
+ System Resources 11 3h ago
+ Tor & Network 7 1d ago
+
`),
+ nextcloud: () => demoAppShell('Nextcloud', 'Files · Calendar · Contacts', '/assets/img/app-icons/nextcloud.webp', `
+
+
Storage used
182 GB / 1.8 TB
+
+
+
+
+
+ Recent file Size Modified
+ node-backup-2026-07.tar.gz 2.3 GB Today
+ Sovereign Computing.pdf 4.8 MB Yesterday
+ family-photos-june/ 1.2 GB 3d ago
+
`),
+ jellyfin: () => demoAppShell('Jellyfin', 'The Free Software Media System', '/assets/img/app-icons/jellyfin.webp', `
+
+
+ Recently added Type Added
+ The Bitcoin Standard (audiobook) Audio Today
+ Lightning Payment Demo Video 2d ago
+ Node Unboxing Timelapse Video 5d ago
+
`),
+ vaultwarden: () => demoAppShell('Vaultwarden', 'Bitwarden-compatible password manager', '/assets/img/app-icons/vaultwarden.webp', `
+
+
+
Security report
+
+ Weak passwords 3
+ Reused passwords 1
+ Exposed in breaches 0
+ Two-factor enabled Yes
+
+
`),
+ 'nostr-rs-relay': () => demoAppShell('Nostr Relay', 'wss://relay.archipelago.lan', '/assets/img/app-icons/nostrudel.svg', `
+
+
+ Kind Description Events (24h)
+ 1 Short text notes 1,824
+ 0 Profile metadata 96
+ 7 Reactions 3,412
+ 4 Encrypted DMs 58
+
`),
+ searxng: () => demoAppShell('SearXNG', 'Privacy-respecting metasearch', '/assets/img/app-icons/searxng.png', `
+
+
+
No queries logged · no profiling · results proxied
+
+ `),
+ 'uptime-kuma': () => demoAppShell('Uptime Kuma', 'Service monitoring', '/assets/img/app-icons/uptime-kuma.webp', `
+
+
+ Monitor Status Uptime Ping
+ Bitcoin RPC Up 100% 4 ms
+ LND REST Up 99.9% 7 ms
+ Electrum server Up 99.8% 11 ms
+ Tor hidden service Up 99.7% 380 ms
+ Mempool web Up 100% 6 ms
+
`),
+}
+for (const [id, page] of Object.entries(DEMO_APP_PAGES)) {
+ app.get([`/app/${id}`, `/app/${id}/`], (_req, res) => res.type('html').send(page()))
+}
+
// Generic app shell for any launched app without a dedicated mock UI — a clean
// "not interactive in the demo" notice with the app's icon. Registered after all
// specific /app/... routes so those win; only bare /app/[/] reaches here.
@@ -4406,16 +4767,18 @@ wss.on('connection', (ws, req) => {
clearInterval(pingInterval)
clearInterval(heartbeatInterval)
}
- }, 30000) // Ping every 30 seconds
+ }, 25000) // Ping every 25 seconds
- // Send periodic heartbeat data so clients don't think the connection is dead
+ // Send periodic heartbeat data so clients (and any idle-timeout proxy in
+ // front of the public demo) never see a silent socket. 20s stays well under
+ // common 30–60s proxy idle timeouts that were killing demo connections.
const heartbeatInterval = setInterval(() => {
if (ws.readyState === 1) {
try {
ws.send(JSON.stringify({ type: 'heartbeat', rev: Date.now() }))
} catch { /* ignore */ }
}
- }, 45000) // Every 45s (client expects data within 60s)
+ }, 20000)
// Send initial data immediately (this visitor's store, not the global proxy —
// there is no request context inside the WS connection handler).
@@ -4458,15 +4821,16 @@ wss.on('connection', (ws, req) => {
})
})
-// Best-effort: pull a few REAL recent testnet txids so the wallet's transactions
-// deep-link to live mempool.space/testnet pages. Falls back to the mock hashes
-// (already set) if offline. Patches the pristine seed so every session inherits them.
+// Best-effort: pull a few REAL recent txids from mempool.guide (the instance the
+// demo's in-app explorer proxies) so wallet transactions deep-link to pages that
+// actually resolve. Falls back to the mock hashes (already set) if offline.
+// Patches the pristine seed so every session inherits them.
async function hydrateRealTestnetTxids() {
if (!DEMO) return
try {
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 4000)
- const r = await fetch('https://mempool.space/testnet/api/mempool/recent', { signal: ctrl.signal })
+ const r = await fetch('https://mempool.guide/api/mempool/recent', { signal: ctrl.signal })
clearTimeout(t)
if (!r.ok) return
const recent = await r.json()
diff --git a/neode-ui/src/components/LightningChannelsPanel.vue b/neode-ui/src/components/LightningChannelsPanel.vue
index 7adb8057..fe1caf0b 100644
--- a/neode-ui/src/components/LightningChannelsPanel.vue
+++ b/neode-ui/src/components/LightningChannelsPanel.vue
@@ -258,13 +258,11 @@
diff --git a/neode-ui/vite.config.ts b/neode-ui/vite.config.ts
index 67149bd7..bb1b14eb 100644
--- a/neode-ui/vite.config.ts
+++ b/neode-ui/vite.config.ts
@@ -141,8 +141,10 @@ export default defineConfig({
changeOrigin: true,
secure: false,
},
+ // Mock filebrowser (Cloud page) — same backend as everything else. Point
+ // BACKEND_URL at a real node to develop against its filebrowser instead.
'/app/filebrowser': {
- target: 'http://192.168.1.228',
+ target: process.env.BACKEND_URL || 'http://localhost:5959',
changeOrigin: true,
secure: false,
},
@@ -158,6 +160,10 @@ export default defineConfig({
'/app/fedimint': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/app/bitcoin-core': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/app/bitcoin-knots': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
+ // Catch-all for the remaining mock app UIs (btcpay-server, grafana, …) and
+ // the generic "Not available in the demo" notice. Registered after the
+ // specific /app/* keys so those still win.
+ '/app': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/electrs-status': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/proxy': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/lnd-connect-info': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },