Merge pull request 'Demo fixes: real media, LND QR/balances, mempool.guide, overlay sessions, mobile scroll' (#76) from demo-fixes-20260714 into main
This commit is contained in:
commit
85f9fc2296
@ -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">₿</span>
|
||||
<div>
|
||||
<p class="text-xs text-white-60 mb-1">On-chain</p>
|
||||
<p class="text-sm font-medium text-white" id="onchainBalance">—</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">☉</span>
|
||||
<div>
|
||||
<p class="text-xs text-white-60 mb-1">Lightning Balance</p>
|
||||
<p class="text-sm font-medium text-white" id="channelBalance">—</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);
|
||||
}
|
||||
|
||||
@ -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`** |
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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/',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}/`
|
||||
}
|
||||
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user