fix(demo): mock backend overhaul — real media, correct shapes, mempool.guide
- Serve committed demo/content (music/photos/documents) alongside demo/files drop-ins, normalizing top-level folder names; Dockerfile now COPYs it. This is why Cloud music failed: the real library sat in demo/content while the loader only read the empty demo/files. - Drop unbacked seeded Videos; runtime WAV/SVG fallback for any seeded media without a real file so playback never hits 'no supported source'. - monitoring.current/history/alerts/alert-rules/export rewritten to the exact MetricSnapshot shapes Monitoring.vue expects (epoch-second timestamps, containers with limits/block IO, rpc_latency_ms, ws_connections). - streaming.list-services + configure-service (Networking Profits page was erroring with Method not found). - server.get-state snapshot so the 30s resync / reconnect path stops erroring. - lnd-connect-info now returns cert/macaroon base64url + grpc/rest ports (the lnd-ui shell only renders the wallet QR + details when they're present); LND REST balance endpoints for the shell's new balance cards. - Fix broken icon paths (lnd.svg → lnd.png and 15 others) against real assets. - containers-scanned: true so app cards never sit on 'Checking…'. - 8 new installed demo apps with placeholder dashboard UIs (btcpay-server, grafana, nextcloud, jellyfin, vaultwarden, nostr-rs-relay, searxng, uptime-kuma) via the previously unused demoAppShell. - WS heartbeat 45s → 20s (+ping 25s) so idle-timeout proxies in front of the public demo stop killing the socket (the 'always reconnecting' report). - nginx demo proxy: mempool.space → mempool.guide (mempool.space blocks the proxied embed) + WebSocket upgrade passthrough; txid hydration follows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8b0b672674
commit
fa37155ff3
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 & 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 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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user