Demo fixes: real media, LND QR/balances, mempool.guide, overlay sessions, mobile scroll #76

Merged
lfg2025 merged 6 commits from demo-fixes-20260714 into main 2026-07-14 02:50:02 +00:00
20 changed files with 646 additions and 187 deletions

View File

@ -196,6 +196,20 @@
<p class="text-sm font-medium text-orange" id="channelCount">0</p>
</div>
</div>
<div class="info-card flex items-center gap-3" id="onchainBalanceCard" style="display:none">
<span style="font-size:1.5rem;color:#f7931a;font-weight:700">&#8383;</span>
<div>
<p class="text-xs text-white-60 mb-1">On-chain</p>
<p class="text-sm font-medium text-white" id="onchainBalance">&mdash;</p>
</div>
</div>
<div class="info-card flex items-center gap-3" id="channelBalanceCard" style="display:none">
<span style="font-size:1.5rem;color:#4ade80;font-weight:700">&#9737;</span>
<div>
<p class="text-xs text-white-60 mb-1">Lightning Balance</p>
<p class="text-sm font-medium text-white" id="channelBalance">&mdash;</p>
</div>
</div>
<div class="info-card flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="status-dot-sm bg-green" id="restDot"></div>
@ -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);
}

View File

@ -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`** |

View File

@ -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

View File

@ -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;

View File

@ -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 <repo>/demo/files/<Folder>/<file> 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 <repo>/demo/files/<Folder>/<file> (or the
// committed library in <repo>/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 `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500">
<rect width="800" height="500" fill="#14161c"/>
<rect x="24" y="24" width="752" height="452" rx="16" fill="none" stroke="#2a2e3a" stroke-width="2"/>
<circle cx="400" cy="215" r="58" fill="none" stroke="#f7931a" stroke-width="4" opacity="0.7"/>
<path d="M370 245 L400 185 L430 245 Z" fill="#f7931a" opacity="0.5"/>
<text x="400" y="330" text-anchor="middle" font-family="system-ui,sans-serif" font-size="20" fill="#8a8f9a">${label}</text>
<text x="400" y="360" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#5b6070">Archipelago demo sample</text>
</svg>`
}
// 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', `
<div class="grid">
<div class="card"><div class="k">Store</div><div class="v">Archipelago Shop</div></div>
<div class="card"><div class="k">Invoices (30d)</div><div class="v">47</div></div>
<div class="card"><div class="k">Settled</div><div class="v">1,284,500 sats</div></div>
<div class="card"><div class="k">Lightning</div><div class="v"><span class="badge">Connected</span></div></div>
</div>
<div class="card"><table>
<tr><th>Invoice</th><th>Amount</th><th>Status</th><th>Created</th></tr>
<tr><td>INV-0047 · Sticker pack</td><td>21,000 sats</td><td>Settled</td><td>2h ago</td></tr>
<tr><td>INV-0046 · Node consult</td><td>250,000 sats</td><td>Settled</td><td>9h ago</td></tr>
<tr><td>INV-0045 · Coffee</td><td>5,500 sats</td><td>Settled</td><td>1d ago</td></tr>
<tr><td>INV-0044 · Merch bundle</td><td>84,000 sats</td><td>Expired</td><td>2d ago</td></tr>
</table></div>`),
grafana: () => demoAppShell('Grafana', 'Dashboards · node metrics', '/assets/img/app-icons/grafana.png', `
<div class="grid">
<div class="card"><div class="k">Dashboards</div><div class="v">6</div></div>
<div class="card"><div class="k">Data sources</div><div class="v">3</div></div>
<div class="card"><div class="k">Alerts firing</div><div class="v">0</div></div>
<div class="card"><div class="k">Uptime</div><div class="v">99.98%</div></div>
</div>
<div class="card"><table>
<tr><th>Dashboard</th><th>Panels</th><th>Last viewed</th></tr>
<tr><td>Bitcoin Node Overview</td><td>14</td><td>12m ago</td></tr>
<tr><td>Lightning Routing</td><td>9</td><td>1h ago</td></tr>
<tr><td>System Resources</td><td>11</td><td>3h ago</td></tr>
<tr><td>Tor &amp; Network</td><td>7</td><td>1d ago</td></tr>
</table></div>`),
nextcloud: () => demoAppShell('Nextcloud', 'Files · Calendar · Contacts', '/assets/img/app-icons/nextcloud.webp', `
<div class="grid">
<div class="card"><div class="k">Storage used</div><div class="v">182 GB / 1.8 TB</div><div class="bar"><i style="width:10%"></i></div></div>
<div class="card"><div class="k">Files</div><div class="v">12,847</div></div>
<div class="card"><div class="k">Shares</div><div class="v">23</div></div>
<div class="card"><div class="k">Users</div><div class="v">3</div></div>
</div>
<div class="card"><table>
<tr><th>Recent file</th><th>Size</th><th>Modified</th></tr>
<tr><td>node-backup-2026-07.tar.gz</td><td>2.3 GB</td><td>Today</td></tr>
<tr><td>Sovereign Computing.pdf</td><td>4.8 MB</td><td>Yesterday</td></tr>
<tr><td>family-photos-june/</td><td>1.2 GB</td><td>3d ago</td></tr>
</table></div>`),
jellyfin: () => demoAppShell('Jellyfin', 'The Free Software Media System', '/assets/img/app-icons/jellyfin.webp', `
<div class="grid">
<div class="card"><div class="k">Movies</div><div class="v">214</div></div>
<div class="card"><div class="k">Shows</div><div class="v">38</div></div>
<div class="card"><div class="k">Music albums</div><div class="v">156</div></div>
<div class="card"><div class="k">Active streams</div><div class="v">1</div></div>
</div>
<div class="card"><table>
<tr><th>Recently added</th><th>Type</th><th>Added</th></tr>
<tr><td>The Bitcoin Standard (audiobook)</td><td>Audio</td><td>Today</td></tr>
<tr><td>Lightning Payment Demo</td><td>Video</td><td>2d ago</td></tr>
<tr><td>Node Unboxing Timelapse</td><td>Video</td><td>5d ago</td></tr>
</table></div>`),
vaultwarden: () => demoAppShell('Vaultwarden', 'Bitwarden-compatible password manager', '/assets/img/app-icons/vaultwarden.webp', `
<div class="grid">
<div class="card"><div class="k">Vault items</div><div class="v">184</div></div>
<div class="card"><div class="k">Logins</div><div class="v">142</div></div>
<div class="card"><div class="k">Secure notes</div><div class="v">28</div></div>
<div class="card"><div class="k">Status</div><div class="v"><span class="badge">Vault locked</span></div></div>
</div>
<div class="card">
<div class="k" style="margin-bottom:10px">Security report</div>
<table>
<tr><td>Weak passwords</td><td>3</td></tr>
<tr><td>Reused passwords</td><td>1</td></tr>
<tr><td>Exposed in breaches</td><td>0</td></tr>
<tr><td>Two-factor enabled</td><td>Yes</td></tr>
</table>
</div>`),
'nostr-rs-relay': () => demoAppShell('Nostr Relay', 'wss://relay.archipelago.lan', '/assets/img/app-icons/nostrudel.svg', `
<div class="grid">
<div class="card"><div class="k">Events stored</div><div class="v">48,213</div></div>
<div class="card"><div class="k">Connections</div><div class="v">17</div></div>
<div class="card"><div class="k">Subscriptions</div><div class="v">42</div></div>
<div class="card"><div class="k">DB size</div><div class="v">312 MB</div></div>
</div>
<div class="card"><table>
<tr><th>Kind</th><th>Description</th><th>Events (24h)</th></tr>
<tr><td>1</td><td>Short text notes</td><td>1,824</td></tr>
<tr><td>0</td><td>Profile metadata</td><td>96</td></tr>
<tr><td>7</td><td>Reactions</td><td>3,412</td></tr>
<tr><td>4</td><td>Encrypted DMs</td><td>58</td></tr>
</table></div>`),
searxng: () => demoAppShell('SearXNG', 'Privacy-respecting metasearch', '/assets/img/app-icons/searxng.png', `
<div class="card" style="margin-bottom:18px;text-align:center;padding:28px">
<input placeholder="Search privately…" style="width:100%;max-width:480px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);border-radius:10px;padding:12px 16px;color:#f2f2f4;font-size:15px;outline:none">
<div class="k" style="margin-top:12px">No queries logged · no profiling · results proxied</div>
</div>
<div class="grid">
<div class="card"><div class="k">Engines enabled</div><div class="v">28</div></div>
<div class="card"><div class="k">Queries today</div><div class="v">64</div></div>
<div class="card"><div class="k">Avg response</div><div class="v">0.8s</div></div>
<div class="card"><div class="k">Tracking</div><div class="v"><span class="badge">Zero</span></div></div>
</div>`),
'uptime-kuma': () => demoAppShell('Uptime Kuma', 'Service monitoring', '/assets/img/app-icons/uptime-kuma.webp', `
<div class="grid">
<div class="card"><div class="k">Monitors</div><div class="v">8</div></div>
<div class="card"><div class="k">Up</div><div class="v">8</div></div>
<div class="card"><div class="k">Avg uptime (30d)</div><div class="v">99.94%</div></div>
<div class="card"><div class="k">Incidents</div><div class="v">1</div></div>
</div>
<div class="card"><table>
<tr><th>Monitor</th><th>Status</th><th>Uptime</th><th>Ping</th></tr>
<tr><td>Bitcoin RPC</td><td><span class="badge">Up</span></td><td>100%</td><td>4 ms</td></tr>
<tr><td>LND REST</td><td><span class="badge">Up</span></td><td>99.9%</td><td>7 ms</td></tr>
<tr><td>Electrum server</td><td><span class="badge">Up</span></td><td>99.8%</td><td>11 ms</td></tr>
<tr><td>Tor hidden service</td><td><span class="badge">Up</span></td><td>99.7%</td><td>380 ms</td></tr>
<tr><td>Mempool web</td><td><span class="badge">Up</span></td><td>100%</td><td>6 ms</td></tr>
</table></div>`),
}
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/<id>[/] 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 3060s 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()

View File

@ -258,13 +258,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useAppLauncherStore } from '@/stores/appLauncher'
defineProps<{ compact?: boolean }>()
const router = useRouter()
interface Channel {
chan_id: string
remote_pubkey: string
@ -321,7 +319,8 @@ function fundingTxid(ch: Channel): string {
function openInMempool(txid: string) {
if (!txid) return
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txid}` } })
// Overlay the explorer above the current page never navigate away.
useAppLauncherStore().openSession('mempool', { path: `/tx/${txid}` })
}
function capacityPercent(amount: number, capacity: number): number {

View File

@ -75,9 +75,9 @@
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
interface WalletTransaction {
tx_hash: string
@ -100,7 +100,6 @@ defineProps<{
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const router = useRouter()
function close() {
emit('close')
@ -120,7 +119,8 @@ function kindLabel(tx: WalletTransaction): string {
function openInMempool(tx: WalletTransaction) {
if (!isOnchain(tx)) return
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${tx.tx_hash}` } })
// Overlay the explorer above the current page never navigate away.
useAppLauncherStore().openSession('mempool', { path: `/tx/${tx.tx_hash}` })
}
function formatTxTime(timestamp: number): string {

View File

@ -75,6 +75,15 @@ const DEMO_MOCK_UI: Record<string, string> = {
fedimint: '/app/fedimint/',
fedimintd: '/app/fedimint/',
filebrowser: '/app/filebrowser/',
// Static placeholder dashboards served by the mock backend (DEMO_APP_PAGES).
'btcpay-server': '/app/btcpay-server/',
grafana: '/app/grafana/',
nextcloud: '/app/nextcloud/',
jellyfin: '/app/jellyfin/',
vaultwarden: '/app/vaultwarden/',
'nostr-rs-relay': '/app/nostr-rs-relay/',
searxng: '/app/searxng/',
'uptime-kuma': '/app/uptime-kuma/',
}
/**

View File

@ -1,11 +1,10 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import router from '@/router'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
import { openInAppOrNewTab } from '@/utils/openExternal'
import { IS_DEMO, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
/**
* Open a URL in a new browser tab but if a companion (phone) is currently
@ -209,25 +208,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const showConsent = ref(false)
let previousActiveElement: HTMLElement | null = null
/** Active app in panel mode (store-based, no route change) */
/** Active app in the store-driven session (no route change) */
const panelAppId = ref<string | null>(null)
/** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */
const panelPath = ref<string | null>(null)
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
function dashboardReturnPath(): string {
const current = router.currentRoute?.value
if (!current) return '/dashboard/apps'
const fullPath = current.fullPath || '/dashboard/apps'
if (!fullPath.startsWith('/dashboard') || current.name === 'app-session') return '/dashboard/apps'
return fullPath
}
function openSession(appId: string) {
function openSession(appId: string, opts: { path?: string } = {}) {
recordAppLaunch(appId)
const mobile = isMobileViewport()
// Demo: apps backed by a real external site that blocks iframing
// (mempool.space) open externally; everything else demoable renders in the
// in-app session.
// Demo: apps backed by a real external site that blocks iframing open
// externally; everything else demoable renders in the in-app session.
if (IS_DEMO && isDemoExternal(appId)) {
const ext = demoAppUrl(appId)
if (ext) {
@ -239,7 +230,9 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Tab-only apps (set X-Frame-Options, can't be iframed). No interstitial:
// desktop opens a new browser tab; mobile opens the in-app WebView (Android
// companion) or a new browser tab (PWA) — see openInAppOrNewTab.
if (NEW_TAB_APP_IDS.has(appId)) {
// In the demo, demoable apps are served same-origin by the mock backend
// (no framing headers), so they always render in the in-app session.
if (NEW_TAB_APP_IDS.has(appId) && !(IS_DEMO && isDemoApp(appId))) {
const launchUrl = directAppUrl(appId)
if (launchUrl) {
if (mobile) openInAppOrNewTab(launchUrl)
@ -248,21 +241,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
}
}
// Iframeable apps. Mobile and desktop-panel mode both use the store-driven
// panel so the underlying page/tab never changes (no background swap) and
// closing returns the user to wherever they launched from. Only desktop
// overlay/fullscreen modes use a routed session.
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
if (mobile || mode === 'panel') {
panelAppId.value = appId
} else {
panelAppId.value = null
router.push({ name: 'app-session', params: { appId }, query: { returnTo: dashboardReturnPath() } })
}
// Iframeable apps always use the store-driven session so the underlying
// page never changes: panel mode renders beside the page, overlay and
// fullscreen modes render above it (AppSession styles per display mode).
// Closing always returns the user exactly where they launched from.
panelPath.value = opts.path ?? null
panelAppId.value = appId
}
function closePanel() {
panelAppId.value = null
panelPath.value = null
}
/** Legacy: open app in iframe overlay (kept for backward compat) */
@ -299,9 +288,20 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
return
}
// Route to full-page session if we can resolve an app ID from the URL
// Open the in-app session if we can resolve an app ID from the URL,
// carrying through any deep-link path (e.g. /tx/<hash>).
if (resolvedId) {
openSession(resolvedId)
let deepPath: string | undefined
try {
const u = new URL(launchUrl, window.location.origin)
let p = `${u.pathname}${u.search}${u.hash}`
// /app/<id>/-style URLs carry the mount prefix — strip it so only the
// app-internal path is treated as the deep link.
const prefix = `/app/${resolvedId}`
if (p.startsWith(prefix)) p = p.slice(prefix.length) || '/'
if (p && p !== '/') deepPath = p
} catch { /* no deep link */ }
openSession(resolvedId, { path: deepPath })
return
}
@ -517,6 +517,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
close,
closePanel,
panelAppId,
panelPath,
showConsent,
consentRequest,
approveConsent,

View File

@ -195,6 +195,16 @@ select:focus-visible {
}
@media (max-width: 920px) {
/* Same dynamic-viewport pin as the <768px rule: mobile browsers report 100vh
taller than the visible area (URL/tab-bar chrome), which pushes the bottom
of every page under the browser chrome. Pin the dashboard to the dynamic
viewport across the whole mobile/tablet breakpoint so pages always scroll
fully clear of the tab bar, with the .mobile-scroll-pad margin visible. */
.dashboard-view.dashboard-view {
height: 100dvh;
min-height: 100dvh;
}
.dashboard-view .app-header-inline-tabs {
display: none !important;
}

View File

@ -1,6 +1,6 @@
<template>
<div class="app-session-root">
<Teleport to="body" :disabled="isInlinePanel && !isMobile">
<Teleport to="body" :disabled="inlinePanelMode">
<div
:class="backdropClasses"
@click.self="handleBackdropClick"
@ -92,7 +92,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useAppStore } from '@/stores/app'
@ -110,10 +110,12 @@ import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge'
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
import { useElectrsSync } from '@/composables/useElectrsSync'
import { IS_DEMO, isDemoExternal } from '@/composables/useDemoIntro'
import { IS_DEMO, isDemoApp, isDemoExternal } from '@/composables/useDemoIntro'
const props = defineProps<{
appIdProp?: string
/** Deep-link path inside the app (store-driven sessions), e.g. /tx/<hash> */
pathProp?: string
}>()
const emit = defineEmits<{
@ -167,10 +169,12 @@ const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value ==
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
function updateIsMobile() { isMobile.value = window.innerWidth < 768 }
// In the demo, apps backed by a real external site that blocks iframing
// (mempool.space) open in a new tab rather than the in-app session frame.
// In the demo, apps backed by a real external site that blocks iframing open
// in a new tab rather than the in-app session frame. Demoable apps are served
// same-origin by the mock backend, so the prod new-tab list doesn't apply.
const mustOpenNewTab = computed(() =>
NEW_TAB_APPS.has(appId.value) || (IS_DEMO && isDemoExternal(appId.value))
(NEW_TAB_APPS.has(appId.value) && !(IS_DEMO && isDemoApp(appId.value))) ||
(IS_DEMO && isDemoExternal(appId.value))
)
// ElectrumX shows a sync screen before its real UI (the Electrum server only
@ -200,7 +204,8 @@ const screensaverSuppressedApps = new Set([
const appUrl = computed(() => {
const runtimeUrl = store.data?.['package-data']?.[appId.value]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
return resolveAppUrl(appId.value, route.query.path as string | undefined, runtimeUrl)
const deepPath = props.pathProp ?? (route.query.path as string | undefined)
return resolveAppUrl(appId.value, deepPath, runtimeUrl)
})
function closeRouteSession() {
@ -227,16 +232,8 @@ function setMode(mode: DisplayMode) {
displayMode.value = mode
localStorage.setItem(DISPLAY_MODE_KEY, mode)
// Switch from inline panel to route-based overlay/fullscreen
if (isInlinePanel.value && mode !== 'panel') {
const id = appId.value
emit('close')
const returnTo = route.fullPath.startsWith('/dashboard') ? route.fullPath : '/dashboard/apps'
router.push({ name: 'app-session', params: { appId: id }, query: { returnTo } })
return
}
// Switch from route-based to inline panel
// Route-based sessions (deep links) hand off to the store-driven session so
// the app keeps floating above the dashboard instead of owning the route.
if (!isInlinePanel.value && mode === 'panel') {
const id = appId.value
const launcher = useAppLauncherStore()
@ -250,22 +247,24 @@ function setMode(mode: DisplayMode) {
return
}
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
}
}
// Reactive classes based on display mode. On mobile the store-driven panel
// renders as a full-screen overlay (teleported to body) so it covers the nav
// and the underlying page never changes desktop keeps the inline panel.
// Reactive classes based on display mode. The store-driven session honors the
// selected display mode in place: panel renders inline beside the page,
// overlay/fullscreen render above it (teleported to body) the underlying
// route never changes. Mobile always uses the full overlay.
const inlinePanelMode = computed(() =>
isInlinePanel.value && !isMobile.value && displayMode.value === 'panel'
)
const backdropClasses = computed(() => {
if (isInlinePanel.value && !isMobile.value) return 'app-session-backdrop-inline'
if (inlinePanelMode.value) return 'app-session-backdrop-inline'
return 'app-session-backdrop-overlay'
})
const panelClasses = computed(() => {
const base = 'app-session-panel glass-card'
if (isInlinePanel.value && !isMobile.value) return `${base} app-session-inline`
if (inlinePanelMode.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
return `${base} app-session-overlay`
})
@ -380,9 +379,14 @@ function onMessage(e: MessageEvent) {
// Enter fullscreen on mount if mode is fullscreen
watch(displayMode, (mode) => {
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
}
if (mode !== 'fullscreen') return
// The panel may teleport to <body> on this mode change request fullscreen
// after the DOM settles so we grab the element at its new location.
void nextTick(() => {
if (displayMode.value === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
}
})
})
onMounted(() => {

View File

@ -404,6 +404,7 @@ import {
buildServiceCategories, useServiceCategories,
} from './apps/appsConfig'
import { getCuratedAppList, INSTALLED_ALIASES, type MarketplaceApp } from './marketplace/marketplaceData'
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
const { t } = useI18n()
const router = useRouter()
@ -633,7 +634,9 @@ function launchAppNow(id: string) {
}
return
}
if (!isMobile && pkg && opensInTab(id)) {
// Demo: demoable apps are served same-origin by the mock backend, so the
// tab-launch list (real apps with framing headers) doesn't apply.
if (!isMobile && pkg && opensInTab(id) && !(IS_DEMO && isDemoApp(id))) {
const url = resolveRuntimeLaunchUrl(pkg)
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')

View File

@ -120,7 +120,11 @@
<!-- Panel mode app session renders alongside current page content -->
<Transition name="panel-slide">
<div v-if="appLauncher.panelAppId" class="app-panel-container">
<AppSession :app-id-prop="appLauncher.panelAppId" @close="appLauncher.closePanel()" />
<AppSession
:app-id-prop="appLauncher.panelAppId"
:path-prop="appLauncher.panelPath ?? undefined"
@close="appLauncher.closePanel()"
/>
</div>
</Transition>
</main>

View File

@ -517,7 +517,8 @@ async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet',
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
const walletTransactions = ref<WalletTransaction[]>([])
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
// Overlay the explorer above the current page never navigate away.
function openInMempool(txHash: string) { useAppLauncherStore().openSession('mempool', { path: `/tx/${txHash}` }) }
// wallet.ecash-history's shape (see handle_wallet_ecash_history in
// api/rpc/wallet.rs) distinct from the LND-shaped WalletTransaction used

View File

@ -85,7 +85,7 @@
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ demoNoInstall ? 'No demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
{{ demoNoInstall ? 'Not available in demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
</button>
</div>
</div>
@ -164,7 +164,7 @@
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ demoNoInstall ? 'No demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
{{ demoNoInstall ? 'Not available in demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
</button>
</div>
@ -517,7 +517,7 @@ const installBlockedReason = computed(() => {
return electrumxArchiveWarning
})
// Demo: only demoable apps can be installed; the rest show "No demo".
// Demo: only demoable apps can be installed; the rest show "Not available in demo".
const demoNoInstall = computed(() => IS_DEMO && !!app.value?.id && !isDemoApp(app.value.id))
let pendingRedirect: ReturnType<typeof setTimeout> | null = null

View File

@ -82,7 +82,11 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
// mempool). Non-demoable apps fall through to a generic notice page.
if (IS_DEMO) {
const base = demoAppUrl(id)
if (base) return routeQueryPath ? base + routeQueryPath : base
if (base) {
if (!routeQueryPath) return base
// Join without a double slash (/app/mempool/ + /tx/x → /app/mempool/tx/x)
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : '/' + routeQueryPath)
}
return `/app/${id}/`
}

View File

@ -121,7 +121,7 @@
v-else-if="IS_DEMO && !isInstalled(app.id) && !isDemoApp(app.id)"
disabled
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
>No demo</button>
>Not available in demo</button>
<!-- Install button -->
<button
v-else-if="!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"

View File

@ -78,7 +78,7 @@
v-else-if="IS_DEMO && !isInstalled(app.id) && !isDemoApp(app.id)"
disabled
class="glass-button glass-button-sm rounded-lg text-sm font-medium opacity-50 cursor-not-allowed"
>No demo</button>
>Not available in demo</button>
<button
v-else-if="!isInstalled(app.id) && app.dockerImage"
data-controller-install-btn

View File

@ -148,12 +148,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { formatTxTime } from './utils'
import type { WalletTransaction } from './types'
import { useAppLauncherStore } from '@/stores/appLauncher'
const router = useRouter()
const { t } = useI18n()
const showIncomingTxPanel = ref(false)
@ -179,6 +178,7 @@ defineEmits<{
}>()
function openInMempool(txHash: string) {
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } })
// Overlay the explorer above the current page never navigate away.
useAppLauncherStore().openSession('mempool', { path: `/tx/${txHash}` })
}
</script>

View File

@ -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 },