feat(demo): real registry UIs, IndeeHub iframe proxy, mempool tab, media Range
- App UIs now use the real registry shells with dummy data: bitcoin-ui for
Bitcoin Core (Satoshi subversion) and Bitcoin Knots (Knots subversion) via
per-path /app/bitcoin-{core,knots}/bitcoin-status; the real lnd-ui (mock
/proxy/lnd/v1/getinfo+channels, /lnd-connect-info, /api/container/logs); the
static fedimint-ui. ElectrumX already on the real electrs-ui. Custom mock UIs
dropped — accurate UX.
- IndeeHub loads in the iframe: nginx reverse-proxies /app/indeedhub/ →
indee.tx1138.com and strips X-Frame-Options/CSP (it blocked framing before).
- Mempool opens in a new tab (mempool.space can't be iframed).
- Cloud media playback: HTTP Range support in the curated-file server so audio/
video can stream and seek (needs real files dropped into demo/files/).
- Dockerfile/.dockerignore copy docker/lnd-ui + docker/fedimint-ui.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a0f70b3949
commit
cf5f6d021a
@ -12,6 +12,8 @@
|
|||||||
docker/*
|
docker/*
|
||||||
!docker/bitcoin-ui/
|
!docker/bitcoin-ui/
|
||||||
!docker/electrs-ui/
|
!docker/electrs-ui/
|
||||||
|
!docker/lnd-ui/
|
||||||
|
!docker/fedimint-ui/
|
||||||
|
|
||||||
# Allow backend source for ISO source builds
|
# Allow backend source for ISO source builds
|
||||||
!core/
|
!core/
|
||||||
|
|||||||
@ -18,6 +18,8 @@ COPY neode-ui/ ./
|
|||||||
# the Bitcoin UI mock shell and any curated cloud files dropped into demo/files.
|
# the Bitcoin UI mock shell and any curated cloud files dropped into demo/files.
|
||||||
COPY docker/bitcoin-ui /docker/bitcoin-ui
|
COPY docker/bitcoin-ui /docker/bitcoin-ui
|
||||||
COPY docker/electrs-ui /docker/electrs-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/files /demo/files
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
|
|||||||
@ -70,6 +70,20 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# LND UI endpoints (polled by the lnd-ui shell)
|
||||||
|
location /proxy/ {
|
||||||
|
proxy_pass http://neode-backend:5959;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
location /lnd-connect-info {
|
||||||
|
proxy_pass http://neode-backend:5959;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
# Proxy FileBrowser API to mock backend (demo mode)
|
# Proxy FileBrowser API to mock backend (demo mode)
|
||||||
location /app/filebrowser/ {
|
location /app/filebrowser/ {
|
||||||
client_max_body_size 10G;
|
client_max_body_size 10G;
|
||||||
@ -80,6 +94,19 @@ http {
|
|||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# IndeeHub: reverse-proxy the real site same-origin and strip its
|
||||||
|
# X-Frame-Options / CSP so it can load inside the in-app iframe.
|
||||||
|
location /app/indeedhub/ {
|
||||||
|
proxy_pass https://indee.tx1138.com/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host indee.tx1138.com;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header Accept-Encoding "";
|
||||||
|
proxy_hide_header X-Frame-Options;
|
||||||
|
proxy_hide_header Content-Security-Policy;
|
||||||
|
proxy_hide_header Content-Security-Policy-Report-Only;
|
||||||
|
}
|
||||||
|
|
||||||
# Proxy every other app UI (/app/<id>/) to the mock backend, which serves
|
# Proxy every other app UI (/app/<id>/) to the mock backend, which serves
|
||||||
# the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the
|
# the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the
|
||||||
# generic "Not available in the demo" notice for the rest.
|
# generic "Not available in the demo" notice for the rest.
|
||||||
|
|||||||
@ -1087,65 +1087,83 @@ app.get('/electrs-status', (_req, res) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get(['/app/lnd/', '/app/lnd-ui/', '/app/archy-lnd-ui/', '/app/thunderhub/'], (_req, res) => {
|
// Serve a real registry UI shell (docker/<dir>/index.html), filled by the
|
||||||
res.type('html').send(demoAppShell('LND', 'Lightning Network Daemon · signet', '/assets/img/app-icons/lnd.svg', `
|
// dummy endpoints each shell polls (defined below). This gives accurate UX.
|
||||||
<div class="grid">
|
function serveDockerUI(dir) {
|
||||||
<div class="card"><div class="k">Node</div><div class="v"><span class="badge">● Synced to chain</span></div></div>
|
return async (_req, res) => {
|
||||||
<div class="card"><div class="k">Channel balance</div><div class="v">8,250,000 sat</div></div>
|
try {
|
||||||
<div class="card"><div class="k">On-chain balance</div><div class="v">2,350,000 sat</div></div>
|
const html = await fs.readFile(path.join(__dirname, '..', 'docker', dir, 'index.html'), 'utf8')
|
||||||
<div class="card"><div class="k">Active channels</div><div class="v">4</div></div>
|
res.type('html').send(html)
|
||||||
<div class="card"><div class="k">Peers</div><div class="v">11</div></div>
|
} catch (e) {
|
||||||
<div class="card"><div class="k">Routing (30d)</div><div class="v">1,284 sat</div></div>
|
res.status(500).type('text/plain').send(`Unable to load ${dir} mock: ${e.message}`)
|
||||||
</div>
|
}
|
||||||
<div class="card"><div class="k" style="margin-bottom:8px">Channels</div>
|
}
|
||||||
<table><thead><tr><th>Peer</th><th>Capacity</th><th>Local / Remote</th><th>State</th></tr></thead><tbody>
|
}
|
||||||
<tr><td>ACINQ</td><td>5,000,000</td><td>2,450,000 / 2,550,000</td><td><span class="badge">active</span></td></tr>
|
|
||||||
<tr><td>Wallet of Satoshi</td><td>2,000,000</td><td>1,200,000 / 800,000</td><td><span class="badge">active</span></td></tr>
|
|
||||||
<tr><td>Voltage</td><td>10,000,000</td><td>4,500,000 / 5,500,000</td><td><span class="badge">active</span></td></tr>
|
|
||||||
<tr><td>Kraken</td><td>3,000,000</td><td>1,800,000 / 1,200,000</td><td><span class="badge">active</span></td></tr>
|
|
||||||
</tbody></table>
|
|
||||||
</div>`))
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get(['/app/bitcoin-core/'], (_req, res) => {
|
// ── Bitcoin Core + Knots: the real bitcoin-ui shell, per-implementation status ─
|
||||||
res.type('html').send(demoAppShell('Bitcoin Core', 'Full node · signet', '/assets/img/app-icons/bitcoin-core.png', `
|
app.get('/app/bitcoin-core/', serveDockerUI('bitcoin-ui'))
|
||||||
<div class="grid">
|
app.get('/app/bitcoin-knots/', serveDockerUI('bitcoin-ui'))
|
||||||
<div class="card"><div class="k">Status</div><div class="v"><span class="badge">● Synced</span></div></div>
|
function bitcoinStatusPayload(impl) {
|
||||||
<div class="card"><div class="k">Block height</div><div class="v">902,418</div><div class="bar"><i style="width:100%"></i></div></div>
|
const net = mockBitcoinNetworkInfo()
|
||||||
<div class="card"><div class="k">Chain</div><div class="v">signet</div></div>
|
net.subversion = impl === 'knots' ? '/Satoshi:28.1.0/Knots:20250305/' : '/Satoshi:28.4.0/'
|
||||||
<div class="card"><div class="k">Connections</div><div class="v">18</div></div>
|
return {
|
||||||
<div class="card"><div class="k">Mempool</div><div class="v">12,840 txs</div></div>
|
ok: true, stale: false, updated_at_ms: Date.now(), error: null,
|
||||||
<div class="card"><div class="k">Version</div><div class="v mono">v28.4.0</div></div>
|
blockchain_info: mockBitcoinBlockchainInfo(),
|
||||||
</div>
|
network_info: net,
|
||||||
<div class="card"><div class="k" style="margin-bottom:8px">Peers</div>
|
index_info: {
|
||||||
<table><thead><tr><th>Address</th><th>Type</th><th>Height</th></tr></thead><tbody>
|
txindex: { synced: true, best_block_height: 902418 },
|
||||||
<tr><td>node1.signet…onion</td><td>outbound</td><td>902,418</td></tr>
|
blockfilterindex: { synced: true, best_block_height: 902418 },
|
||||||
<tr><td>node2.signet…onion</td><td>outbound</td><td>902,418</td></tr>
|
},
|
||||||
<tr><td>192.168.1.42</td><td>inbound</td><td>902,417</td></tr>
|
zmq_notifications: [
|
||||||
</tbody></table>
|
{ type: 'pubhashblock', address: 'tcp://127.0.0.1:28332', hwm: 1000 },
|
||||||
</div>`))
|
{ type: 'pubrawtx', address: 'tcp://127.0.0.1:28333', hwm: 1000 },
|
||||||
})
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.get('/app/bitcoin-core/bitcoin-status', (_req, res) => res.json(bitcoinStatusPayload('core')))
|
||||||
|
app.get('/app/bitcoin-knots/bitcoin-status', (_req, res) => res.json(bitcoinStatusPayload('knots')))
|
||||||
|
|
||||||
app.get(['/app/fedimint/', '/app/fedimintd/'], (_req, res) => {
|
// ── LND: the real lnd-ui shell + the endpoints it polls (same-origin) ────────
|
||||||
res.type('html').send(demoAppShell('Fedimint Guardian', 'Archipelago Federation · 4-of-5 consensus', '/assets/img/app-icons/fedimint.png', `
|
app.get(['/app/lnd/', '/app/lnd-ui/', '/app/archy-lnd-ui/', '/app/thunderhub/'], serveDockerUI('lnd-ui'))
|
||||||
<div class="grid">
|
function lndGetinfo() {
|
||||||
<div class="card"><div class="k">Consensus</div><div class="v"><span class="badge">● Online</span></div></div>
|
return {
|
||||||
<div class="card"><div class="k">Epoch</div><div class="v">48,217</div></div>
|
alias: 'archipelago-lnd', identity_pubkey: '02' + randomHex(32),
|
||||||
<div class="card"><div class="k">Guardians online</div><div class="v">5 / 5</div></div>
|
num_active_channels: 4, num_inactive_channels: 0, num_pending_channels: 0, num_peers: 11,
|
||||||
<div class="card"><div class="k">E-cash issued</div><div class="v">12,480,000 sat</div></div>
|
block_height: 902418, block_hash: randomHex(32),
|
||||||
<div class="card"><div class="k">Lightning gateway</div><div class="v"><span class="badge">● Connected</span></div></div>
|
synced_to_chain: true, synced_to_graph: true, version: '0.18.3-beta',
|
||||||
<div class="card"><div class="k">Block sync</div><div class="v">902,418</div><div class="bar"><i style="width:100%"></i></div></div>
|
chains: [{ chain: 'bitcoin', network: 'signet' }],
|
||||||
</div>
|
}
|
||||||
<div class="card"><div class="k" style="margin-bottom:8px">Guardians</div>
|
}
|
||||||
<table><thead><tr><th>Guardian</th><th>Peer</th><th>Status</th></tr></thead><tbody>
|
function lndChannels() {
|
||||||
<tr><td>Guardian Alpha</td><td>peer-0</td><td><span class="badge">online</span></td></tr>
|
const peers = [['ACINQ', 5_000_000, 2_450_000], ['Wallet of Satoshi', 2_000_000, 1_200_000],
|
||||||
<tr><td>Guardian Beta</td><td>peer-1</td><td><span class="badge">online</span></td></tr>
|
['Voltage', 10_000_000, 4_500_000], ['Kraken', 3_000_000, 1_800_000]]
|
||||||
<tr><td>Guardian Gamma</td><td>peer-2</td><td><span class="badge">online</span></td></tr>
|
return {
|
||||||
<tr><td>Guardian Delta</td><td>peer-3</td><td><span class="badge">online</span></td></tr>
|
channels: peers.map(([alias, cap, local]) => ({
|
||||||
<tr><td>Guardian Epsilon</td><td>peer-4</td><td><span class="badge">online</span></td></tr>
|
active: true, remote_pubkey: '02' + randomHex(32), peer_alias: alias,
|
||||||
</tbody></table>
|
capacity: String(cap), local_balance: String(local), remote_balance: String(cap - local),
|
||||||
</div>`))
|
total_satoshis_sent: '12840', total_satoshis_received: '9120', private: false,
|
||||||
})
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.get('/proxy/lnd/v1/getinfo', (_req, res) => res.json(lndGetinfo()))
|
||||||
|
app.get('/proxy/lnd/v1/channels', (_req, res) => res.json(lndChannels()))
|
||||||
|
app.get('/lnd-connect-info', (_req, res) => res.json({
|
||||||
|
getinfo: lndGetinfo(), channelCount: 4, cert_base: 'demo',
|
||||||
|
grpcReachable: true, restReachable: true,
|
||||||
|
tor_onion: 'lnd' + randomHex(16) + '.onion', error: null,
|
||||||
|
}))
|
||||||
|
app.get('/api/container/logs', (_req, res) => res.json({
|
||||||
|
logs: [
|
||||||
|
'[INF] LND: Version 0.18.3-beta commit=v0.18.3-beta',
|
||||||
|
'[INF] CHDB: Inserting 4 channel(s) into database',
|
||||||
|
'[INF] LND: Channel(s) active; synced_to_chain=true',
|
||||||
|
'[INF] PEER: Connected to 11 peers',
|
||||||
|
'[INF] RPCS: gRPC proxy started at 0.0.0.0:8080',
|
||||||
|
].join('\n'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Fedimint: the real fedimint-ui shell (static) ────────────────────────────
|
||||||
|
app.get(['/app/fedimint/', '/app/fedimintd/'], serveDockerUI('fedimint-ui'))
|
||||||
|
|
||||||
app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => {
|
app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
@ -3705,12 +3723,31 @@ app.patch('/app/filebrowser/api/resources/*', (req, res) => {
|
|||||||
// FileBrowser raw file content (text reads, blob/stream fetches)
|
// FileBrowser raw file content (text reads, blob/stream fetches)
|
||||||
app.get('/app/filebrowser/api/raw/*', (req, res) => {
|
app.get('/app/filebrowser/api/raw/*', (req, res) => {
|
||||||
const full = fbNormalize(req.params[0] || '')
|
const full = fbNormalize(req.params[0] || '')
|
||||||
// Curated binary files live on disk and stream directly.
|
// Curated binary files live on disk and stream directly, with HTTP Range
|
||||||
|
// support so audio/video can seek and play in the browser.
|
||||||
if (diskFilePaths[full]) {
|
if (diskFilePaths[full]) {
|
||||||
|
const abs = diskFilePaths[full]
|
||||||
|
let stat
|
||||||
|
try { stat = fsSync.statSync(abs) } catch { return res.status(404).send('File not found') }
|
||||||
res.type(fbContentType(fbBase(full)))
|
res.type(fbContentType(fbBase(full)))
|
||||||
return fsSync.createReadStream(diskFilePaths[full]).on('error', () => {
|
res.setHeader('Accept-Ranges', 'bytes')
|
||||||
if (!res.headersSent) res.status(404).send('File not found')
|
const range = req.headers.range
|
||||||
}).pipe(res)
|
if (range) {
|
||||||
|
const m = /bytes=(\d*)-(\d*)/.exec(range)
|
||||||
|
let start = m && m[1] ? parseInt(m[1], 10) : 0
|
||||||
|
let end = m && m[2] ? parseInt(m[2], 10) : stat.size - 1
|
||||||
|
if (isNaN(start) || start < 0) start = 0
|
||||||
|
if (isNaN(end) || end >= stat.size) end = stat.size - 1
|
||||||
|
if (start > end) { res.status(416).setHeader('Content-Range', `bytes */${stat.size}`); return res.end() }
|
||||||
|
res.status(206)
|
||||||
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`)
|
||||||
|
res.setHeader('Content-Length', end - start + 1)
|
||||||
|
return fsSync.createReadStream(abs, { start, end })
|
||||||
|
.on('error', () => { if (!res.headersSent) res.status(404).end() }).pipe(res)
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Length', stat.size)
|
||||||
|
return fsSync.createReadStream(abs)
|
||||||
|
.on('error', () => { if (!res.headersSent) res.status(404).send('File not found') }).pipe(res)
|
||||||
}
|
}
|
||||||
const content = currentStore().files.contents[full]
|
const content = currentStore().files.contents[full]
|
||||||
if (content === undefined) return res.status(404).send('File not found')
|
if (content === undefined) return res.status(404).send('File not found')
|
||||||
|
|||||||
@ -53,15 +53,16 @@ export function clearDemoIntroSeen(): void {
|
|||||||
// external site). Everything else shows "No demo" on a disabled install button
|
// external site). Everything else shows "No demo" on a disabled install button
|
||||||
// and is not launchable.
|
// and is not launchable.
|
||||||
const DEMO_EXTERNAL_URLS: Record<string, string> = {
|
const DEMO_EXTERNAL_URLS: Record<string, string> = {
|
||||||
// Real, public sites loaded in the in-app iframe.
|
// Real, public sites opened in a NEW TAB (they block iframing).
|
||||||
indeedhub: 'https://indee.tx1138.com/',
|
|
||||||
mempool: 'https://mempool.space/testnet',
|
mempool: 'https://mempool.space/testnet',
|
||||||
'mempool-web': 'https://mempool.space/testnet',
|
'mempool-web': 'https://mempool.space/testnet',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apps with a same-origin mock UI served by the demo backend.
|
// Apps with a same-origin URL loaded in the in-app iframe — either a mock UI
|
||||||
|
// served by the demo backend, or a header-stripping reverse-proxy (IndeeHub).
|
||||||
const DEMO_MOCK_UI: Record<string, string> = {
|
const DEMO_MOCK_UI: Record<string, string> = {
|
||||||
'bitcoin-knots': '/app/bitcoin-ui/',
|
indeedhub: '/app/indeedhub/',
|
||||||
|
'bitcoin-knots': '/app/bitcoin-knots/',
|
||||||
'bitcoin-core': '/app/bitcoin-core/',
|
'bitcoin-core': '/app/bitcoin-core/',
|
||||||
bitcoin: '/app/bitcoin-core/',
|
bitcoin: '/app/bitcoin-core/',
|
||||||
'bitcoin-ui': '/app/bitcoin-ui/',
|
'bitcoin-ui': '/app/bitcoin-ui/',
|
||||||
@ -78,11 +79,11 @@ const DEMO_MOCK_UI: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a demo app opens in a new tab. Nothing does now — both real external
|
* Whether a demo app opens in a new tab. Mempool.space sets X-Frame-Options and
|
||||||
* sites (mempool.space, indee.tx1138.com) load in the in-app iframe.
|
* cannot be iframed, so it opens in a new tab; IndeeHub still loads in-iframe.
|
||||||
*/
|
*/
|
||||||
export function isDemoExternal(_appId: string): boolean {
|
export function isDemoExternal(appId: string): boolean {
|
||||||
return false
|
return appId === 'mempool' || appId === 'mempool-web'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Can this app be launched/installed in the demo? */
|
/** Can this app be launched/installed in the demo? */
|
||||||
|
|||||||
@ -157,7 +157,11 @@ export default defineConfig({
|
|||||||
'/app/lnd': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
'/app/lnd': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||||
'/app/fedimint': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
'/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-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 },
|
||||||
'/electrs-status': { 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 },
|
||||||
|
'/app/indeedhub': { target: 'https://indee.tx1138.com', changeOrigin: true, secure: false, rewrite: (p: string) => p.replace(/^\/app\/indeedhub/, '') },
|
||||||
// Serve the node's deployed AIUI same-origin like production (set VITE_AIUI_URL=/aiui/)
|
// Serve the node's deployed AIUI same-origin like production (set VITE_AIUI_URL=/aiui/)
|
||||||
'/aiui': {
|
'/aiui': {
|
||||||
target: process.env.AIUI_PROXY_TARGET || 'http://127.0.0.1:80',
|
target: process.env.AIUI_PROXY_TARGET || 'http://127.0.0.1:80',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user