chore: snapshot release workspace
This commit is contained in:
parent
6a30ff11bd
commit
d6f108d818
14
CHANGELOG.md
14
CHANGELOG.md
@ -1,9 +1,21 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.7.85-alpha (2026-06-12)
|
||||||
|
|
||||||
|
- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.
|
||||||
|
- Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.
|
||||||
|
- LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.
|
||||||
|
- Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.
|
||||||
|
- Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.
|
||||||
|
- Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting.
|
||||||
|
|
||||||
## v1.7.84-alpha (2026-06-11)
|
## v1.7.84-alpha (2026-06-11)
|
||||||
|
|
||||||
- Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.
|
- Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.
|
||||||
- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and sendrawtransaction-focused whitelist.
|
- Kiosk mode now includes a browser safe-area path for HDMI displays that crop edges, and self-update refreshes kiosk launcher/systemd files so display fixes ship to existing nodes. The experimental X11 scaling safe-area is opt-in to avoid stretching TV output.
|
||||||
|
- Wi-Fi setup now reports scan errors instead of showing an empty network list, supports retrying scans from the modal, parses escaped `nmcli` SSIDs correctly, and can join open networks without forcing a WPA password.
|
||||||
|
- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist.
|
||||||
|
- The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC.
|
||||||
- The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.
|
- The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.
|
||||||
- Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.
|
- Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.
|
||||||
- Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path.
|
- Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path.
|
||||||
|
|||||||
@ -122,7 +122,7 @@ echo ""
|
|||||||
# Install custom app dependencies
|
# Install custom app dependencies
|
||||||
echo "Installing custom app dependencies..."
|
echo "Installing custom app dependencies..."
|
||||||
|
|
||||||
for app in did-wallet endurain morphos-server router web5-dwn; do
|
for app in did-wallet endurain morphos-server router; do
|
||||||
if [ -d "apps/$app" ]; then
|
if [ -d "apps/$app" ]; then
|
||||||
echo " - Installing $app dependencies..."
|
echo " - Installing $app dependencies..."
|
||||||
cd "apps/$app"
|
cd "apps/$app"
|
||||||
|
|||||||
@ -20,8 +20,8 @@
|
|||||||
- **Mempool** block explorer and fee estimator
|
- **Mempool** block explorer and fee estimator
|
||||||
- **Fedimint** federation guardian and gateway
|
- **Fedimint** federation guardian and gateway
|
||||||
|
|
||||||
### Self-Hosted Apps (30)
|
### Self-Hosted Apps (29)
|
||||||
Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
|
Bitcoin, Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
|
||||||
|
|
||||||
### Decentralized Identity
|
### Decentralized Identity
|
||||||
- Ed25519 node identity with DID Documents (did:key)
|
- Ed25519 node identity with DID Documents (did:key)
|
||||||
|
|||||||
@ -425,7 +425,7 @@
|
|||||||
"author": "Portainer",
|
"author": "Portainer",
|
||||||
"category": "development",
|
"category": "development",
|
||||||
"tier": "optional",
|
"tier": "optional",
|
||||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:latest",
|
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||||
"repoUrl": "https://github.com/portainer/portainer",
|
"repoUrl": "https://github.com/portainer/portainer",
|
||||||
"containerConfig": {
|
"containerConfig": {
|
||||||
"ports": [
|
"ports": [
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
|
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
|
||||||
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
|
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
|
||||||
| btcpay-server | 23000 (HTTP) | v1.13.5 |
|
| btcpay-server | 23000 (HTTP) | v1.13.5 |
|
||||||
| thunderhub | 3010 (HTTP) | v0.13.31 |
|
|
||||||
| mempool | 4080 (HTTP) | v2.5.0 |
|
| mempool | 4080 (HTTP) | v2.5.0 |
|
||||||
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
|
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
|
||||||
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
|
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
|
||||||
@ -43,7 +42,7 @@ cd apps
|
|||||||
./build.sh <app-id> # Build specific app
|
./build.sh <app-id> # Build specific app
|
||||||
```
|
```
|
||||||
|
|
||||||
Custom apps with local source: `router`, `did-wallet`, `web5-dwn`. All other apps use official container images.
|
Custom apps with local source: `router`, `did-wallet`. All other apps use official container images.
|
||||||
|
|
||||||
## App Structure
|
## App Structure
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,6 @@ This document lists all port assignments for Archipelago apps.
|
|||||||
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
|
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
|
||||||
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
||||||
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
||||||
| web5-dwn | 3000 | TCP | HTTP API | 13000 |
|
|
||||||
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
||||||
|
|
||||||
## Development Ports (Offset: +10000)
|
## Development Ports (Offset: +10000)
|
||||||
@ -53,7 +52,6 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
|
|||||||
| Strfry | http://localhost:18082 |
|
| Strfry | http://localhost:18082 |
|
||||||
| DID Wallet | http://localhost:18083 |
|
| DID Wallet | http://localhost:18083 |
|
||||||
| Router | http://localhost:18084 |
|
| Router | http://localhost:18084 |
|
||||||
| Web5 DWN | http://localhost:13000 |
|
|
||||||
| Meshtastic | http://localhost:14403 |
|
| Meshtastic | http://localhost:14403 |
|
||||||
|
|
||||||
## Port Conflict Resolution
|
## Port Conflict Resolution
|
||||||
|
|||||||
@ -30,14 +30,13 @@ cd apps
|
|||||||
./build.sh
|
./build.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet, web5-dwn) will be built from source.
|
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet) will be built from source.
|
||||||
|
|
||||||
### Build Specific App
|
### Build Specific App
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./build.sh router
|
./build.sh router
|
||||||
./build.sh did-wallet
|
./build.sh did-wallet
|
||||||
./build.sh web5-dwn
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running Apps via Archipelago
|
## Running Apps via Archipelago
|
||||||
@ -64,7 +63,6 @@ In development mode, apps are accessible on offset ports:
|
|||||||
|
|
||||||
- **Router**: http://localhost:18084
|
- **Router**: http://localhost:18084
|
||||||
- **DID Wallet**: http://localhost:18083
|
- **DID Wallet**: http://localhost:18083
|
||||||
- **Web5 DWN**: http://localhost:13000
|
|
||||||
- **Nostr RS Relay**: http://localhost:18081
|
- **Nostr RS Relay**: http://localhost:18081
|
||||||
- **Strfry**: http://localhost:18082
|
- **Strfry**: http://localhost:18082
|
||||||
|
|
||||||
@ -72,7 +70,7 @@ See [PORTS.md](./PORTS.md) for complete port mapping.
|
|||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
### For Custom Apps (router, did-wallet, web5-dwn)
|
### For Custom Apps (router, did-wallet)
|
||||||
|
|
||||||
1. **Make changes** to source code in `apps/<app-id>/src/`
|
1. **Make changes** to source code in `apps/<app-id>/src/`
|
||||||
2. **Rebuild** the container:
|
2. **Rebuild** the container:
|
||||||
|
|||||||
@ -8,7 +8,6 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
|
|||||||
- **bitcoin-knots** — Full Bitcoin node (v28.1)
|
- **bitcoin-knots** — Full Bitcoin node (v28.1)
|
||||||
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
|
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
|
||||||
- **btcpay-server** — Payment processor (v1.13.5)
|
- **btcpay-server** — Payment processor (v1.13.5)
|
||||||
- **thunderhub** — Lightning management UI (v0.13.31)
|
|
||||||
- **mempool** — Block explorer and fee estimator (v2.5.0)
|
- **mempool** — Block explorer and fee estimator (v2.5.0)
|
||||||
- **electrumx** — Electrum server
|
- **electrumx** — Electrum server
|
||||||
- **fedimint** — Federated Bitcoin minting (v0.10.0)
|
- **fedimint** — Federated Bitcoin minting (v0.10.0)
|
||||||
@ -18,7 +17,6 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
|
|||||||
- **nostrudel** — Nostr web client (v0.40.0)
|
- **nostrudel** — Nostr web client (v0.40.0)
|
||||||
|
|
||||||
### Web5 & Identity
|
### Web5 & Identity
|
||||||
- **web5-dwn** — Decentralized Web Node (v0.4.0)
|
|
||||||
- **did-wallet** — Web5 DID Wallet
|
- **did-wallet** — Web5 DID Wallet
|
||||||
|
|
||||||
### Self-Hosted Services
|
### Self-Hosted Services
|
||||||
|
|||||||
@ -33,7 +33,7 @@ app:
|
|||||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||||
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
|
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
|
||||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee";
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||||
fi;
|
fi;
|
||||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||||
|
|||||||
@ -33,7 +33,7 @@ app:
|
|||||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||||
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
|
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
|
||||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee";
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||||
fi;
|
fi;
|
||||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||||
|
|||||||
@ -10,8 +10,6 @@ app:
|
|||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- app_id: web5-dwn
|
|
||||||
version: ">=1.0.0"
|
|
||||||
- storage: 2Gi
|
- storage: 2Gi
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
@ -40,7 +38,6 @@ app:
|
|||||||
options: [rw]
|
options: [rw]
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- DWN_ENDPOINT=http://web5-dwn:3000
|
|
||||||
- WALLET_STORAGE=/app/wallet
|
- WALLET_STORAGE=/app/wallet
|
||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
|
|||||||
@ -34,5 +34,4 @@ app.post('/api/wallet/did/create', async (req, res) => {
|
|||||||
// Start server
|
// Start server
|
||||||
app.listen(port, '0.0.0.0', () => {
|
app.listen(port, '0.0.0.0', () => {
|
||||||
console.log(`DID Wallet listening on port ${port}`);
|
console.log(`DID Wallet listening on port ${port}`);
|
||||||
console.log(`DWN endpoint: ${process.env.DWN_ENDPOINT || 'http://web5-dwn:3000'}`);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -25,7 +25,7 @@ app:
|
|||||||
|
|
||||||
resources:
|
resources:
|
||||||
cpu_limit: 0
|
cpu_limit: 0
|
||||||
memory_limit: 4Gi
|
memory_limit: 6Gi
|
||||||
disk_limit: 50Gi
|
disk_limit: 50Gi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
@ -48,7 +48,7 @@ app:
|
|||||||
- COIN=Bitcoin
|
- COIN=Bitcoin
|
||||||
- DB_DIRECTORY=/data
|
- DB_DIRECTORY=/data
|
||||||
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
|
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
|
||||||
- CACHE_MB=3072
|
- CACHE_MB=1024
|
||||||
- MAX_SEND=10000000
|
- MAX_SEND=10000000
|
||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
|
|||||||
@ -6,7 +6,7 @@ app:
|
|||||||
category: development
|
category: development
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: 146.59.87.168:3000/lfg2025/portainer:latest
|
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
data_uid: "1000:1000"
|
data_uid: "1000:1000"
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
*.log
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
README.md
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy built application
|
|
||||||
COPY --from=builder /app/dist ./dist
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/package.json ./
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN addgroup -g 1000 appuser && \
|
|
||||||
adduser -D -u 1000 -G appuser appuser && \
|
|
||||||
mkdir -p /app/data && \
|
|
||||||
chown -R appuser:appuser /app
|
|
||||||
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
ENV DWN_STORAGE_PATH=/app/data
|
|
||||||
ENV DID_METHOD=key
|
|
||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
# Web5 DWN (Decentralized Web Node)
|
|
||||||
|
|
||||||
Personal data store for Web5. Store and sync your decentralized data across devices.
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From the apps directory
|
|
||||||
./build.sh web5-dwn
|
|
||||||
|
|
||||||
# Or manually
|
|
||||||
cd web5-dwn
|
|
||||||
docker build -t archipelago/web5-dwn:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web5-dwn
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
|
|
||||||
- **3000**: HTTP API (dev: 13000)
|
|
||||||
|
|
||||||
## Running Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -p 3000:3000 \
|
|
||||||
-v /tmp/archipelago-dev/web5-dwn:/app/data \
|
|
||||||
-e DWN_STORAGE_PATH=/app/data \
|
|
||||||
archipelago/web5-dwn:latest
|
|
||||||
```
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
app:
|
|
||||||
id: web5-dwn
|
|
||||||
name: Decentralized Web Node
|
|
||||||
version: 1.0.0
|
|
||||||
description: Personal data store for Web5. Store and sync your decentralized data across devices.
|
|
||||||
|
|
||||||
container:
|
|
||||||
image: archipelago/web5-dwn:1.0.0
|
|
||||||
image_signature: cosign://...
|
|
||||||
pull_policy: if-not-present
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
- storage: 5Gi
|
|
||||||
|
|
||||||
resources:
|
|
||||||
cpu_limit: 1
|
|
||||||
memory_limit: 512Mi
|
|
||||||
disk_limit: 5Gi
|
|
||||||
|
|
||||||
security:
|
|
||||||
capabilities: []
|
|
||||||
readonly_root: true
|
|
||||||
no_new_privileges: true
|
|
||||||
user: 1000
|
|
||||||
seccomp_profile: default
|
|
||||||
network_policy: isolated
|
|
||||||
apparmor_profile: web5-dwn
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- host: 3000
|
|
||||||
container: 3000
|
|
||||||
protocol: tcp # HTTP API
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: /var/lib/archipelago/web5-dwn
|
|
||||||
target: /app/data
|
|
||||||
options: [rw]
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- DWN_STORAGE_PATH=/app/data
|
|
||||||
- DID_METHOD=key
|
|
||||||
|
|
||||||
health_check:
|
|
||||||
type: http
|
|
||||||
endpoint: http://localhost:3000
|
|
||||||
path: /health
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
web5_integration:
|
|
||||||
did_support: true
|
|
||||||
dwn_protocol: true
|
|
||||||
sync_enabled: true
|
|
||||||
2747
apps/web5-dwn/package-lock.json
generated
2747
apps/web5-dwn/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "web5-dwn",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Decentralized Web Node for Web5",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"dev": "ts-node src/index.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^4.18.2",
|
|
||||||
"@web5/api": "^0.9.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/node": "^20.10.0",
|
|
||||||
"typescript": "^5.3.3",
|
|
||||||
"ts-node": "^10.9.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const port = 3000;
|
|
||||||
|
|
||||||
// Middleware
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', service: 'web5-dwn' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// DWN API endpoints
|
|
||||||
app.post('/dwn', async (req, res) => {
|
|
||||||
// Placeholder for DWN protocol implementation
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
message: 'DWN protocol endpoint (placeholder)'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/dwn', async (req, res) => {
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
message: 'DWN query endpoint (placeholder)'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
app.listen(port, '0.0.0.0', () => {
|
|
||||||
console.log(`Web5 DWN listening on port ${port}`);
|
|
||||||
console.log(`Storage path: ${process.env.DWN_STORAGE_PATH || '/app/data'}`);
|
|
||||||
});
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"module": "commonjs",
|
|
||||||
"lib": ["ES2020"],
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@ -227,6 +227,9 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let report = serde_json::json!({
|
let report = serde_json::json!({
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
|
"node_name": data.server_info.name.clone().filter(|n| !n.trim().is_empty()),
|
||||||
|
"hostname": system_hostname().await,
|
||||||
|
"server_url": local_server_url(&self.config.host_ip),
|
||||||
"version": data.server_info.version,
|
"version": data.server_info.version,
|
||||||
"uptime_secs": uptime_secs,
|
"uptime_secs": uptime_secs,
|
||||||
"cpu_cores": cpu_cores,
|
"cpu_cores": cpu_cores,
|
||||||
@ -507,3 +510,24 @@ impl RpcHandler {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn system_hostname() -> Option<String> {
|
||||||
|
let output = tokio::process::Command::new("hostname")
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
(!hostname.is_empty()).then_some(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_server_url(host_ip: &str) -> Option<String> {
|
||||||
|
let host_ip = host_ip.trim();
|
||||||
|
if host_ip.is_empty() || host_ip == "127.0.0.1" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(format!("https://{host_ip}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -659,8 +659,8 @@ async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredential
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let rpcauth = match read_trimmed(&rpcauth_path).await {
|
let rpcauth = match read_trimmed(&rpcauth_path).await {
|
||||||
Some(value) => value,
|
Some(value) if rpcauth_matches_password(&value, TXRELAY_USER, &password) => value,
|
||||||
None => {
|
_ => {
|
||||||
let generated = generate_rpcauth(TXRELAY_USER, &password);
|
let generated = generate_rpcauth(TXRELAY_USER, &password);
|
||||||
write_secret_file(&rpcauth_path, &generated).await?;
|
write_secret_file(&rpcauth_path, &generated).await?;
|
||||||
generated
|
generated
|
||||||
@ -729,6 +729,24 @@ fn generate_rpcauth(username: &str, password: &str) -> String {
|
|||||||
format!("{username}:{salt_hex}${hash_hex}")
|
format!("{username}:{salt_hex}${hash_hex}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rpcauth_matches_password(rpcauth: &str, username: &str, password: &str) -> bool {
|
||||||
|
let Some(rest) = rpcauth.strip_prefix(&format!("{username}:")) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some((salt_hex, expected_hash)) = rest.split_once('$') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if salt_hex.is_empty() || expected_hash.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
mac.update(password.as_bytes());
|
||||||
|
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||||
|
hash_hex.eq_ignore_ascii_case(expected_hash)
|
||||||
|
}
|
||||||
|
|
||||||
fn preferred_endpoint(settings: &BitcoinRelaySettings) -> Option<String> {
|
fn preferred_endpoint(settings: &BitcoinRelaySettings) -> Option<String> {
|
||||||
if settings.allow_https {
|
if settings.allow_https {
|
||||||
if let Some(endpoint) = settings.https_endpoint.clone() {
|
if let Some(endpoint) = settings.https_endpoint.clone() {
|
||||||
|
|||||||
@ -731,7 +731,6 @@ fn health_probe_url_for_app(app_id: &str) -> Option<String> {
|
|||||||
"bitcoin-ui" => 8334,
|
"bitcoin-ui" => 8334,
|
||||||
"botfights" => 9100,
|
"botfights" => 9100,
|
||||||
"btcpay-server" | "btcpay" | "btcpayserver" => 23000,
|
"btcpay-server" | "btcpay" | "btcpayserver" => 23000,
|
||||||
"dwn" => 3100,
|
|
||||||
"electrumx" | "electrs" | "mempool-electrs" | "electrs-ui" => 50002,
|
"electrumx" | "electrs" | "mempool-electrs" | "electrs-ui" => 50002,
|
||||||
"fedimint" | "fedimintd" => 8175,
|
"fedimint" | "fedimintd" => 8175,
|
||||||
"filebrowser" => 8083,
|
"filebrowser" => 8083,
|
||||||
|
|||||||
@ -31,7 +31,7 @@ impl RpcHandler {
|
|||||||
let password = params
|
let password = params
|
||||||
.get("password")
|
.get("password")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
|
.unwrap_or("");
|
||||||
|
|
||||||
// Validate SSID (prevent command injection)
|
// Validate SSID (prevent command injection)
|
||||||
if ssid.len() > 64 || ssid.contains('\0') {
|
if ssid.len() > 64 || ssid.contains('\0') {
|
||||||
@ -284,7 +284,7 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
|
|||||||
let networks: Vec<serde_json::Value> = stdout
|
let networks: Vec<serde_json::Value> = stdout
|
||||||
.lines()
|
.lines()
|
||||||
.filter_map(|line| {
|
.filter_map(|line| {
|
||||||
let parts: Vec<&str> = line.splitn(3, ':').collect();
|
let parts = split_nmcli_escaped(line, 3);
|
||||||
if parts.len() < 3 {
|
if parts.len() < 3 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@ -305,6 +305,28 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
|
|||||||
Ok(networks)
|
Ok(networks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut chars = line.chars();
|
||||||
|
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if ch == '\\' {
|
||||||
|
if let Some(next) = chars.next() {
|
||||||
|
current.push(next);
|
||||||
|
}
|
||||||
|
} else if ch == ':' && fields.len() + 1 < limit {
|
||||||
|
fields.push(current);
|
||||||
|
current = String::new();
|
||||||
|
} else {
|
||||||
|
current.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(current);
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect to a WiFi network using nmcli.
|
/// Connect to a WiFi network using nmcli.
|
||||||
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
|
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
|
||||||
let conn_name = format!("archipelago-wifi-{ssid}");
|
let conn_name = format!("archipelago-wifi-{ssid}");
|
||||||
@ -321,27 +343,28 @@ async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
|
|||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let mut args = vec![
|
||||||
|
"connection",
|
||||||
|
"add",
|
||||||
|
"type",
|
||||||
|
"wifi",
|
||||||
|
"con-name",
|
||||||
|
&conn_name,
|
||||||
|
"ifname",
|
||||||
|
"*",
|
||||||
|
"ssid",
|
||||||
|
ssid,
|
||||||
|
"ipv4.method",
|
||||||
|
"auto",
|
||||||
|
"ipv6.method",
|
||||||
|
"auto",
|
||||||
|
];
|
||||||
|
if !password.is_empty() {
|
||||||
|
args.extend(["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password]);
|
||||||
|
}
|
||||||
|
|
||||||
let output = tokio::process::Command::new("nmcli")
|
let output = tokio::process::Command::new("nmcli")
|
||||||
.args([
|
.args(args)
|
||||||
"connection",
|
|
||||||
"add",
|
|
||||||
"type",
|
|
||||||
"wifi",
|
|
||||||
"con-name",
|
|
||||||
&conn_name,
|
|
||||||
"ifname",
|
|
||||||
"*",
|
|
||||||
"ssid",
|
|
||||||
ssid,
|
|
||||||
"wifi-sec.key-mgmt",
|
|
||||||
"wpa-psk",
|
|
||||||
"wifi-sec.psk",
|
|
||||||
password,
|
|
||||||
"ipv4.method",
|
|
||||||
"auto",
|
|
||||||
"ipv6.method",
|
|
||||||
"auto",
|
|
||||||
])
|
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.context("Failed to run nmcli wifi profile create")?;
|
.context("Failed to run nmcli wifi profile create")?;
|
||||||
|
|||||||
@ -13,18 +13,33 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
||||||
|
.query(&[("type", "WITNESS_PUBKEY_HASH")])
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("LND REST connection failed")?;
|
.context("LND REST connection failed")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp
|
let body: serde_json::Value = resp
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.context("Failed to parse newaddress response")?;
|
.context("Failed to parse newaddress response")?;
|
||||||
|
|
||||||
if let Some(error) = body.get("error").and_then(|v| v.as_str()) {
|
if !status.is_success() {
|
||||||
anyhow::bail!("LND could not generate an address: {}", error);
|
let message = lnd_error_message(&body);
|
||||||
|
anyhow::bail!(
|
||||||
|
"LND could not generate a Bitcoin address ({}): {}",
|
||||||
|
status,
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(error) = body
|
||||||
|
.get("error")
|
||||||
|
.or_else(|| body.get("message"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
anyhow::bail!("LND could not generate a Bitcoin address: {}", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
let address = body
|
let address = body
|
||||||
@ -548,3 +563,35 @@ impl RpcHandler {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lnd_error_message(body: &serde_json::Value) -> String {
|
||||||
|
body.get("message")
|
||||||
|
.or_else(|| body.get("error"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or("unknown LND error")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::lnd_error_message;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lnd_error_message_prefers_message_field() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"error": "grpc proxy error",
|
||||||
|
"message": "wallet locked",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(lnd_error_message(&body), "wallet locked");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lnd_error_message_falls_back_to_unknown() {
|
||||||
|
assert_eq!(
|
||||||
|
lnd_error_message(&serde_json::json!({})),
|
||||||
|
"unknown LND error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -312,11 +312,6 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
|||||||
"30s",
|
"30s",
|
||||||
"3",
|
"3",
|
||||||
),
|
),
|
||||||
"dwn" => (
|
|
||||||
"curl -sf http://localhost:3000/health || exit 1",
|
|
||||||
"30s",
|
|
||||||
"3",
|
|
||||||
),
|
|
||||||
"portainer" => return vec![],
|
"portainer" => return vec![],
|
||||||
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
|
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
|
||||||
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
|
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
|
||||||
@ -360,10 +355,10 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
|||||||
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
|
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
|
||||||
// floor; ideally this would be host-RAM aware (next pass).
|
// floor; ideally this would be host-RAM aware (next pass).
|
||||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
|
||||||
// ElectrumX: large cache materially speeds initial history indexing.
|
// ElectrumX indexing spikes above its cache size due Python,
|
||||||
// CACHE_MB=3072 below needs container headroom for Python, rocksdb,
|
// RocksDB, socket buffers, and reorg/history work. Keep cache
|
||||||
// socket buffers, and reorg/indexing spikes.
|
// conservative and give the process headroom to avoid restart loops.
|
||||||
"electrumx" | "mempool-electrs" | "electrs" => "4g",
|
"electrumx" | "mempool-electrs" | "electrs" => "6g",
|
||||||
"cryptpad" => "512m",
|
"cryptpad" => "512m",
|
||||||
"ollama" => "4g",
|
"ollama" => "4g",
|
||||||
// Medium apps
|
// Medium apps
|
||||||
@ -384,7 +379,6 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
|||||||
"uptime-kuma" => "256m",
|
"uptime-kuma" => "256m",
|
||||||
"filebrowser" => "256m",
|
"filebrowser" => "256m",
|
||||||
"searxng" => "512m",
|
"searxng" => "512m",
|
||||||
"dwn" => "256m",
|
|
||||||
"portainer" => "256m",
|
"portainer" => "256m",
|
||||||
"nostr-rs-relay" | "nostr-relay" => "256m",
|
"nostr-rs-relay" | "nostr-relay" => "256m",
|
||||||
"routstr" => "512m",
|
"routstr" => "512m",
|
||||||
@ -789,11 +783,9 @@ pub(super) async fn get_app_config(
|
|||||||
"COIN=Bitcoin".to_string(),
|
"COIN=Bitcoin".to_string(),
|
||||||
"DB_DIRECTORY=/data".to_string(),
|
"DB_DIRECTORY=/data".to_string(),
|
||||||
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
|
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
|
||||||
// Sync-speed: bigger LRU/write cache during initial
|
// Keep cache below the container limit; high values
|
||||||
// history index. Default is 1200MB; the container gets
|
// have caused OOM/restart loops during catch-up.
|
||||||
// 4g (config.rs::get_memory_limit) so 3072 fits with
|
"CACHE_MB=1024".to_string(),
|
||||||
// headroom.
|
|
||||||
"CACHE_MB=3072".to_string(),
|
|
||||||
// Block-fetcher concurrency — defaults are conservative
|
// Block-fetcher concurrency — defaults are conservative
|
||||||
// for shared hosts; 4 is plenty for one bitcoind backend.
|
// for shared hosts; 4 is plenty for one bitcoind backend.
|
||||||
"MAX_SEND=10000000".to_string(),
|
"MAX_SEND=10000000".to_string(),
|
||||||
@ -1129,18 +1121,6 @@ pub(super) async fn get_app_config(
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"dwn" => (
|
|
||||||
vec!["3100:3000".to_string()],
|
|
||||||
vec!["/var/lib/archipelago/dwn:/dwn/data".to_string()],
|
|
||||||
vec![
|
|
||||||
"DS_PORT=3000".to_string(),
|
|
||||||
"DS_MESSAGES_STORE_URI=level://data/messages".to_string(),
|
|
||||||
"DS_DATA_STORE_URI=level://data/data".to_string(),
|
|
||||||
"DS_EVENT_LOG_URI=level://data/events".to_string(),
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
"botfights" => {
|
"botfights" => {
|
||||||
let jwt_secret = read_or_generate_secret("botfights-jwt").await;
|
let jwt_secret = read_or_generate_secret("botfights-jwt").await;
|
||||||
(
|
(
|
||||||
|
|||||||
@ -133,6 +133,10 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Apply git-based update: runs self-update.sh which pulls, builds, and restarts.
|
/// Apply git-based update: runs self-update.sh which pulls, builds, and restarts.
|
||||||
pub(super) async fn handle_update_git_apply(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_update_git_apply(&self) -> Result<serde_json::Value> {
|
||||||
|
if std::env::var("ARCHIPELAGO_GIT_UPDATES").is_err() {
|
||||||
|
anyhow::bail!("git/self-build updates are disabled; use manifest OTA updates instead");
|
||||||
|
}
|
||||||
|
|
||||||
let script = std::path::PathBuf::from(
|
let script = std::path::PathBuf::from(
|
||||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -276,7 +276,6 @@ fn get_app_tier(app_id: &str) -> &'static str {
|
|||||||
"core"
|
"core"
|
||||||
}
|
}
|
||||||
"btcpay" | "btcpay-server" | "btcpayserver" => "core",
|
"btcpay" | "btcpay-server" | "btcpayserver" => "core",
|
||||||
"dwn" => "core",
|
|
||||||
"filebrowser" => "core",
|
"filebrowser" => "core",
|
||||||
// Recommended: enhanced functionality
|
// Recommended: enhanced functionality
|
||||||
"fedimint" | "fedimint-gateway" => "recommended",
|
"fedimint" | "fedimint-gateway" => "recommended",
|
||||||
@ -518,13 +517,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
|||||||
repo: "https://github.com/indeedhub/indeedhub".to_string(),
|
repo: "https://github.com/indeedhub/indeedhub".to_string(),
|
||||||
tier: "",
|
tier: "",
|
||||||
},
|
},
|
||||||
"dwn" => AppMetadata {
|
|
||||||
title: "Decentralized Web Node".to_string(),
|
|
||||||
description: "Store and sync personal data with DID-based access control".to_string(),
|
|
||||||
icon: "/assets/img/app-icons/dwn.svg".to_string(),
|
|
||||||
repo: "https://github.com/TBD54566975/dwn-server".to_string(),
|
|
||||||
tier: "",
|
|
||||||
},
|
|
||||||
"tor" | "archy-tor" => AppMetadata {
|
"tor" | "archy-tor" => AppMetadata {
|
||||||
title: "Tor".to_string(),
|
title: "Tor".to_string(),
|
||||||
description: "Anonymous overlay network for privacy".to_string(),
|
description: "Anonymous overlay network for privacy".to_string(),
|
||||||
|
|||||||
@ -187,9 +187,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
|||||||
// Penpot (primary = frontend)
|
// Penpot (primary = frontend)
|
||||||
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
|
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
|
||||||
|
|
||||||
// DWN
|
|
||||||
"dwn" => Some("DWN_SERVER_IMAGE"),
|
|
||||||
|
|
||||||
// AI
|
// AI
|
||||||
"routstr" => Some("ROUTSTR_IMAGE"),
|
"routstr" => Some("ROUTSTR_IMAGE"),
|
||||||
|
|
||||||
|
|||||||
@ -66,7 +66,9 @@ pub async fn sync_with_peer(
|
|||||||
// hop. Only runs when the source is Trusted — Observer-level peers
|
// hop. Only runs when the source is Trusted — Observer-level peers
|
||||||
// don't get to expand our federation on their own authority.
|
// don't get to expand our federation on their own authority.
|
||||||
if peer.trust_level == TrustLevel::Trusted {
|
if peer.trust_level == TrustLevel::Trusted {
|
||||||
if let Err(e) = merge_transitive_peers(data_dir, &peer.did, &state.federated_peers).await {
|
if let Err(e) =
|
||||||
|
merge_transitive_peers(data_dir, &peer.did, local_did, &state.federated_peers).await
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
peer_did = %peer.did,
|
peer_did = %peer.did,
|
||||||
error = %e,
|
error = %e,
|
||||||
@ -109,6 +111,7 @@ pub async fn sync_with_peer_by_did(data_dir: &Path, peer_did: &str) -> Result<No
|
|||||||
async fn merge_transitive_peers(
|
async fn merge_transitive_peers(
|
||||||
data_dir: &std::path::Path,
|
data_dir: &std::path::Path,
|
||||||
source_did: &str,
|
source_did: &str,
|
||||||
|
local_did: &str,
|
||||||
hints: &[FederationPeerHint],
|
hints: &[FederationPeerHint],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if hints.is_empty() {
|
if hints.is_empty() {
|
||||||
@ -119,8 +122,9 @@ async fn merge_transitive_peers(
|
|||||||
let mut refreshed = 0u32;
|
let mut refreshed = 0u32;
|
||||||
|
|
||||||
for hint in hints {
|
for hint in hints {
|
||||||
// Don't import our own DID (a peer advertising us back).
|
// Don't import the source peer advertising itself, or our own DID
|
||||||
if hint.did == source_did {
|
// when the source advertises us back as one of its trusted peers.
|
||||||
|
if hint.did == source_did || hint.did == local_did {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
|
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
|
||||||
@ -359,4 +363,69 @@ mod tests {
|
|||||||
Some("npub1a")
|
Some("npub1a")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn merge_transitive_peers_skips_source_and_local_node() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
super::super::storage::save_nodes(
|
||||||
|
dir.path(),
|
||||||
|
&[FederatedNode {
|
||||||
|
did: "did:key:zSource".into(),
|
||||||
|
pubkey: "aa".into(),
|
||||||
|
onion: "source.onion".into(),
|
||||||
|
name: Some("Source".into()),
|
||||||
|
trust_level: TrustLevel::Trusted,
|
||||||
|
added_at: "now".into(),
|
||||||
|
last_seen: None,
|
||||||
|
last_state: None,
|
||||||
|
fips_npub: None,
|
||||||
|
last_transport: None,
|
||||||
|
last_transport_at: None,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
merge_transitive_peers(
|
||||||
|
dir.path(),
|
||||||
|
"did:key:zSource",
|
||||||
|
"did:key:zLocal",
|
||||||
|
&[
|
||||||
|
FederationPeerHint {
|
||||||
|
did: "did:key:zSource".into(),
|
||||||
|
pubkey: "aa".into(),
|
||||||
|
onion: "source.onion".into(),
|
||||||
|
name: Some("Source".into()),
|
||||||
|
fips_npub: None,
|
||||||
|
},
|
||||||
|
FederationPeerHint {
|
||||||
|
did: "did:key:zLocal".into(),
|
||||||
|
pubkey: "bb".into(),
|
||||||
|
onion: "local.onion".into(),
|
||||||
|
name: Some("Local".into()),
|
||||||
|
fips_npub: None,
|
||||||
|
},
|
||||||
|
FederationPeerHint {
|
||||||
|
did: "did:key:zPeer".into(),
|
||||||
|
pubkey: "cc".into(),
|
||||||
|
onion: "peer.onion".into(),
|
||||||
|
name: Some("Kitchen".into()),
|
||||||
|
fips_npub: Some("npub1peer".into()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let nodes = super::super::storage::load_nodes(dir.path()).await.unwrap();
|
||||||
|
assert_eq!(nodes.len(), 2);
|
||||||
|
assert!(nodes.iter().all(|n| n.did != "did:key:zLocal"));
|
||||||
|
let peer = nodes
|
||||||
|
.iter()
|
||||||
|
.find(|n| n.did == "did:key:zPeer")
|
||||||
|
.expect("trusted transitive peer should be added");
|
||||||
|
assert_eq!(peer.name.as_deref(), Some("Kitchen"));
|
||||||
|
assert_eq!(peer.trust_level, TrustLevel::Trusted);
|
||||||
|
assert_eq!(peer.fips_npub.as_deref(), Some("npub1peer"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,7 +71,7 @@ async fn build_telemetry_report(
|
|||||||
data_dir: &std::path::Path,
|
data_dir: &std::path::Path,
|
||||||
) -> anyhow::Result<serde_json::Value> {
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
// Anonymous node ID — truncated SHA-256 hash of pubkey
|
// Anonymous node ID — truncated SHA-256 hash of pubkey
|
||||||
let (node_id, version, container_count, running_count, peer_count, containers) =
|
let (node_id, node_name, version, container_count, running_count, peer_count, containers) =
|
||||||
if let Some(ref sm) = state {
|
if let Some(ref sm) = state {
|
||||||
let (data, _) = sm.get_snapshot().await;
|
let (data, _) = sm.get_snapshot().await;
|
||||||
let id = {
|
let id = {
|
||||||
@ -98,6 +98,10 @@ async fn build_telemetry_report(
|
|||||||
.count();
|
.count();
|
||||||
(
|
(
|
||||||
id,
|
id,
|
||||||
|
data.server_info
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.filter(|n| !n.trim().is_empty()),
|
||||||
data.server_info.version.clone(),
|
data.server_info.version.clone(),
|
||||||
data.package_data.len(),
|
data.package_data.len(),
|
||||||
running,
|
running,
|
||||||
@ -107,6 +111,7 @@ async fn build_telemetry_report(
|
|||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
"unknown".to_string(),
|
"unknown".to_string(),
|
||||||
|
None,
|
||||||
"unknown".to_string(),
|
"unknown".to_string(),
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
@ -125,6 +130,8 @@ async fn build_telemetry_report(
|
|||||||
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
|
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
|
||||||
.map(|f| f as u64)
|
.map(|f| f as u64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
let hostname = system_hostname().await;
|
||||||
|
let server_url = local_server_url(data_dir).await;
|
||||||
|
|
||||||
// Latest metrics snapshot
|
// Latest metrics snapshot
|
||||||
let latest = store.latest().await;
|
let latest = store.latest().await;
|
||||||
@ -166,6 +173,9 @@ async fn build_telemetry_report(
|
|||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
|
"node_name": node_name,
|
||||||
|
"hostname": hostname,
|
||||||
|
"server_url": server_url,
|
||||||
"version": version,
|
"version": version,
|
||||||
"uptime_secs": uptime_secs,
|
"uptime_secs": uptime_secs,
|
||||||
"cpu_cores": cpu_cores,
|
"cpu_cores": cpu_cores,
|
||||||
@ -181,6 +191,35 @@ async fn build_telemetry_report(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn system_hostname() -> Option<String> {
|
||||||
|
let output = tokio::process::Command::new("hostname")
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
(!hostname.is_empty()).then_some(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn local_server_url(data_dir: &std::path::Path) -> Option<String> {
|
||||||
|
let _ = data_dir;
|
||||||
|
let output = tokio::process::Command::new("hostname")
|
||||||
|
.arg("-I")
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ip = String::from_utf8_lossy(&output.stdout)
|
||||||
|
.split_whitespace()
|
||||||
|
.find(|ip| !ip.starts_with("127.") && ip.contains('.'))?
|
||||||
|
.to_string();
|
||||||
|
Some(format!("https://{ip}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// POST a telemetry report to the central collector.
|
/// POST a telemetry report to the central collector.
|
||||||
async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow::Result<()> {
|
async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow::Result<()> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
|
|||||||
@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
/// Live download progress counters. Updated by download_component_resumable
|
/// Live download progress counters. Updated by download_component_resumable
|
||||||
/// as bytes arrive and read by the update.status RPC so the UI can show
|
/// as bytes arrive and read by the update.status RPC so the UI can show
|
||||||
@ -502,6 +502,8 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
|
|||||||
.context("Reading update state")?;
|
.context("Reading update state")?;
|
||||||
let mut state: UpdateState = serde_json::from_str(&data).context("Parsing update state")?;
|
let mut state: UpdateState = serde_json::from_str(&data).context("Parsing update state")?;
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
// Keep current_version in sync with the binary. Sideloaded nodes
|
// Keep current_version in sync with the binary. Sideloaded nodes
|
||||||
// (ssh + cp /usr/local/bin/archipelago) don't touch the state file,
|
// (ssh + cp /usr/local/bin/archipelago) don't touch the state file,
|
||||||
// so without this the running 1.7.0-alpha binary would keep seeing
|
// so without this the running 1.7.0-alpha binary would keep seeing
|
||||||
@ -517,11 +519,36 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
|
|||||||
// if there's genuinely something newer.
|
// if there's genuinely something newer.
|
||||||
state.available_update = None;
|
state.available_update = None;
|
||||||
state.manifest_mirror = None;
|
state.manifest_mirror = None;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `update_in_progress` means a manifest OTA is downloaded and staged,
|
||||||
|
// ready for apply. Older git/self-build update paths could leave this
|
||||||
|
// flag stuck true without a staging directory, which traps the UI in an
|
||||||
|
// unrecoverable state. Heal that on every state load.
|
||||||
|
if state.update_in_progress && !has_staged_update(data_dir).await {
|
||||||
|
warn!(
|
||||||
|
staging = %data_dir.join("update-staging").display(),
|
||||||
|
"Clearing stale update_in_progress without staged OTA files"
|
||||||
|
);
|
||||||
|
state.update_in_progress = false;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
save_state(data_dir, &state).await?;
|
save_state(data_dir, &state).await?;
|
||||||
}
|
}
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn has_staged_update(data_dir: &Path) -> bool {
|
||||||
|
let staging_dir = data_dir.join("update-staging");
|
||||||
|
let Ok(mut entries) = fs::read_dir(&staging_dir).await else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matches!(entries.next_entry().await, Ok(Some(_)))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
|
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
|
||||||
let path = data_dir.join(UPDATE_STATE_FILE);
|
let path = data_dir.join(UPDATE_STATE_FILE);
|
||||||
let data = serde_json::to_string_pretty(state)?;
|
let data = serde_json::to_string_pretty(state)?;
|
||||||
@ -1764,6 +1791,11 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_save_and_load_state_roundtrip() {
|
async fn test_save_and_load_state_roundtrip() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let staging = dir.path().join("update-staging");
|
||||||
|
tokio::fs::create_dir_all(&staging).await.unwrap();
|
||||||
|
tokio::fs::write(staging.join("archipelago"), b"staged")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let state = UpdateState {
|
let state = UpdateState {
|
||||||
current_version: "1.0.0".to_string(),
|
current_version: "1.0.0".to_string(),
|
||||||
last_check: Some("2025-06-15T12:00:00Z".to_string()),
|
last_check: Some("2025-06-15T12:00:00Z".to_string()),
|
||||||
@ -1800,6 +1832,22 @@ mod tests {
|
|||||||
assert!(loaded.available_update.is_none());
|
assert!(loaded.available_update.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_load_state_clears_stale_in_progress_without_staging() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = UpdateState {
|
||||||
|
update_in_progress: true,
|
||||||
|
..UpdateState::default()
|
||||||
|
};
|
||||||
|
save_state(dir.path(), &state).await.unwrap();
|
||||||
|
|
||||||
|
let loaded = load_state(dir.path()).await.unwrap();
|
||||||
|
|
||||||
|
assert!(!loaded.update_in_progress);
|
||||||
|
let persisted = load_state(dir.path()).await.unwrap();
|
||||||
|
assert!(!persisted.update_in_progress);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_dismiss_update_clears_available() {
|
async fn test_dismiss_update_clears_available() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@ -123,7 +123,6 @@ impl PodmanClient {
|
|||||||
"immich_server" | "immich" => "http://localhost:2283",
|
"immich_server" | "immich" => "http://localhost:2283",
|
||||||
"nginx-proxy-manager" => "http://localhost:8081",
|
"nginx-proxy-manager" => "http://localhost:8081",
|
||||||
"fedimint-gateway" => "http://localhost:8176",
|
"fedimint-gateway" => "http://localhost:8176",
|
||||||
"dwn" => "http://localhost:3100",
|
|
||||||
"endurain" => "http://localhost:8080",
|
"endurain" => "http://localhost:8080",
|
||||||
"netbird" => "http://localhost:8087",
|
"netbird" => "http://localhost:8087",
|
||||||
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
|
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# 1.8-alpha Improvements Tracker
|
# 1.8-alpha Improvements Tracker
|
||||||
|
|
||||||
Last updated: 2026-06-11 00:17 EDT
|
Last updated: 2026-06-12 01:15 EDT
|
||||||
|
|
||||||
This tracks the user-facing improvement list that must land with the `1.8-alpha`
|
This tracks the user-facing improvement list that must land with the `1.8-alpha`
|
||||||
container migration release and the next ISO cut produced from that release. It
|
container migration release and the next ISO cut produced from that release. It
|
||||||
@ -116,6 +116,19 @@ header gap; and removed the My Apps desktop category dropdown. Focused
|
|||||||
Marketplace/App config tests, type-check, and scoped `git diff --check` passed.
|
Marketplace/App config tests, type-check, and scoped `git diff --check` passed.
|
||||||
Browser smoke against the already-running local Vite/mock session is still next.
|
Browser smoke against the already-running local Vite/mock session is still next.
|
||||||
|
|
||||||
|
Active-session update, 2026-06-12 01:15 EDT: system update UX hardening landed
|
||||||
|
locally. `load_state()` now clears stale `update_in_progress` when no staged OTA
|
||||||
|
files exist, so failed legacy update attempts cannot leave the update screen
|
||||||
|
permanently stuck. Direct `update.git-apply` is gated behind
|
||||||
|
`ARCHIPELAGO_GIT_UPDATES`, preventing production nodes from accidentally entering
|
||||||
|
the local git/self-build path that requires `cargo`. `.116` was recovered from a
|
||||||
|
failed self-build attempt by applying its already-staged manifest OTA; it is now
|
||||||
|
on `1.7.84-alpha`, backend health is OK, nginx is active/config-valid, HTTP UI
|
||||||
|
returns `200`, `update_in_progress=false`, and staging was removed. Validation:
|
||||||
|
`cargo fmt --check`, `cargo check -p archipelago`, and scoped `git diff --check`
|
||||||
|
passed; focused `cargo test` was blocked by a local `rust-lld` undefined hidden
|
||||||
|
symbol linker failure unrelated to the updater patch.
|
||||||
|
|
||||||
Done criteria for this tracker:
|
Done criteria for this tracker:
|
||||||
|
|
||||||
- Code/UI items: implemented, covered by targeted test or manual smoke check,
|
- Code/UI items: implemented, covered by targeted test or manual smoke check,
|
||||||
@ -148,6 +161,7 @@ Done criteria for this tracker:
|
|||||||
| Fix BTCPay issue from desktop file "BTCPay Issues" | blocked | Need file contents or path to that desktop artifact. |
|
| Fix BTCPay issue from desktop file "BTCPay Issues" | blocked | Need file contents or path to that desktop artifact. |
|
||||||
| Check Nostr Discoverable Nodes and get it working correctly | in-progress | Discover modal now keeps discovered rows visible during relay refresh/failure and shows `Searching relays...` instead of dropping to an empty state. Covered by `DiscoverModal.test.ts`, local type-check, and `git diff --check`. Needs live relay/trust validation before marking done. |
|
| Check Nostr Discoverable Nodes and get it working correctly | in-progress | Discover modal now keeps discovered rows visible during relay refresh/failure and shows `Searching relays...` instead of dropping to an empty state. Covered by `DiscoverModal.test.ts`, local type-check, and `git diff --check`. Needs live relay/trust validation before marking done. |
|
||||||
| Make sure update password is working properly | done | Backend now returns separate SSH update status so a successful web password change is not reported as a full failure when optional SSH password update fails. Settings modal shows success plus SSH warning and stays open for review. Covered by local type-check, focused modal/RPC tests, auth unit test, `cargo check -p archipelago`, and `git diff --check`. |
|
| Make sure update password is working properly | done | Backend now returns separate SSH update status so a successful web password change is not reported as a full failure when optional SSH password update fails. Settings modal shows success plus SSH warning and stays open for review. Covered by local type-check, focused modal/RPC tests, auth unit test, `cargo check -p archipelago`, and `git diff --check`. |
|
||||||
|
| Prevent System Update screen from getting permanently stuck | done | Update state loading now reconciles `update_in_progress` with the actual manifest OTA staging directory and clears stale stuck state when no staged files exist. Direct git/self-build apply is disabled unless `ARCHIPELAGO_GIT_UPDATES` is explicitly set, so production nodes cannot fall into the old `self-update.sh` path that requires local `cargo`. `.116` was recovered by applying its valid staged manifest OTA and verified on `1.7.84-alpha` with backend health OK, nginx active/config-valid, HTTP UI `200`, `update_in_progress=false`, and staging removed. Validated locally with `cargo fmt --check`, `cargo check -p archipelago`, and scoped `git diff --check`; focused `cargo test` was blocked by a local `rust-lld` linker artifact failure unrelated to the updater patch. |
|
||||||
| Do UI performance and general performance improvements | todo | Needs profiling target; start with obvious loading/render issues. |
|
| Do UI performance and general performance improvements | todo | Needs profiling target; start with obvious loading/render issues. |
|
||||||
| Make sure companion app is all working well, had issues with tab apps | in-progress | Mobile app-session now keeps apps that require a new tab inside the session fallback instead of auto-opening an external tab and closing immediately. Covered by `AppSessionMobileNewTab.test.ts`, existing app-session config tests, app launcher tests, local type-check, and `git diff --check`. Broader companion smoke test still needed before marking done. |
|
| Make sure companion app is all working well, had issues with tab apps | in-progress | Mobile app-session now keeps apps that require a new tab inside the session fallback instead of auto-opening an external tab and closing immediately. Covered by `AppSessionMobileNewTab.test.ts`, existing app-session config tests, app launcher tests, local type-check, and `git diff --check`. Broader companion smoke test still needed before marking done. |
|
||||||
| Even though performance is better, on reboot/restart backend/update show checking-containers notification instead of no apps | done | My Apps now shows a dedicated `Checking containers` card when initial backend data has loaded but `server-info.status-info.containers-scanned` is still false and no apps are ready to render, instead of falling through to the no-apps empty state. A follow-up UI pass preserves the last known app list when a later scanner/backoff update reports an empty package map with `containers-scanned=false`, and shows a refresh status banner above the grid. Validated by local type-check, targeted tests, and `git diff --check`; follow-up validation passed `npm test -- --run src/views/apps/__tests__/appPackageCache.test.ts` and `npm run type-check`. |
|
| Even though performance is better, on reboot/restart backend/update show checking-containers notification instead of no apps | done | My Apps now shows a dedicated `Checking containers` card when initial backend data has loaded but `server-info.status-info.containers-scanned` is still false and no apps are ready to render, instead of falling through to the no-apps empty state. A follow-up UI pass preserves the last known app list when a later scanner/backoff update reports an empty package map with `containers-scanned=false`, and shows a refresh status banner above the grid. Validated by local type-check, targeted tests, and `git diff --check`; follow-up validation passed `npm test -- --run src/views/apps/__tests__/appPackageCache.test.ts` and `npm run type-check`. |
|
||||||
@ -194,7 +208,7 @@ Done criteria for this tracker:
|
|||||||
| Work on setup screens function and flows | in-progress | Onboarding setup choice now shows only usable paths: Fresh Start and Restore from Seed. Removed the disabled `Connect Existing (Coming Soon)` option, and covered default Fresh routing plus Restore routing with `OnboardingOptions.test.ts`; `useOnboarding.test.ts`, local type-check, and `git diff --check` passed. Broader onboarding/setup audit still needed before marking done. |
|
| Work on setup screens function and flows | in-progress | Onboarding setup choice now shows only usable paths: Fresh Start and Restore from Seed. Removed the disabled `Connect Existing (Coming Soon)` option, and covered default Fresh routing plus Restore routing with `OnboardingOptions.test.ts`; `useOnboarding.test.ts`, local type-check, and `git diff --check` passed. Broader onboarding/setup audit still needed before marking done. |
|
||||||
| Work on Easy Mode experience | in-progress | Easy Mode goal configure steps now route to their owning app/screen instead of silently completing without navigation; verify steps now expose a `Check & Continue` action; configure/info/verify actions start goal progress before completing the active step. Covered by `goalStepActions.test.ts`, existing goal store tests, local type-check, and `git diff --check`. Broader Easy Mode product scope still needed before marking done. |
|
| Work on Easy Mode experience | in-progress | Easy Mode goal configure steps now route to their owning app/screen instead of silently completing without navigation; verify steps now expose a `Check & Continue` action; configure/info/verify actions start goal progress before completing the active step. Covered by `goalStepActions.test.ts`, existing goal store tests, local type-check, and `git diff --check`. Broader Easy Mode product scope still needed before marking done. |
|
||||||
| Update My Apps homescreen to show most-used apps instead of hardcoded | done | App launches are recorded locally through the app launcher, and the Home My Apps card now shows the top three installed user apps by launch count/recency with a running-app/name fallback when there is no history. Covered by `appUsage.test.ts`, existing app launcher tests, local type-check, targeted tests, and `git diff --check`. |
|
| Update My Apps homescreen to show most-used apps instead of hardcoded | done | App launches are recorded locally through the app launcher, and the Home My Apps card now shows the top three installed user apps by launch count/recency with a running-app/name fallback when there is no history. Covered by `appUsage.test.ts`, existing app launcher tests, local type-check, targeted tests, and `git diff --check`. |
|
||||||
| Improve Full Archive Node dependent apps UX | todo | Already partly represented by Bitcoin-pruned install block; needs broader dependency UX. |
|
| Improve Full Archive Node dependent apps UX | in-progress | Electrum-style apps already block install on pruned Bitcoin nodes; Marketplace/App Store cards now surface an inline warning that a full archive Bitcoin node is required instead of only showing a terse `Bitcoin Pruned` button. Covered by `MarketplaceAppCard.test.ts` and local type-check. Broader dependency UX remains. |
|
||||||
| Fix incorrect modals that are wrong color and are not full-screen overlay | done | Custom Teleport modals that still used the old light `bg-black/10` overlay now use the same full-screen `bg-black/60` overlay treatment as BaseModal/newer modals. Verified no fixed modal overlays retain `bg-black/10`; validated by local type-check, targeted tests, and `git diff --check`. |
|
| Fix incorrect modals that are wrong color and are not full-screen overlay | done | Custom Teleport modals that still used the old light `bg-black/10` overlay now use the same full-screen `bg-black/60` overlay treatment as BaseModal/newer modals. Verified no fixed modal overlays retain `bg-black/10`; validated by local type-check, targeted tests, and `git diff --check`. |
|
||||||
| Prevent modals from allowing background scroll | done | Added shared scroll-lock composable, root-level body lock, wheel/touch containment, and explicit dashboard route-panel locking. User validated the background no longer scrolls behind modal overlays. |
|
| Prevent modals from allowing background scroll | done | Added shared scroll-lock composable, root-level body lock, wheel/touch containment, and explicit dashboard route-panel locking. User validated the background no longer scrolls behind modal overlays. |
|
||||||
| Look over gamepad navigation | todo | Needs focused controller-nav pass. |
|
| Look over gamepad navigation | todo | Needs focused controller-nav pass. |
|
||||||
@ -206,6 +220,7 @@ Done criteria for this tracker:
|
|||||||
| Delete app data option and uninstall warning | done | Uninstall dialogs in My Apps and App Details now include a clear warning plus a `Delete app data and reset it` choice. Leaving it off preserves app data for later reinstall; checking it passes `preserve_data=false` through `package.uninstall` so the app is fully reset. Covered by `AppsUninstallModal.test.ts`, `rpc-client.test.ts`, local type-check, targeted tests, and `git diff --check`. |
|
| Delete app data option and uninstall warning | done | Uninstall dialogs in My Apps and App Details now include a clear warning plus a `Delete app data and reset it` choice. Leaving it off preserves app data for later reinstall; checking it passes `preserve_data=false` through `package.uninstall` so the app is fully reset. Covered by `AppsUninstallModal.test.ts`, `rpc-client.test.ts`, local type-check, targeted tests, and `git diff --check`. |
|
||||||
| Add App Store container with recommended apps that change to Home Screen | done | Home now shows up to three uninstalled core/recommended App Store apps and routes clicks through the existing Marketplace App Details handoff. Installed aliases are honored, so recommendations disappear once the app is installed and the app moves into normal My Apps/Home behavior. Follow-up layout polish moved Cloud back into the second card slot, moved Recommended Apps into Cloud's previous slot, and placed Quick Start inside the grid next to Wallet to avoid an odd-width row. Covered by `homeRecommendations.test.ts`, local type-check, `git diff --check`, and Playwright Home dashboard smoke against local Vite/mock backend. |
|
| Add App Store container with recommended apps that change to Home Screen | done | Home now shows up to three uninstalled core/recommended App Store apps and routes clicks through the existing Marketplace App Details handoff. Installed aliases are honored, so recommendations disappear once the app is installed and the app moves into normal My Apps/Home behavior. Follow-up layout polish moved Cloud back into the second card slot, moved Recommended Apps into Cloud's previous slot, and placed Quick Start inside the grid next to Wallet to avoid an odd-width row. Covered by `homeRecommendations.test.ts`, local type-check, `git diff --check`, and Playwright Home dashboard smoke against local Vite/mock backend. |
|
||||||
| Add QR code to download mobile companion app in login-triggered modal and improve modal | done | Companion intro modal now renders a QR code on desktop and a direct download button on mobile. It reads `VITE_COMPANION_APK_URL` and falls back to `/packages/archipelago-companion.apk.zip`; the APK zip is now published at `neode-ui/public/packages/archipelago-companion.apk.zip` so the modal can serve it immediately. Covered by local type-check, `git diff --check`, and manual file placement verification. |
|
| Add QR code to download mobile companion app in login-triggered modal and improve modal | done | Companion intro modal now renders a QR code on desktop and a direct download button on mobile. It reads `VITE_COMPANION_APK_URL` and falls back to `/packages/archipelago-companion.apk.zip`; the APK zip is now published at `neode-ui/public/packages/archipelago-companion.apk.zip` so the modal can serve it immediately. Covered by local type-check, `git diff --check`, and manual file placement verification. |
|
||||||
|
| Fix TV HDMI overscan clipping in kiosk mode | in-progress | Kiosk launcher now passes a browser safe-area fallback through `/kiosk?safe_area=...`; `/kiosk` now persists the safe-area value during redirect; self-update and deploy paths refresh kiosk launcher/services. The X11 safe-area attempt is opt-in because it stretched the live TV output on `100.66.157.120`. Wi-Fi UI fixes are included in the same OTA patch: scan errors are visible, scans can be retried, escaped SSIDs parse correctly, and open networks do not require a password. Needs live validation on HDMI node `100.66.157.120` after applying the visible OTA update. |
|
||||||
| Video calling Picture-in-Picture | blocked | Need referenced document or desired provider/library. |
|
| Video calling Picture-in-Picture | blocked | Need referenced document or desired provider/library. |
|
||||||
| Card-based loading visuals on App Store pages | done | Discover and Marketplace now show app-card skeleton grids while community/Nostr catalog data is loading and no cards are available yet, instead of a centered spinner/empty state. Validated by local type-check, targeted tests, and `git diff --check`. |
|
| Card-based loading visuals on App Store pages | done | Discover and Marketplace now show app-card skeleton grids while community/Nostr catalog data is loading and no cards are available yet, instead of a centered spinner/empty state. Validated by local type-check, targeted tests, and `git diff --check`. |
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ Allowed RPC methods:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
sendrawtransaction
|
sendrawtransaction
|
||||||
|
submitpackage
|
||||||
testmempoolaccept
|
testmempoolaccept
|
||||||
getmempoolinfo
|
getmempoolinfo
|
||||||
getrawmempool
|
getrawmempool
|
||||||
@ -37,6 +38,7 @@ getblockcount
|
|||||||
getblockhash
|
getblockhash
|
||||||
getblockheader
|
getblockheader
|
||||||
getrawtransaction
|
getrawtransaction
|
||||||
|
gettxout
|
||||||
decoderawtransaction
|
decoderawtransaction
|
||||||
decodescript
|
decodescript
|
||||||
estimatesmartfee
|
estimatesmartfee
|
||||||
@ -113,7 +115,7 @@ The Bitcoin Knots app should add the restricted user only when the secret exists
|
|||||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"
|
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"
|
||||||
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0"
|
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0"
|
||||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee"
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"
|
||||||
fi
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -245,6 +247,15 @@ curl -sS --user "$BITCOIN_RPC_TXRELAY_USER:$BITCOIN_RPC_TXRELAY_PASSWORD" \
|
|||||||
Expected result is a Bitcoin RPC validation error such as `TX decode failed`,
|
Expected result is a Bitcoin RPC validation error such as `TX decode failed`,
|
||||||
which confirms the request reached `sendrawtransaction`.
|
which confirms the request reached `sendrawtransaction`.
|
||||||
|
|
||||||
|
If a wallet verifies the connection but reports `RPC Forbidden` during
|
||||||
|
broadcast, the credentials authenticated but the broadcast method was outside
|
||||||
|
the loaded `txrelay` whitelist. Restart the active Bitcoin backend after
|
||||||
|
updating the whitelist, then test both `sendrawtransaction` and, for newer
|
||||||
|
package-relay clients, `submitpackage`. Also confirm the public reverse proxy
|
||||||
|
passes the wallet's `Authorization` header through to `127.0.0.1:8332`; do not
|
||||||
|
point public wallet traffic at the Bitcoin UI `/bitcoin-rpc/` helper, because
|
||||||
|
that helper injects the local dashboard credential.
|
||||||
|
|
||||||
Check that wallet/admin RPC is blocked:
|
Check that wallet/admin RPC is blocked:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@ -1,37 +1,87 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Start X server in the background
|
|
||||||
/usr/bin/Xorg :0 -nocursor vt1 -nolisten tcp -keeptty &
|
|
||||||
XPID=$!
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# Check if X started
|
# Start a dedicated X server for the attached kiosk display.
|
||||||
if ! kill -0 $XPID 2>/dev/null; then
|
/usr/bin/Xorg :0 vt1 -nolisten tcp -keeptty &
|
||||||
echo 'ERROR: Xorg failed to start'
|
XPID=$!
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
export DISPLAY=:0
|
export DISPLAY=:0
|
||||||
export HOME=/home/archipelago
|
export HOME=/home/archipelago
|
||||||
|
|
||||||
# Allow archipelago user to connect
|
X_READY=false
|
||||||
xhost +SI:localuser:archipelago 2>/dev/null
|
for _ in $(seq 1 30); do
|
||||||
|
if kill -0 "$XPID" 2>/dev/null && xrandr --query >/tmp/archipelago-kiosk-xrandr.txt 2>/dev/null; then
|
||||||
|
X_READY=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
# Disable screen blanking
|
if [ "$X_READY" != "true" ]; then
|
||||||
xset s off 2>/dev/null
|
echo 'ERROR: Xorg failed to become ready'
|
||||||
xset -dpms 2>/dev/null
|
kill "$XPID" 2>/dev/null || true
|
||||||
xset s noblank 2>/dev/null
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Hide cursor
|
KIOSK_SAFE_AREA_X_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_X_PX:-}
|
||||||
unclutter -idle 3 -root &
|
KIOSK_SAFE_AREA_Y_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_Y_PX:-}
|
||||||
|
|
||||||
# Kill any stale Chromium instances before starting
|
configure_display() {
|
||||||
pkill -u archipelago -f 'chromium.*kiosk' 2>/dev/null
|
command -v xrandr >/dev/null 2>&1 || return 0
|
||||||
|
|
||||||
|
local output mode internal width height
|
||||||
|
output=$(awk '/ connected/ && $1 !~ /^eDP|^LVDS/{print $1; exit}' /tmp/archipelago-kiosk-xrandr.txt)
|
||||||
|
[ -n "$output" ] || output=$(awk '/ connected/{print $1; exit}' /tmp/archipelago-kiosk-xrandr.txt)
|
||||||
|
[ -n "$output" ] || return 0
|
||||||
|
|
||||||
|
mode=$(awk -v out="$output" '
|
||||||
|
$1 == out { active = 1; next }
|
||||||
|
active && /^[[:space:]]+[0-9]+x[0-9]+/ {
|
||||||
|
if ($0 ~ /\*/) { print $1; exit }
|
||||||
|
if (!first) first = $1
|
||||||
|
}
|
||||||
|
active && /^[^[:space:]]/ { active = 0 }
|
||||||
|
END { if (first) print first }
|
||||||
|
' /tmp/archipelago-kiosk-xrandr.txt)
|
||||||
|
[ -n "$mode" ] || mode=1920x1080
|
||||||
|
|
||||||
|
# Kiosk should use one native output. A spanning desktop makes Chromium land
|
||||||
|
# on the laptop panel or stretch across both outputs.
|
||||||
|
for internal in $(awk '/ connected/ && $1 ~ /^eDP|^LVDS/{print $1}' /tmp/archipelago-kiosk-xrandr.txt); do
|
||||||
|
[ "$internal" = "$output" ] || xrandr --output "$internal" --off 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
xrandr --output "$output" \
|
||||||
|
--primary \
|
||||||
|
--mode "$mode" \
|
||||||
|
--pos 0x0 \
|
||||||
|
--scale 1x1 \
|
||||||
|
--panning 0x0 \
|
||||||
|
--transform none 2>/dev/null || true
|
||||||
|
|
||||||
|
width=${mode%x*}
|
||||||
|
height=${mode#*x}
|
||||||
|
case "$width:$height" in *[!0-9:]*|:*) width=1920; height=1080 ;; esac
|
||||||
|
|
||||||
|
# Browser safe-area fallback for TVs that crop edges. Driver underscan is
|
||||||
|
# preferable, but many Intel HDMI outputs do not expose that property.
|
||||||
|
KIOSK_SAFE_AREA_X_PX=${KIOSK_SAFE_AREA_X_PX:-$((width * 3 / 100))}
|
||||||
|
KIOSK_SAFE_AREA_Y_PX=${KIOSK_SAFE_AREA_Y_PX:-$((height * 3 / 100))}
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_display
|
||||||
|
|
||||||
|
xhost +SI:localuser:archipelago 2>/dev/null || true
|
||||||
|
xsetroot -solid black 2>/dev/null || true
|
||||||
|
xset s off 2>/dev/null || true
|
||||||
|
xset -dpms 2>/dev/null || true
|
||||||
|
xset s noblank 2>/dev/null || true
|
||||||
|
|
||||||
|
pkill -u archipelago -f 'chromium.*localhost' 2>/dev/null || true
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
# Run Chromium as archipelago user in a restart loop
|
|
||||||
while true; do
|
while true; do
|
||||||
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago chromium --kiosk \
|
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago chromium --kiosk \
|
||||||
--app=http://localhost/kiosk \
|
--app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
|
||||||
--noerrdialogs \
|
--noerrdialogs \
|
||||||
--disable-infobars \
|
--disable-infobars \
|
||||||
--disable-translate \
|
--disable-translate \
|
||||||
@ -45,9 +95,10 @@ while true; do
|
|||||||
--enable-gpu-rasterization \
|
--enable-gpu-rasterization \
|
||||||
--num-raster-threads=2 \
|
--num-raster-threads=2 \
|
||||||
--renderer-process-limit=2 \
|
--renderer-process-limit=2 \
|
||||||
--window-size=9999,9999 \
|
--window-size=1920,1080 \
|
||||||
--window-position=0,0 \
|
--window-position=0,0 \
|
||||||
--start-fullscreen \
|
--start-fullscreen \
|
||||||
|
--force-device-scale-factor=1 \
|
||||||
--disable-background-networking \
|
--disable-background-networking \
|
||||||
--disable-background-timer-throttling \
|
--disable-background-timer-throttling \
|
||||||
--disable-backgrounding-occluded-windows \
|
--disable-backgrounding-occluded-windows \
|
||||||
@ -60,5 +111,4 @@ while true; do
|
|||||||
sleep 3
|
sleep 3
|
||||||
done
|
done
|
||||||
|
|
||||||
# Cleanup
|
kill "$XPID" 2>/dev/null || true
|
||||||
kill $XPID 2>/dev/null
|
|
||||||
|
|||||||
@ -110,7 +110,7 @@ tail -f /tmp/neode-dev.log
|
|||||||
- **Docker Optional** - Apps run for real if Docker/Podman is available, otherwise simulated
|
- **Docker Optional** - Apps run for real if Docker/Podman is available, otherwise simulated
|
||||||
- **Auto-Detection** - Automatically detects container runtime and adapts
|
- **Auto-Detection** - Automatically detects container runtime and adapts
|
||||||
- **WebSocket Support** - Real-time state updates via JSON patches
|
- **WebSocket Support** - Real-time state updates via JSON patches
|
||||||
- **Pre-loaded Apps** - 8 apps always visible in My Apps
|
- **Pre-loaded Apps** - 7 apps always visible in My Apps
|
||||||
|
|
||||||
### Pre-installed Apps (always running in mock mode)
|
### Pre-installed Apps (always running in mock mode)
|
||||||
- `bitcoin` - Bitcoin Core (port 8332)
|
- `bitcoin` - Bitcoin Core (port 8332)
|
||||||
@ -119,7 +119,6 @@ tail -f /tmp/neode-dev.log
|
|||||||
- `mempool` - Blockchain explorer (port 4080)
|
- `mempool` - Blockchain explorer (port 4080)
|
||||||
- `filebrowser` - Web file manager (port 8083)
|
- `filebrowser` - Web file manager (port 8083)
|
||||||
- `lorabell` - LoRa doorbell (no UI port)
|
- `lorabell` - LoRa doorbell (no UI port)
|
||||||
- `thunderhub` - Lightning node management (port 3010)
|
|
||||||
- `fedimint` - Federated Bitcoin mint (port 8175)
|
- `fedimint` - Federated Bitcoin mint (port 8175)
|
||||||
|
|
||||||
Additional apps can be installed from the Marketplace (30+ available).
|
Additional apps can be installed from the Marketplace (30+ available).
|
||||||
@ -226,4 +225,3 @@ The warning is non-fatal - Vite still works, but upgrading is recommended.
|
|||||||
---
|
---
|
||||||
|
|
||||||
Happy coding! 🎨⚡
|
Happy coding! 🎨⚡
|
||||||
|
|
||||||
|
|||||||
@ -55,7 +55,7 @@ The mock backend supports multiple startup modes via `VITE_DEV_MODE`:
|
|||||||
The mock backend (`mock-backend.js`) simulates the full Rust backend for local development:
|
The mock backend (`mock-backend.js`) simulates the full Rust backend for local development:
|
||||||
|
|
||||||
**Pre-installed apps** (always visible in My Apps):
|
**Pre-installed apps** (always visible in My Apps):
|
||||||
- Bitcoin Core, LND, Electrs, Mempool, FileBrowser, LoraBell, ThunderHub, Fedimint
|
- Bitcoin Core, LND, Electrs, Mempool, FileBrowser, LoraBell, Fedimint
|
||||||
|
|
||||||
**Marketplace**: 30+ curated apps with Docker images, install/uninstall simulation
|
**Marketplace**: 30+ curated apps with Docker images, install/uninstall simulation
|
||||||
|
|
||||||
|
|||||||
@ -102,6 +102,26 @@ const toastMessage = messageToast.toastMessage
|
|||||||
|
|
||||||
useControllerNav()
|
useControllerNav()
|
||||||
|
|
||||||
|
function syncKioskSafeArea() {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
const isKiosk = localStorage.getItem('kiosk') === 'true'
|
||||||
|
|| new URLSearchParams(window.location.search).has('kiosk')
|
||||||
|
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
|
||||||
|
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
|
||||||
|
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
|
||||||
|
const rawSafeAreaY = localStorage.getItem('archipelago_kiosk_safe_area_y_px') || rawSafeArea
|
||||||
|
const safeAreaX = /^\d{1,3}$/.test(rawSafeAreaX) ? Number(rawSafeAreaX) : safeArea
|
||||||
|
const safeAreaY = /^\d{1,3}$/.test(rawSafeAreaY) ? Number(rawSafeAreaY) : safeArea
|
||||||
|
document.documentElement.classList.toggle('kiosk-safe-area', isKiosk && (safeAreaX > 0 || safeAreaY > 0))
|
||||||
|
if (isKiosk && (safeAreaX > 0 || safeAreaY > 0)) {
|
||||||
|
document.documentElement.style.setProperty('--kiosk-safe-area-x', `${safeAreaX}px`)
|
||||||
|
document.documentElement.style.setProperty('--kiosk-safe-area-y', `${safeAreaY}px`)
|
||||||
|
} else {
|
||||||
|
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
|
||||||
|
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start/stop message polling and remote relay when auth state changes
|
// Start/stop message polling and remote relay when auth state changes
|
||||||
watch(() => appStore.isAuthenticated, (authenticated) => {
|
watch(() => appStore.isAuthenticated, (authenticated) => {
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
@ -330,6 +350,7 @@ function onVisibilityChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
syncKioskSafeArea()
|
||||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
window.addEventListener('keydown', onKeyDown, true)
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
window.addEventListener('mousemove', onUserActivity)
|
window.addEventListener('mousemove', onUserActivity)
|
||||||
@ -393,6 +414,9 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
document.documentElement.classList.remove('kiosk-safe-area')
|
||||||
|
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
|
||||||
|
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
|
||||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
window.removeEventListener('keydown', onKeyDown, true)
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
window.removeEventListener('mousemove', onUserActivity)
|
window.removeEventListener('mousemove', onUserActivity)
|
||||||
|
|||||||
@ -463,9 +463,6 @@
|
|||||||
"noIdentities": "No identities yet",
|
"noIdentities": "No identities yet",
|
||||||
"createFirstIdentity": "Create your first sovereign digital identity.",
|
"createFirstIdentity": "Create your first sovereign digital identity.",
|
||||||
"deleting": "Deleting...",
|
"deleting": "Deleting...",
|
||||||
"decentralizedWebNode": "Decentralized Web Node",
|
|
||||||
"dwnDescription": "Personal data store with DID-based access control",
|
|
||||||
"manageDwn": "Manage DWN",
|
|
||||||
"syncing": "Syncing...",
|
"syncing": "Syncing...",
|
||||||
"syncNow": "Sync Now",
|
"syncNow": "Sync Now",
|
||||||
"verifiableCredentials": "Verifiable Credentials",
|
"verifiableCredentials": "Verifiable Credentials",
|
||||||
@ -590,6 +587,7 @@
|
|||||||
},
|
},
|
||||||
"marketplaceDetails": {
|
"marketplaceDetails": {
|
||||||
"backToStore": "Back to App Store",
|
"backToStore": "Back to App Store",
|
||||||
|
"backToHome": "Back to Home",
|
||||||
"screenshots": "Screenshots",
|
"screenshots": "Screenshots",
|
||||||
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
|
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
|
||||||
"about": "About {name}",
|
"about": "About {name}",
|
||||||
|
|||||||
@ -463,9 +463,6 @@
|
|||||||
"noIdentities": "A\u00fan no hay identidades",
|
"noIdentities": "A\u00fan no hay identidades",
|
||||||
"createFirstIdentity": "Cree su primera identidad digital soberana.",
|
"createFirstIdentity": "Cree su primera identidad digital soberana.",
|
||||||
"deleting": "Eliminando...",
|
"deleting": "Eliminando...",
|
||||||
"decentralizedWebNode": "Nodo web descentralizado",
|
|
||||||
"dwnDescription": "Almac\u00e9n de datos personal con control de acceso basado en DID",
|
|
||||||
"manageDwn": "Administrar DWN",
|
|
||||||
"syncing": "Sincronizando...",
|
"syncing": "Sincronizando...",
|
||||||
"syncNow": "Sincronizar ahora",
|
"syncNow": "Sincronizar ahora",
|
||||||
"verifiableCredentials": "Credenciales verificables",
|
"verifiableCredentials": "Credenciales verificables",
|
||||||
@ -589,6 +586,7 @@
|
|||||||
},
|
},
|
||||||
"marketplaceDetails": {
|
"marketplaceDetails": {
|
||||||
"backToStore": "Volver a la tienda",
|
"backToStore": "Volver a la tienda",
|
||||||
|
"backToHome": "Volver al inicio",
|
||||||
"screenshots": "Capturas de pantalla",
|
"screenshots": "Capturas de pantalla",
|
||||||
"screenshotPlaceholder": "Capturas de pantalla de ejemplo \u2014 im\u00e1genes disponibles pronto",
|
"screenshotPlaceholder": "Capturas de pantalla de ejemplo \u2014 im\u00e1genes disponibles pronto",
|
||||||
"about": "Acerca de {name}",
|
"about": "Acerca de {name}",
|
||||||
|
|||||||
@ -86,11 +86,26 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/kiosk',
|
path: '/kiosk',
|
||||||
name: 'kiosk',
|
name: 'kiosk',
|
||||||
redirect: '/',
|
component: () => import('../views/Kiosk.vue'),
|
||||||
beforeEnter: () => {
|
beforeEnter: (to) => {
|
||||||
// Persist kiosk mode before redirect so App.vue can skip the remote relay
|
// Persist kiosk mode before redirect so App.vue can skip the remote relay
|
||||||
// (relay duplicates xdotool input on the kiosk display)
|
// (relay duplicates xdotool input on the kiosk display)
|
||||||
localStorage.setItem('kiosk', 'true')
|
localStorage.setItem('kiosk', 'true')
|
||||||
|
const safeArea = to.query.safe_area
|
||||||
|
const safeAreaPx = Array.isArray(safeArea) ? safeArea[0] : safeArea
|
||||||
|
if (safeAreaPx && /^\d{1,3}$/.test(safeAreaPx)) {
|
||||||
|
localStorage.setItem('archipelago_kiosk_safe_area_px', safeAreaPx)
|
||||||
|
}
|
||||||
|
const safeAreaX = to.query.safe_area_x
|
||||||
|
const safeAreaXPx = Array.isArray(safeAreaX) ? safeAreaX[0] : safeAreaX
|
||||||
|
if (safeAreaXPx && /^\d{1,3}$/.test(safeAreaXPx)) {
|
||||||
|
localStorage.setItem('archipelago_kiosk_safe_area_x_px', safeAreaXPx)
|
||||||
|
}
|
||||||
|
const safeAreaY = to.query.safe_area_y
|
||||||
|
const safeAreaYPx = Array.isArray(safeAreaY) ? safeAreaY[0] : safeAreaY
|
||||||
|
if (safeAreaYPx && /^\d{1,3}$/.test(safeAreaYPx)) {
|
||||||
|
localStorage.setItem('archipelago_kiosk_safe_area_y_px', safeAreaYPx)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -386,4 +401,3 @@ router.afterEach((to) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
||||||
|
|||||||
@ -111,10 +111,8 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
|||||||
'4080': 'mempool',
|
'4080': 'mempool',
|
||||||
'8175': 'fedimint',
|
'8175': 'fedimint',
|
||||||
'8176': 'fedimint-gateway',
|
'8176': 'fedimint-gateway',
|
||||||
'3100': 'dwn',
|
|
||||||
'7778': 'indeedhub',
|
'7778': 'indeedhub',
|
||||||
'50002': 'electrumx',
|
'50002': 'electrumx',
|
||||||
'3010': 'thunderhub',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const APP_ID_TO_PORT: Record<string, string> = {
|
const APP_ID_TO_PORT: Record<string, string> = {
|
||||||
|
|||||||
@ -1294,6 +1294,21 @@ body {
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.kiosk-safe-area,
|
||||||
|
html.kiosk-safe-area body {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.kiosk-safe-area #app {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for glass containers */
|
/* Custom scrollbar for glass containers */
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
|
|||||||
@ -416,8 +416,8 @@ function isStartingUp(appId: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAppTier(appId: string): string {
|
function getAppTier(appId: string): string {
|
||||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
|
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'filebrowser']
|
||||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
||||||
if (core.includes(appId)) return 'core'
|
if (core.includes(appId)) return 'core'
|
||||||
if (recommended.includes(appId)) return 'recommended'
|
if (recommended.includes(appId)) return 'recommended'
|
||||||
return 'optional'
|
return 'optional'
|
||||||
|
|||||||
@ -125,55 +125,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- App Store Recommendations -->
|
<!-- Wallet Overview -->
|
||||||
<div
|
<HomeWalletCard
|
||||||
v-if="homeRecommendedApps.length > 0"
|
:animate="animateCards"
|
||||||
data-controller-container
|
:wallet-connected="walletConnected"
|
||||||
tabindex="0"
|
:wallet-onchain="walletOnchain"
|
||||||
class="home-card controller-focusable"
|
:wallet-lightning="walletLightning"
|
||||||
:class="{ 'home-card-animate': animateCards }"
|
:wallet-ecash="walletEcash"
|
||||||
style="--card-stagger: 2"
|
:wallet-transactions="walletTransactions"
|
||||||
>
|
:is-dev="isDev"
|
||||||
<div class="home-card-shell">
|
@show-send="showSendModal = true"
|
||||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
@show-receive="showReceiveModal = true"
|
||||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
@show-transactions="showTransactionsModal = true"
|
||||||
<div class="home-card-text">
|
@faucet="devFaucet"
|
||||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.recommendedApps') }}</h2>
|
@open-in-mempool="openInMempool"
|
||||||
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
|
/>
|
||||||
</div>
|
|
||||||
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" class="text-white/60 hover:text-white transition-colors">
|
|
||||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
|
|
||||||
<button
|
|
||||||
v-for="app in homeRecommendedApps"
|
|
||||||
:key="app.id"
|
|
||||||
type="button"
|
|
||||||
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
|
|
||||||
@click="viewRecommendedApp(app)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="app.icon"
|
|
||||||
:src="app.icon"
|
|
||||||
:alt="app.title || app.id"
|
|
||||||
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
|
|
||||||
@error="handleImageError"
|
|
||||||
/>
|
|
||||||
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
|
|
||||||
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-white/45 capitalize">{{ getAppTier(app.id) }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
|
||||||
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Network Overview -->
|
<!-- Network Overview -->
|
||||||
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 3">
|
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 3">
|
||||||
@ -213,21 +179,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Wallet Overview -->
|
<!-- App Store Recommendations -->
|
||||||
<HomeWalletCard
|
<div
|
||||||
:animate="animateCards"
|
v-if="homeRecommendedApps.length > 0"
|
||||||
:wallet-connected="walletConnected"
|
data-controller-container
|
||||||
:wallet-onchain="walletOnchain"
|
tabindex="0"
|
||||||
:wallet-lightning="walletLightning"
|
class="home-card controller-focusable lg:col-span-2"
|
||||||
:wallet-ecash="walletEcash"
|
:class="{ 'home-card-animate': animateCards }"
|
||||||
:wallet-transactions="walletTransactions"
|
style="--card-stagger: 4"
|
||||||
:is-dev="isDev"
|
>
|
||||||
@show-send="showSendModal = true"
|
<div class="home-card-shell">
|
||||||
@show-receive="showReceiveModal = true"
|
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||||
@show-transactions="showTransactionsModal = true"
|
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||||
@faucet="devFaucet"
|
<div class="home-card-text">
|
||||||
@open-in-mempool="openInMempool"
|
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.recommendedApps') }}</h2>
|
||||||
/>
|
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" class="text-white/60 hover:text-white transition-colors">
|
||||||
|
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="home-card-stats grid grid-cols-1 md:grid-cols-3 gap-3 mb-4 flex-1 min-h-0">
|
||||||
|
<button
|
||||||
|
v-for="app in homeRecommendedApps"
|
||||||
|
:key="app.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
|
||||||
|
@click="viewRecommendedApp(app)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="app.icon"
|
||||||
|
:src="app.icon"
|
||||||
|
:alt="app.title || app.id"
|
||||||
|
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
|
||||||
|
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||||
|
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick Start Goals -->
|
<!-- Quick Start Goals -->
|
||||||
<div
|
<div
|
||||||
@ -299,7 +298,7 @@ import { rpcClient } from '@/api/rpc-client'
|
|||||||
import { getAppUsage } from '@/utils/appUsage'
|
import { getAppUsage } from '@/utils/appUsage'
|
||||||
import { handleImageError, isServicePackage, isWebsitePackage, resolveAppIcon } from './apps/appsConfig'
|
import { handleImageError, isServicePackage, isWebsitePackage, resolveAppIcon } from './apps/appsConfig'
|
||||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||||
import { getAppTier, getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
|
import { getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
|
||||||
import { getHomeRecommendedApps } from './home/homeRecommendations'
|
import { getHomeRecommendedApps } from './home/homeRecommendations'
|
||||||
import HomeWalletCard from './home/HomeWalletCard.vue'
|
import HomeWalletCard from './home/HomeWalletCard.vue'
|
||||||
import HomeSystemCard from './home/HomeSystemCard.vue'
|
import HomeSystemCard from './home/HomeSystemCard.vue'
|
||||||
@ -389,7 +388,7 @@ const homeRecommendedApps = computed(() => getHomeRecommendedApps(getCuratedAppL
|
|||||||
|
|
||||||
function viewRecommendedApp(app: MarketplaceApp) {
|
function viewRecommendedApp(app: MarketplaceApp) {
|
||||||
setCurrentApp(app)
|
setCurrentApp(app)
|
||||||
router.push({ name: 'marketplace-app-detail', params: { id: app.id } }).catch(() => {})
|
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'home' } }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
function marketplaceDescription(app: MarketplaceApp) {
|
function marketplaceDescription(app: MarketplaceApp) {
|
||||||
|
|||||||
@ -107,7 +107,6 @@ const launchableApps = computed<KioskApp[]>(() => {
|
|||||||
'tailscale': '/app/tailscale/',
|
'tailscale': '/app/tailscale/',
|
||||||
'fedimint': '/app/fedimint/',
|
'fedimint': '/app/fedimint/',
|
||||||
'fedimint-gateway': '/app/fedimint-gateway/',
|
'fedimint-gateway': '/app/fedimint-gateway/',
|
||||||
'dwn': '/app/dwn/',
|
|
||||||
'indeedhub': 'http://localhost:7778',
|
'indeedhub': 'http://localhost:7778',
|
||||||
'botfights': 'http://localhost:9100',
|
'botfights': 'http://localhost:9100',
|
||||||
'nwnn': 'https://nwnn.l484.com',
|
'nwnn': 'https://nwnn.l484.com',
|
||||||
@ -172,7 +171,10 @@ onUnmounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.kiosk-root {
|
.kiosk-root {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
left: var(--kiosk-safe-area-x, 0px);
|
||||||
|
top: var(--kiosk-safe-area-y, 0px);
|
||||||
|
width: calc(100vw - (var(--kiosk-safe-area-x, 0px) * 2));
|
||||||
|
height: calc(100vh - (var(--kiosk-safe-area-y, 0px) * 2));
|
||||||
background: #000;
|
background: #000;
|
||||||
outline: none;
|
outline: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -180,11 +182,12 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.kiosk-launcher {
|
.kiosk-launcher {
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 2rem 3rem;
|
padding: clamp(1rem, 3vh, 2rem) clamp(1.5rem, 4vw, 3rem);
|
||||||
background: linear-gradient(180deg, #0a0a12 0%, #000 100%);
|
background: linear-gradient(180deg, #0a0a12 0%, #000 100%);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kiosk-header {
|
.kiosk-header {
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ t('marketplaceDetails.backToStore') }}
|
{{ backButtonLabel }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Mobile Full-Width Back Button -->
|
<!-- Mobile Full-Width Back Button -->
|
||||||
@ -20,7 +20,7 @@
|
|||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{{ t('marketplaceDetails.backToStore') }}</span>
|
<span>{{ backButtonLabel }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Transition name="content-fade" mode="out-in">
|
<Transition name="content-fade" mode="out-in">
|
||||||
@ -397,6 +397,7 @@ const installingDeps = ref(false)
|
|||||||
const installError = ref<string | null>(null)
|
const installError = ref<string | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const bitcoinPruned = ref(false)
|
const bitcoinPruned = ref(false)
|
||||||
|
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
|
||||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||||
|
|
||||||
const appId = computed(() => route.params.id as string)
|
const appId = computed(() => route.params.id as string)
|
||||||
@ -550,7 +551,9 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (route.query.from === 'discover') {
|
if (route.query.from === 'home') {
|
||||||
|
router.push('/dashboard').catch(() => {})
|
||||||
|
} else if (route.query.from === 'discover') {
|
||||||
router.push('/dashboard/discover').catch(() => {})
|
router.push('/dashboard/discover').catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
router.push('/dashboard/marketplace').catch(() => {})
|
router.push('/dashboard/marketplace').catch(() => {})
|
||||||
|
|||||||
@ -347,6 +347,7 @@
|
|||||||
:wifi-submitting="wifiSubmitting"
|
:wifi-submitting="wifiSubmitting"
|
||||||
:wifi-selected-ssid="wifiSelectedSsid"
|
:wifi-selected-ssid="wifiSelectedSsid"
|
||||||
:wifi-error="wifiError"
|
:wifi-error="wifiError"
|
||||||
|
:wifi-scan-error="wifiScanError"
|
||||||
:dns-selected-provider="dnsSelectedProvider"
|
:dns-selected-provider="dnsSelectedProvider"
|
||||||
:dns-servers="networkData.dnsServers"
|
:dns-servers="networkData.dnsServers"
|
||||||
:dns-applying="dnsApplying"
|
:dns-applying="dnsApplying"
|
||||||
@ -358,6 +359,7 @@
|
|||||||
@close-wifi="showWifiModal = false"
|
@close-wifi="showWifiModal = false"
|
||||||
@select-wifi="selectWifi"
|
@select-wifi="selectWifi"
|
||||||
@connect-wifi="connectToWifi"
|
@connect-wifi="connectToWifi"
|
||||||
|
@scan-wifi="scanWifi"
|
||||||
@cancel-wifi-connect="wifiConnecting = false; wifiPassword = ''; wifiError = ''"
|
@cancel-wifi-connect="wifiConnecting = false; wifiPassword = ''; wifiError = ''"
|
||||||
@close-dns="showDnsModal = false; dnsError = ''"
|
@close-dns="showDnsModal = false; dnsError = ''"
|
||||||
@select-dns-provider="(v: string) => { dnsSelectedProvider = v }"
|
@select-dns-provider="(v: string) => { dnsSelectedProvider = v }"
|
||||||
@ -564,6 +566,7 @@ const wifiSubmitting = ref(false)
|
|||||||
const wifiSelectedSsid = ref('')
|
const wifiSelectedSsid = ref('')
|
||||||
const wifiPassword = ref('')
|
const wifiPassword = ref('')
|
||||||
const wifiError = ref('')
|
const wifiError = ref('')
|
||||||
|
const wifiScanError = ref('')
|
||||||
|
|
||||||
// DNS
|
// DNS
|
||||||
const showDnsModal = ref(false)
|
const showDnsModal = ref(false)
|
||||||
@ -610,15 +613,33 @@ async function loadInterfaces() {
|
|||||||
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
|
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanWifi() {
|
function wifiRequiresPassword(network: WifiNetwork | undefined): boolean {
|
||||||
wifiScanning.value = true; wifiNetworks.value = []
|
const security = (network?.security || '').trim().toLowerCase()
|
||||||
try { const res = await rpcClient.call<{ networks: WifiNetwork[] }>({ method: 'network.scan-wifi' }); wifiNetworks.value = res.networks } catch { wifiNetworks.value = [] } finally { wifiScanning.value = false }
|
return security.length > 0 && security !== '--' && security !== 'none' && security !== 'open'
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectWifi(ssid: string) { wifiSelectedSsid.value = ssid; wifiPassword.value = ''; wifiConnecting.value = true }
|
async function scanWifi() {
|
||||||
|
wifiScanning.value = true; wifiNetworks.value = []; wifiScanError.value = ''; wifiError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ networks: WifiNetwork[] }>({ method: 'network.scan-wifi' })
|
||||||
|
wifiNetworks.value = res.networks
|
||||||
|
} catch (e) {
|
||||||
|
wifiNetworks.value = []
|
||||||
|
wifiScanError.value = e instanceof Error ? e.message : 'WiFi scan failed.'
|
||||||
|
} finally { wifiScanning.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWifi(network: WifiNetwork) {
|
||||||
|
wifiSelectedSsid.value = network.ssid; wifiPassword.value = ''; wifiError.value = ''
|
||||||
|
if (wifiRequiresPassword(network)) {
|
||||||
|
wifiConnecting.value = true
|
||||||
|
} else {
|
||||||
|
connectToWifi('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function connectToWifi(password: string) {
|
async function connectToWifi(password: string) {
|
||||||
if (!password || !wifiSelectedSsid.value) return
|
if (!wifiSelectedSsid.value) return
|
||||||
wifiError.value = ''; wifiSubmitting.value = true
|
wifiError.value = ''; wifiSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await rpcClient.call({ method: 'network.configure-wifi', params: { ssid: wifiSelectedSsid.value, password } })
|
await rpcClient.call({ method: 'network.configure-wifi', params: { ssid: wifiSelectedSsid.value, password } })
|
||||||
|
|||||||
@ -27,7 +27,6 @@ export const APP_PORTS: Record<string, number> = {
|
|||||||
'tailscale': 8240,
|
'tailscale': 8240,
|
||||||
'fedimintd': 8175,
|
'fedimintd': 8175,
|
||||||
'fedimint-gateway': 8176,
|
'fedimint-gateway': 8176,
|
||||||
'dwn': 3100,
|
|
||||||
'endurain': 8080,
|
'endurain': 8080,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,6 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
|||||||
"strfry": 8082,
|
"strfry": 8082,
|
||||||
"uptime-kuma": 3002,
|
"uptime-kuma": 3002,
|
||||||
"vaultwarden": 8082,
|
"vaultwarden": 8082,
|
||||||
"web5-dwn": 3000,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GENERATED_APP_TITLES: Record<string, string> = {
|
export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||||
@ -69,7 +68,6 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
|
|||||||
"strfry": "Strfry Nostr Relay",
|
"strfry": "Strfry Nostr Relay",
|
||||||
"uptime-kuma": "Uptime Kuma",
|
"uptime-kuma": "Uptime Kuma",
|
||||||
"vaultwarden": "Vaultwarden",
|
"vaultwarden": "Vaultwarden",
|
||||||
"web5-dwn": "Decentralized Web Node",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GENERATED_NEW_TAB_APPS = new Set<string>([
|
export const GENERATED_NEW_TAB_APPS = new Set<string>([
|
||||||
|
|||||||
@ -269,7 +269,7 @@ const tier = computed(() => {
|
|||||||
const t = props.pkg.manifest?.tier
|
const t = props.pkg.manifest?.tier
|
||||||
if (t && t !== '') return t
|
if (t && t !== '') return t
|
||||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
|
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
|
||||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
||||||
if (core.includes(props.id)) return 'core'
|
if (core.includes(props.id)) return 'core'
|
||||||
if (recommended.includes(props.id)) return 'recommended'
|
if (recommended.includes(props.id)) return 'recommended'
|
||||||
return 'optional'
|
return 'optional'
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export const APP_CATEGORY_MAP: Record<string, string> = {
|
|||||||
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
|
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
|
||||||
'nostrudel': 'nostr',
|
'nostrudel': 'nostr',
|
||||||
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
|
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
|
||||||
'uptime-kuma': 'networking', 'dwn': 'data',
|
'uptime-kuma': 'networking',
|
||||||
'botfights': 'community', 'nwnn': 'l484', '484-kitchen': 'l484',
|
'botfights': 'community', 'nwnn': 'l484', '484-kitchen': 'l484',
|
||||||
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
|
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,7 +100,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
|||||||
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
|
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
|
||||||
{ id: 'fedimint', title: 'Fedimint', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
|
{ id: 'fedimint', title: 'Fedimint', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
|
||||||
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
|
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
|
||||||
{ id: 'dwn', title: 'Decentralized Web Node', version: '0.4.0', description: 'Own your data with DID-based access control. Sync across devices, sovereign.', icon: '/assets/img/app-icons/dwn.svg', author: 'TBD', dockerImage: `${R}/dwn-server:main`, repoUrl: 'https://github.com/TBD54566975/dwn-server' },
|
|
||||||
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
|
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
|
||||||
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
|
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
|
||||||
{ id: 'gitea', title: 'Gitea', version: '1.23', category: 'development', description: 'Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.', icon: '/assets/img/app-icons/gitea.svg', author: 'Gitea', dockerImage: 'docker.io/gitea/gitea:1.23', repoUrl: 'https://gitea.com' },
|
{ id: 'gitea', title: 'Gitea', version: '1.23', category: 'development', description: 'Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.', icon: '/assets/img/app-icons/gitea.svg', author: 'Gitea', dockerImage: 'docker.io/gitea/gitea:1.23', repoUrl: 'https://gitea.com' },
|
||||||
|
|||||||
@ -2,13 +2,21 @@
|
|||||||
<div v-if="node" class="glass-card p-5 mb-6">
|
<div v-if="node" class="glass-card p-5 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-sm font-medium text-white/80">
|
<h3 class="text-sm font-medium text-white/80">
|
||||||
Node Detail — <span class="font-mono">{{ nodeId.slice(0, 8) }}</span>
|
Node Detail — <span>{{ fleetNodeDisplayName(node) }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<button class="glass-button text-xs px-3 py-1" @click="$emit('close')">Close</button>
|
<button class="glass-button text-xs px-3 py-1" @click="$emit('close')">Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Node Info Summary -->
|
<!-- Node Info Summary -->
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="monitoring-stat-card">
|
||||||
|
<p class="text-xs text-white/50 uppercase tracking-wide">Hostname</p>
|
||||||
|
<p class="text-lg font-bold text-white truncate">{{ node.hostname || 'Unknown' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="monitoring-stat-card">
|
||||||
|
<p class="text-xs text-white/50 uppercase tracking-wide">Address</p>
|
||||||
|
<p class="text-lg font-bold text-white truncate">{{ node.server_url || nodeId.slice(0, 8) }}</p>
|
||||||
|
</div>
|
||||||
<div class="monitoring-stat-card">
|
<div class="monitoring-stat-card">
|
||||||
<p class="text-xs text-white/50 uppercase tracking-wide">Version</p>
|
<p class="text-xs text-white/50 uppercase tracking-wide">Version</p>
|
||||||
<p class="text-lg font-bold text-white">v{{ node.version }}</p>
|
<p class="text-lg font-bold text-white">v{{ node.version }}</p>
|
||||||
@ -17,10 +25,6 @@
|
|||||||
<p class="text-xs text-white/50 uppercase tracking-wide">Uptime</p>
|
<p class="text-xs text-white/50 uppercase tracking-wide">Uptime</p>
|
||||||
<p class="text-lg font-bold text-white">{{ formatUptime(node.uptime_secs) }}</p>
|
<p class="text-lg font-bold text-white">{{ formatUptime(node.uptime_secs) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="monitoring-stat-card">
|
|
||||||
<p class="text-xs text-white/50 uppercase tracking-wide">CPU Cores</p>
|
|
||||||
<p class="text-lg font-bold text-white">{{ node.cpu_cores }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="monitoring-stat-card">
|
<div class="monitoring-stat-card">
|
||||||
<p class="text-xs text-white/50 uppercase tracking-wide">Federation Peers</p>
|
<p class="text-xs text-white/50 uppercase tracking-wide">Federation Peers</p>
|
||||||
<p class="text-lg font-bold text-white">{{ node.federation_peers }}</p>
|
<p class="text-lg font-bold text-white">{{ node.federation_peers }}</p>
|
||||||
@ -119,7 +123,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import LineChart from '@/components/LineChart.vue'
|
import LineChart from '@/components/LineChart.vue'
|
||||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||||
import { type FleetNode, formatUptime, alertSeverityDot, formatTimestamp } from './useFleetData'
|
import { type FleetNode, formatUptime, alertSeverityDot, formatTimestamp, fleetNodeDisplayName } from './useFleetData'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
node: FleetNode | null
|
node: FleetNode | null
|
||||||
|
|||||||
@ -34,10 +34,13 @@
|
|||||||
class="fleet-status-dot"
|
class="fleet-status-dot"
|
||||||
:class="isOnline(node.reported_at) ? 'fleet-dot-online' : 'fleet-dot-offline'"
|
:class="isOnline(node.reported_at) ? 'fleet-dot-online' : 'fleet-dot-offline'"
|
||||||
></span>
|
></span>
|
||||||
<span class="text-sm font-mono text-white">{{ node.node_id.slice(0, 8) }}</span>
|
<span class="text-sm font-semibold text-white truncate">{{ fleetNodeDisplayName(node) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="fleet-version-badge">v{{ node.version }}</span>
|
<span class="fleet-version-badge">v{{ node.version }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3 truncate text-xs text-white/40">
|
||||||
|
{{ fleetNodeSubtitle(node) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2 mb-3">
|
<div class="space-y-2 mb-3">
|
||||||
<div class="fleet-metric-row">
|
<div class="fleet-metric-row">
|
||||||
@ -91,7 +94,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
type FleetNode, type SortOption, SORT_OPTIONS,
|
type FleetNode, type SortOption, SORT_OPTIONS,
|
||||||
isOnline, healthBarClass, formatUptime, timeAgo,
|
isOnline, healthBarClass, formatUptime, timeAgo, fleetNodeDisplayName, fleetNodeSubtitle,
|
||||||
} from './useFleetData'
|
} from './useFleetData'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
@ -1,9 +1,20 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { isOnline, normalizeFleetNode, normalizeNodeHistoryResponse, sortFleetNodes, type FleetNode } from '../useFleetData'
|
import {
|
||||||
|
fleetNodeDisplayName,
|
||||||
|
fleetNodeSubtitle,
|
||||||
|
isOnline,
|
||||||
|
normalizeFleetNode,
|
||||||
|
normalizeNodeHistoryResponse,
|
||||||
|
sortFleetNodes,
|
||||||
|
type FleetNode,
|
||||||
|
} from '../useFleetData'
|
||||||
|
|
||||||
function node(id: string, reportedAt: string): FleetNode {
|
function node(id: string, reportedAt: string): FleetNode {
|
||||||
return {
|
return {
|
||||||
node_id: id,
|
node_id: id,
|
||||||
|
node_name: null,
|
||||||
|
hostname: null,
|
||||||
|
server_url: null,
|
||||||
version: '1.8-alpha',
|
version: '1.8-alpha',
|
||||||
uptime_secs: 60,
|
uptime_secs: 60,
|
||||||
cpu_cores: 4,
|
cpu_cores: 4,
|
||||||
@ -49,10 +60,12 @@ describe('fleet data helpers', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('sorts by name alphabetically', () => {
|
it('sorts by name alphabetically', () => {
|
||||||
expect(sortFleetNodes([
|
const zulu = node('zulu', '2026-06-10T11:59:00Z')
|
||||||
node('zulu', '2026-06-10T11:59:00Z'),
|
zulu.node_name = 'Workshop'
|
||||||
node('alpha', '2026-06-10T11:59:00Z'),
|
const alpha = node('alpha', '2026-06-10T11:59:00Z')
|
||||||
], 'name').map(n => n.node_id)).toEqual(['alpha', 'zulu'])
|
alpha.node_name = 'Kitchen'
|
||||||
|
|
||||||
|
expect(sortFleetNodes([zulu, alpha], 'name').map(n => n.node_id)).toEqual(['alpha', 'zulu'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes older telemetry reports with missing metric and container fields', () => {
|
it('normalizes older telemetry reports with missing metric and container fields', () => {
|
||||||
@ -63,6 +76,9 @@ describe('fleet data helpers', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(normalized.node_id).toBe('legacy-node')
|
expect(normalized.node_id).toBe('legacy-node')
|
||||||
|
expect(normalized.node_name).toBeNull()
|
||||||
|
expect(normalized.hostname).toBeNull()
|
||||||
|
expect(normalized.server_url).toBeNull()
|
||||||
expect(normalized.cpu_pct).toBe(0)
|
expect(normalized.cpu_pct).toBe(0)
|
||||||
expect(normalized.mem_pct).toBe(0)
|
expect(normalized.mem_pct).toBe(0)
|
||||||
expect(normalized.disk_pct).toBe(0)
|
expect(normalized.disk_pct).toBe(0)
|
||||||
@ -70,6 +86,28 @@ describe('fleet data helpers', () => {
|
|||||||
expect(normalized.recent_alerts).toEqual([])
|
expect(normalized.recent_alerts).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses node name, hostname, then node id for fleet display labels', () => {
|
||||||
|
const named = normalizeFleetNode({
|
||||||
|
node_id: 'abcdef123456',
|
||||||
|
node_name: 'Kitchen Node',
|
||||||
|
hostname: 'kitchen-node',
|
||||||
|
server_url: 'https://192.168.1.20',
|
||||||
|
})
|
||||||
|
const hostOnly = normalizeFleetNode({
|
||||||
|
node_id: '123456abcdef',
|
||||||
|
hostname: 'workshop-node',
|
||||||
|
server_url: 'https://192.168.1.21',
|
||||||
|
})
|
||||||
|
const idOnly = normalizeFleetNode({ node_id: 'feedfacecafebeef' })
|
||||||
|
|
||||||
|
expect(fleetNodeDisplayName(named)).toBe('Kitchen Node')
|
||||||
|
expect(fleetNodeSubtitle(named)).toBe('kitchen-node')
|
||||||
|
expect(fleetNodeDisplayName(hostOnly)).toBe('workshop-node')
|
||||||
|
expect(fleetNodeSubtitle(hostOnly)).toBe('https://192.168.1.21')
|
||||||
|
expect(fleetNodeDisplayName(idOnly)).toBe('feedface')
|
||||||
|
expect(fleetNodeSubtitle(idOnly)).toBe('feedfacecafebeef')
|
||||||
|
})
|
||||||
|
|
||||||
it('normalizes node history responses from backend entries or legacy history fields', () => {
|
it('normalizes node history responses from backend entries or legacy history fields', () => {
|
||||||
const entry = { timestamp: '2026-06-10T11:59:00Z', cpu_pct: 1, mem_pct: 2, disk_pct: 3 }
|
const entry = { timestamp: '2026-06-10T11:59:00Z', cpu_pct: 1, mem_pct: 2, disk_pct: 3 }
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,9 @@ import type { ChartDataset } from '@/components/LineChart.vue'
|
|||||||
|
|
||||||
export interface FleetNode {
|
export interface FleetNode {
|
||||||
node_id: string
|
node_id: string
|
||||||
|
node_name?: string | null
|
||||||
|
hostname?: string | null
|
||||||
|
server_url?: string | null
|
||||||
version: string
|
version: string
|
||||||
uptime_secs: number
|
uptime_secs: number
|
||||||
cpu_cores: number
|
cpu_cores: number
|
||||||
@ -112,6 +115,17 @@ export function getContainerState(node: FleetNode, appId: string): string | null
|
|||||||
return container.state
|
return container.state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fleetNodeDisplayName(node: FleetNode): string {
|
||||||
|
const name = node.node_name?.trim() || node.hostname?.trim()
|
||||||
|
return name || node.node_id.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fleetNodeSubtitle(node: FleetNode): string {
|
||||||
|
const host = node.hostname?.trim()
|
||||||
|
if (host && host !== fleetNodeDisplayName(node)) return host
|
||||||
|
return node.server_url?.trim() || node.node_id
|
||||||
|
}
|
||||||
|
|
||||||
export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [
|
export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [
|
||||||
{ label: 'Status', value: 'status' },
|
{ label: 'Status', value: 'status' },
|
||||||
{ label: 'Last Seen', value: 'last-seen' },
|
{ label: 'Last Seen', value: 'last-seen' },
|
||||||
@ -133,7 +147,7 @@ export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNod
|
|||||||
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
|
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
|
||||||
break
|
break
|
||||||
case 'name':
|
case 'name':
|
||||||
sorted.sort((a, b) => a.node_id.localeCompare(b.node_id))
|
sorted.sort((a, b) => fleetNodeDisplayName(a).localeCompare(fleetNodeDisplayName(b)))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
return sorted
|
return sorted
|
||||||
@ -146,6 +160,9 @@ function numberOrZero(value: unknown): number {
|
|||||||
export function normalizeFleetNode(node: Partial<FleetNode>): FleetNode {
|
export function normalizeFleetNode(node: Partial<FleetNode>): FleetNode {
|
||||||
return {
|
return {
|
||||||
node_id: typeof node.node_id === 'string' ? node.node_id : 'unknown',
|
node_id: typeof node.node_id === 'string' ? node.node_id : 'unknown',
|
||||||
|
node_name: typeof node.node_name === 'string' ? node.node_name : null,
|
||||||
|
hostname: typeof node.hostname === 'string' ? node.hostname : null,
|
||||||
|
server_url: typeof node.server_url === 'string' ? node.server_url : null,
|
||||||
version: typeof node.version === 'string' ? node.version : 'unknown',
|
version: typeof node.version === 'string' ? node.version : 'unknown',
|
||||||
uptime_secs: numberOrZero(node.uptime_secs),
|
uptime_secs: numberOrZero(node.uptime_secs),
|
||||||
cpu_cores: numberOrZero(node.cpu_cores),
|
cpu_cores: numberOrZero(node.cpu_cores),
|
||||||
|
|||||||
@ -7,6 +7,8 @@ const apps: MarketplaceApp[] = [
|
|||||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'bitcoin:latest' },
|
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'bitcoin:latest' },
|
||||||
{ id: 'homeassistant', title: 'Home Assistant', dockerImage: 'homeassistant:latest' },
|
{ id: 'homeassistant', title: 'Home Assistant', dockerImage: 'homeassistant:latest' },
|
||||||
{ id: 'mempool', title: 'Mempool', dockerImage: 'mempool:latest' },
|
{ id: 'mempool', title: 'Mempool', dockerImage: 'mempool:latest' },
|
||||||
|
{ id: 'thunderhub', title: 'ThunderHub', dockerImage: 'thunderhub:latest' },
|
||||||
|
{ id: 'dwn', title: 'DWN', dockerImage: 'dwn:latest' },
|
||||||
{ id: 'website-only', title: 'Website Only', webUrl: 'https://example.com' },
|
{ id: 'website-only', title: 'Website Only', webUrl: 'https://example.com' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -22,16 +24,18 @@ describe('homeRecommendations', () => {
|
|||||||
expect(recommended.map((app) => app.id)).toEqual([
|
expect(recommended.map((app) => app.id)).toEqual([
|
||||||
'bitcoin-knots',
|
'bitcoin-knots',
|
||||||
'vaultwarden',
|
'vaultwarden',
|
||||||
|
'homeassistant',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns no recommendations once matching apps are installed', () => {
|
it('fills from optional apps once core and recommended apps are installed', () => {
|
||||||
const recommended = getHomeRecommendedApps(apps, {
|
const recommended = getHomeRecommendedApps(apps, {
|
||||||
'bitcoin-knots': {},
|
'bitcoin-knots': {},
|
||||||
'mempool-web': {},
|
'mempool-web': {},
|
||||||
vaultwarden: {},
|
vaultwarden: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(recommended).toEqual([])
|
expect(recommended.map((app) => app.id)).toEqual(['homeassistant'])
|
||||||
|
expect(recommended.some((app) => app.id === 'dwn' || app.id === 'thunderhub')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -16,18 +16,23 @@ export function getHomeRecommendedApps(
|
|||||||
installedPackages: InstalledPackageMap,
|
installedPackages: InstalledPackageMap,
|
||||||
limit = 3,
|
limit = 3,
|
||||||
): MarketplaceApp[] {
|
): MarketplaceApp[] {
|
||||||
return apps
|
const candidates = apps
|
||||||
|
.filter((app) => app.id !== 'dwn' && app.id !== 'thunderhub')
|
||||||
.filter((app) => {
|
.filter((app) => {
|
||||||
if (!app.dockerImage) return false
|
if (!app.dockerImage) return false
|
||||||
if (isMarketplaceAppInstalled(app.id, installedPackages)) return false
|
if (isMarketplaceAppInstalled(app.id, installedPackages)) return false
|
||||||
const tier = getAppTier(app.id)
|
return true
|
||||||
return tier === 'core' || tier === 'recommended'
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const tierRank = (app: MarketplaceApp) => getAppTier(app.id) === 'core' ? 0 : 1
|
const tierRank = (app: MarketplaceApp) => {
|
||||||
|
const tier = getAppTier(app.id)
|
||||||
|
if (tier === 'core') return 0
|
||||||
|
if (tier === 'recommended') return 1
|
||||||
|
return 2
|
||||||
|
}
|
||||||
const tierDiff = tierRank(a) - tierRank(b)
|
const tierDiff = tierRank(a) - tierRank(b)
|
||||||
if (tierDiff !== 0) return tierDiff
|
if (tierDiff !== 0) return tierDiff
|
||||||
return (a.title || a.id).localeCompare(b.title || b.id)
|
return (a.title || a.id).localeCompare(b.title || b.id)
|
||||||
})
|
})
|
||||||
.slice(0, limit)
|
return candidates.slice(0, limit)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,10 @@
|
|||||||
{{ typeof app.description === 'object' ? app.description.short : (app.description || 'No description available') }}
|
{{ typeof app.description === 'object' ? app.description.short : (app.description || 'No description available') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p v-if="!installed && installBlockedReason" class="mb-4 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-100">
|
||||||
|
Requires a full archive Bitcoin node before install.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="flex gap-2 mt-auto">
|
<div class="flex gap-2 mt-auto">
|
||||||
<!-- Installed & starting up (transitional state) -->
|
<!-- Installed & starting up (transitional state) -->
|
||||||
<span
|
<span
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const app: MarketplaceApp = {
|
|||||||
source: 'community',
|
source: 'community',
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountCard(installed: boolean) {
|
function mountCard(installed: boolean, installBlockedReason?: string) {
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
@ -39,6 +39,7 @@ function mountCard(installed: boolean) {
|
|||||||
startingUp: false,
|
startingUp: false,
|
||||||
containersScanned: true,
|
containersScanned: true,
|
||||||
tierLabel: 'recommended',
|
tierLabel: 'recommended',
|
||||||
|
installBlockedReason,
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
plugins: [i18n],
|
plugins: [i18n],
|
||||||
@ -58,4 +59,10 @@ describe('MarketplaceAppCard', () => {
|
|||||||
expect(wrapper.find('.tier-badge').exists()).toBe(false)
|
expect(wrapper.find('.tier-badge').exists()).toBe(false)
|
||||||
expect(wrapper.text()).not.toContain('recommended')
|
expect(wrapper.text()).not.toContain('recommended')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('explains archive-node-only install blocks on cards', () => {
|
||||||
|
const wrapper = mountCard(false, 'You need a full archival bitcoin node before downloading ElectrumX')
|
||||||
|
expect(wrapper.text()).toContain('Requires a full archive Bitcoin node before install.')
|
||||||
|
expect(wrapper.text()).toContain('Bitcoin Pruned')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -72,8 +72,8 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
|||||||
|
|
||||||
/** Get app tier classification (matches backend get_app_tier) */
|
/** Get app tier classification (matches backend get_app_tier) */
|
||||||
export function getAppTier(appId: string): string {
|
export function getAppTier(appId: string): string {
|
||||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
|
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'filebrowser']
|
||||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
||||||
if (core.includes(appId)) return 'core'
|
if (core.includes(appId)) return 'core'
|
||||||
if (recommended.includes(appId)) return 'recommended'
|
if (recommended.includes(appId)) return 'recommended'
|
||||||
return 'optional'
|
return 'optional'
|
||||||
@ -174,17 +174,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
|||||||
manifestUrl: undefined,
|
manifestUrl: undefined,
|
||||||
repoUrl: 'https://github.com/lightningnetwork/lnd'
|
repoUrl: 'https://github.com/lightningnetwork/lnd'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'thunderhub',
|
|
||||||
title: 'ThunderHub',
|
|
||||||
version: '0.13.31',
|
|
||||||
description: 'Lightning node management UI. Manage channels, send and receive payments, view routing fees, and monitor your Lightning node.',
|
|
||||||
icon: '/assets/img/app-icons/thunderhub.svg',
|
|
||||||
author: 'Anthony Potdevin',
|
|
||||||
dockerImage: 'docker.io/apotdevin/thunderhub:v0.13.31',
|
|
||||||
manifestUrl: undefined,
|
|
||||||
repoUrl: 'https://github.com/apotdevin/thunderhub'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'mempool',
|
id: 'mempool',
|
||||||
title: 'Mempool Explorer',
|
title: 'Mempool Explorer',
|
||||||
@ -405,17 +394,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
|||||||
manifestUrl: undefined,
|
manifestUrl: undefined,
|
||||||
repoUrl: 'https://github.com/indeedhub/indeedhub'
|
repoUrl: 'https://github.com/indeedhub/indeedhub'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'dwn',
|
|
||||||
title: 'Decentralized Web Node',
|
|
||||||
version: '0.4.0',
|
|
||||||
description: 'Store and sync your personal data across devices using decentralized web node protocols. Own your data with DID-based access control.',
|
|
||||||
icon: '/assets/img/app-icons/dwn.svg',
|
|
||||||
author: 'TBD',
|
|
||||||
dockerImage: `${REGISTRY}/dwn-server:main`,
|
|
||||||
manifestUrl: undefined,
|
|
||||||
repoUrl: 'https://github.com/TBD54566975/dwn-server'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'nostrudel',
|
id: 'nostrudel',
|
||||||
title: 'noStrudel',
|
title: 'noStrudel',
|
||||||
|
|||||||
@ -98,11 +98,14 @@
|
|||||||
<div class="glass-card p-6 w-full max-w-md">
|
<div class="glass-card p-6 w-full max-w-md">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-white">WiFi Networks</h3>
|
<h3 class="text-lg font-semibold text-white">WiFi Networks</h3>
|
||||||
<button @click="$emit('closeWifi')" class="text-white/40 hover:text-white transition-colors">
|
<div class="flex items-center gap-2">
|
||||||
|
<button @click="$emit('scanWifi')" :disabled="wifiScanning" class="text-xs text-white/50 hover:text-white disabled:opacity-40 transition-colors">Refresh</button>
|
||||||
|
<button @click="$emit('closeWifi')" class="text-white/40 hover:text-white transition-colors">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="wifiScanning">
|
<template v-if="wifiScanning">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@ -115,7 +118,7 @@
|
|||||||
v-for="net in wifiNetworks"
|
v-for="net in wifiNetworks"
|
||||||
:key="net.ssid"
|
:key="net.ssid"
|
||||||
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
|
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
|
||||||
@click="$emit('selectWifi', net.ssid)"
|
@click="$emit('selectWifi', net)"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-white">{{ net.ssid }}</p>
|
<p class="text-sm font-medium text-white">{{ net.ssid }}</p>
|
||||||
@ -130,6 +133,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="wifiScanError">
|
||||||
|
<div class="rounded-lg border border-red-400/20 bg-red-500/10 p-4 text-sm text-red-200">
|
||||||
|
<p>{{ wifiScanError }}</p>
|
||||||
|
<button @click="$emit('scanWifi')" class="mt-3 text-white/80 hover:text-white underline underline-offset-4">Try again</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-sm text-white/50 text-center py-8">No networks found</p>
|
<p class="text-sm text-white/50 text-center py-8">No networks found</p>
|
||||||
</template>
|
</template>
|
||||||
@ -232,6 +241,7 @@ defineProps<{
|
|||||||
wifiSubmitting: boolean
|
wifiSubmitting: boolean
|
||||||
wifiSelectedSsid: string
|
wifiSelectedSsid: string
|
||||||
wifiError: string
|
wifiError: string
|
||||||
|
wifiScanError: string
|
||||||
dnsSelectedProvider: string
|
dnsSelectedProvider: string
|
||||||
dnsServers: string[]
|
dnsServers: string[]
|
||||||
dnsApplying: boolean
|
dnsApplying: boolean
|
||||||
@ -244,8 +254,9 @@ defineEmits<{
|
|||||||
createServiceForApp: [appId: string]
|
createServiceForApp: [appId: string]
|
||||||
createService: [name: string, port: number | null]
|
createService: [name: string, port: number | null]
|
||||||
closeWifi: []
|
closeWifi: []
|
||||||
selectWifi: [ssid: string]
|
selectWifi: [network: { ssid: string; signal: number; security: string }]
|
||||||
connectWifi: [password: string]
|
connectWifi: [password: string]
|
||||||
|
scanWifi: []
|
||||||
cancelWifiConnect: []
|
cancelWifiConnect: []
|
||||||
closeDns: []
|
closeDns: []
|
||||||
selectDnsProvider: [provider: string]
|
selectDnsProvider: [provider: string]
|
||||||
|
|||||||
@ -105,7 +105,6 @@ import Web5NodeVisibility from './Web5NodeVisibility.vue'
|
|||||||
import Web5ConnectedNodes from './Web5ConnectedNodes.vue'
|
import Web5ConnectedNodes from './Web5ConnectedNodes.vue'
|
||||||
// import Web5SharedContent from './Web5SharedContent.vue' // hidden for now
|
// import Web5SharedContent from './Web5SharedContent.vue' // hidden for now
|
||||||
import Web5Identities from './Web5Identities.vue'
|
import Web5Identities from './Web5Identities.vue'
|
||||||
// import Web5DWN from './Web5DWN.vue' // hidden for now
|
|
||||||
// import Web5CredentialsSummary from './Web5CredentialsSummary.vue' // hidden for now
|
// import Web5CredentialsSummary from './Web5CredentialsSummary.vue' // hidden for now
|
||||||
import Web5Monitoring from './Web5Monitoring.vue'
|
import Web5Monitoring from './Web5Monitoring.vue'
|
||||||
import Web5Federation from './Web5Federation.vue'
|
import Web5Federation from './Web5Federation.vue'
|
||||||
@ -122,7 +121,6 @@ const nostrRelaysRef = ref<InstanceType<typeof Web5NostrRelays> | null>(null)
|
|||||||
const nodeVisibilityRef = ref<InstanceType<typeof Web5NodeVisibility> | null>(null)
|
const nodeVisibilityRef = ref<InstanceType<typeof Web5NodeVisibility> | null>(null)
|
||||||
const connectedNodesRef = ref<InstanceType<typeof Web5ConnectedNodes> | null>(null)
|
const connectedNodesRef = ref<InstanceType<typeof Web5ConnectedNodes> | null>(null)
|
||||||
const identitiesRef = ref<InstanceType<typeof Web5Identities> | null>(null)
|
const identitiesRef = ref<InstanceType<typeof Web5Identities> | null>(null)
|
||||||
// const dwnRef = ref(null) // hidden for now
|
|
||||||
// const credentialsRef = ref(null) // hidden for now
|
// const credentialsRef = ref(null) // hidden for now
|
||||||
// const sharedContentRef = ref(null) // hidden for now
|
// const sharedContentRef = ref(null) // hidden for now
|
||||||
// const sendReceiveRef = ref(null) // wallet hidden
|
// const sendReceiveRef = ref(null) // wallet hidden
|
||||||
@ -393,8 +391,6 @@ onMounted(() => {
|
|||||||
nodeVisibilityRef.value?.loadVisibility()
|
nodeVisibilityRef.value?.loadVisibility()
|
||||||
// domainsRef.value?.loadDomainNames() // hidden for now
|
// domainsRef.value?.loadDomainNames() // hidden for now
|
||||||
nostrRelaysRef.value?.loadNostrRelays()
|
nostrRelaysRef.value?.loadNostrRelays()
|
||||||
// dwnRef.value?.loadDwnStatus() // hidden for now
|
|
||||||
// dwnRef.value?.loadDwnProtocols() // hidden for now
|
|
||||||
// credentialsRef.value?.loadCredentials() // hidden for now
|
// credentialsRef.value?.loadCredentials() // hidden for now
|
||||||
// sharedContentRef.value?.loadContentItems() // hidden for now
|
// sharedContentRef.value?.loadContentItems() // hidden for now
|
||||||
|
|
||||||
|
|||||||
@ -1,284 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- Decentralized Web Node (DWN) -->
|
|
||||||
<div class="glass-card p-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
|
||||||
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-white">{{ t('web5.decentralizedWebNode') }}</h2>
|
|
||||||
<p class="text-xs text-white/60">{{ t('web5.dwnDescription') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<router-link v-if="dwnInstalled && dwnRunning" to="/apps/dwn" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
|
||||||
{{ t('web5.manageDwn') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DWN not installed or not running -->
|
|
||||||
<div v-if="!dwnInstalled || !dwnRunning" class="py-6 text-center">
|
|
||||||
<p class="text-white/60 text-sm mb-4">
|
|
||||||
{{ !dwnInstalled ? 'The DWN container is not installed.' : 'The DWN container is not running.' }}
|
|
||||||
{{ !dwnInstalled ? 'Install it from the App Store to enable decentralized data storage and sync.' : 'Start it from the App Store to enable decentralized data storage and sync.' }}
|
|
||||||
</p>
|
|
||||||
<router-link to="/dashboard/marketplace" class="glass-button px-4 py-2 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
|
||||||
<svg 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="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z" />
|
|
||||||
</svg>
|
|
||||||
Open App Store
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status (only shown when DWN is installed and running) -->
|
|
||||||
<template v-if="dwnInstalled && dwnRunning">
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
|
||||||
<div class="bg-white/5 rounded-lg p-3">
|
|
||||||
<div class="text-xs text-white/50 mb-1">{{ t('common.status') }}</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="w-2 h-2 rounded-full" :class="dwnStatus?.running ? 'bg-green-400' : 'bg-red-400'"></div>
|
|
||||||
<span class="text-sm text-white font-medium">{{ dwnStatus?.running ? t('common.running') : t('common.stopped') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white/5 rounded-lg p-3">
|
|
||||||
<div class="text-xs text-white/50 mb-1">Sync</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="w-2 h-2 rounded-full" :class="{
|
|
||||||
'bg-green-400': dwnSyncStatus === 'synced',
|
|
||||||
'bg-yellow-400 animate-pulse': dwnSyncStatus === 'syncing',
|
|
||||||
'bg-red-400': dwnSyncStatus === 'error',
|
|
||||||
'bg-white/30': dwnSyncStatus === 'idle'
|
|
||||||
}"></div>
|
|
||||||
<span class="text-sm text-white font-medium capitalize">{{ dwnSyncStatus }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white/5 rounded-lg p-3">
|
|
||||||
<div class="text-xs text-white/50 mb-1">Storage</div>
|
|
||||||
<span class="text-sm text-white font-medium">{{ formatDwnStorage }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white/5 rounded-lg p-3">
|
|
||||||
<div class="text-xs text-white/50 mb-1">Messages</div>
|
|
||||||
<span class="text-sm text-white font-medium">{{ dwnStatus?.message_count ?? 0 }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Protocols -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div class="text-xs text-white/50">Registered Protocols ({{ dwnProtocols.length }})</div>
|
|
||||||
<button @click="showRegisterProtocol = !showRegisterProtocol" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
|
|
||||||
{{ showRegisterProtocol ? 'Cancel' : '+ Register' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="showRegisterProtocol" class="bg-white/5 rounded-lg p-3 mb-3">
|
|
||||||
<div class="flex gap-2 items-end">
|
|
||||||
<div class="flex-1">
|
|
||||||
<label class="text-xs text-white/50 block mb-1">Protocol URI</label>
|
|
||||||
<input v-model="newProtocolUri" type="text" placeholder="https://example.com/protocol" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
|
||||||
</div>
|
|
||||||
<label class="flex items-center gap-1.5 text-xs text-white/60 cursor-pointer whitespace-nowrap pb-1.5">
|
|
||||||
<input v-model="newProtocolPublished" type="checkbox" class="rounded bg-black/30 border-white/20" />
|
|
||||||
Published
|
|
||||||
</label>
|
|
||||||
<button @click="registerDwnProtocol" :disabled="registeringProtocol || !newProtocolUri.trim()" class="glass-button glass-button-sm px-3 rounded-lg text-xs font-medium disabled:opacity-50 whitespace-nowrap">
|
|
||||||
{{ registeringProtocol ? 'Registering...' : 'Register' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="dwnProtocols.length" class="flex flex-wrap gap-2">
|
|
||||||
<div v-for="proto in dwnProtocols" :key="proto.protocol" class="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/15 border border-blue-500/20 text-xs text-blue-300 group">
|
|
||||||
<span>{{ proto.protocol }}</span>
|
|
||||||
<span v-if="proto.published" class="text-green-400/60" title="Published">•</span>
|
|
||||||
<button @click="removeDwnProtocol(proto.protocol)" :disabled="removingProtocol === proto.protocol" class="opacity-0 group-hover:opacity-100 text-red-400/60 hover:text-red-400 transition-all ml-1" title="Remove">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-xs text-white/30 italic">No protocols registered</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sync Targets -->
|
|
||||||
<div v-if="dwnStatus?.peer_sync_targets?.length" class="mb-4">
|
|
||||||
<div class="text-xs text-white/50 mb-2">Peer Sync Targets</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div v-for="target in dwnStatus.peer_sync_targets" :key="target" class="flex items-center gap-2 text-xs text-white/70 bg-white/5 rounded-lg px-3 py-2">
|
|
||||||
<svg class="w-3 h-3 text-green-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" /></svg>
|
|
||||||
<span class="truncate font-mono">{{ target }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Messages Browser -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div class="text-xs text-white/50">Messages</div>
|
|
||||||
<button @click="toggleDwnMessages" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
|
|
||||||
{{ showDwnMessages ? 'Hide' : 'Browse' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="showDwnMessages">
|
|
||||||
<div v-if="loadingDwnMessages && dwnMessages.length === 0" class="text-xs text-white/40 py-4 text-center">Loading messages...</div>
|
|
||||||
<div v-else-if="dwnMessages.length === 0" class="text-xs text-white/30 italic py-2">No messages stored</div>
|
|
||||||
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
|
|
||||||
<div v-if="loadingDwnMessages" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
|
||||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Refreshing messages...
|
|
||||||
</div>
|
|
||||||
<div v-for="msg in dwnMessages" :key="msg.record_id" class="bg-white/5 rounded-lg p-3">
|
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<span class="text-xs font-mono text-white/50 truncate max-w-[200px]" :title="msg.record_id">{{ (msg.record_id || '').slice(0, 8) }}...</span>
|
|
||||||
<span class="text-xs text-white/40">{{ new Date(msg.date_created).toLocaleString() }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2 text-xs">
|
|
||||||
<span class="text-white/70">{{ msg.author }}</span>
|
|
||||||
<span v-if="msg.descriptor.protocol" class="text-blue-300/80">{{ msg.descriptor.protocol }}</span>
|
|
||||||
<span v-if="msg.descriptor.schema" class="text-purple-300/80">{{ msg.descriptor.schema }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="msg.data" class="mt-1 text-xs text-white/40 font-mono truncate">{{ JSON.stringify(msg.data).slice(0, 120) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Last Sync & Actions -->
|
|
||||||
<div class="flex items-center justify-between pt-3 border-t border-white/10">
|
|
||||||
<div class="text-xs text-white/40">
|
|
||||||
{{ dwnStatus?.last_sync ? `Last sync: ${new Date(dwnStatus.last_sync).toLocaleString()}` : 'Never synced' }}
|
|
||||||
</div>
|
|
||||||
<button @click="syncDWNs" :disabled="syncingDWNs || !dwnStatus?.running" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50">
|
|
||||||
<svg class="w-4 h-4" :class="{ 'animate-spin': syncingDWNs }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
{{ syncingDWNs ? t('web5.syncing') : t('web5.syncNow') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
|
||||||
import { useAppStore } from '@/stores/app'
|
|
||||||
import { PackageState } from '@/types/api'
|
|
||||||
import type { DwnStatusData, DwnProtocol, DwnMessageEntry } from './types'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const appStore = useAppStore()
|
|
||||||
|
|
||||||
const dwnStatus = ref<DwnStatusData | null>(null)
|
|
||||||
const dwnSyncStatus = ref<'synced' | 'syncing' | 'error' | 'idle'>('idle')
|
|
||||||
const dwnInstalled = computed(() => !!appStore.packages['dwn'])
|
|
||||||
const dwnRunning = computed(() => appStore.packages['dwn']?.state === PackageState.Running)
|
|
||||||
const syncingDWNs = ref(false)
|
|
||||||
const dwnProtocols = ref<DwnProtocol[]>([])
|
|
||||||
const dwnMessages = ref<DwnMessageEntry[]>([])
|
|
||||||
const showDwnMessages = ref(false)
|
|
||||||
const loadingDwnMessages = ref(false)
|
|
||||||
const showRegisterProtocol = ref(false)
|
|
||||||
const newProtocolUri = ref('')
|
|
||||||
const newProtocolPublished = ref(false)
|
|
||||||
const registeringProtocol = ref(false)
|
|
||||||
const removingProtocol = ref<string | null>(null)
|
|
||||||
|
|
||||||
const formatDwnStorage = computed(() => {
|
|
||||||
if (!dwnStatus.value) return '0 B'
|
|
||||||
const bytes = dwnStatus.value.storage_bytes
|
|
||||||
if (bytes < 1024) return `${bytes} B`
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadDwnStatus() {
|
|
||||||
try {
|
|
||||||
const res = await rpcClient.call<DwnStatusData>({ method: 'dwn.status' })
|
|
||||||
dwnStatus.value = res
|
|
||||||
dwnSyncStatus.value = (res.sync_status as 'synced' | 'syncing' | 'error' | 'idle') || 'idle'
|
|
||||||
} catch {
|
|
||||||
dwnStatus.value = null
|
|
||||||
dwnSyncStatus.value = 'idle'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncDWNs() {
|
|
||||||
syncingDWNs.value = true
|
|
||||||
dwnSyncStatus.value = 'syncing'
|
|
||||||
try {
|
|
||||||
const res = await rpcClient.call<{ sync_status: string; last_sync: string; messages_synced: number }>({ method: 'dwn.sync' })
|
|
||||||
dwnSyncStatus.value = (res.sync_status as 'synced' | 'syncing' | 'error' | 'idle') || 'synced'
|
|
||||||
await loadDwnStatus()
|
|
||||||
} catch {
|
|
||||||
dwnSyncStatus.value = 'error'
|
|
||||||
} finally {
|
|
||||||
syncingDWNs.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDwnProtocols() {
|
|
||||||
try {
|
|
||||||
const res = await rpcClient.call<{ protocols: DwnProtocol[] }>({ method: 'dwn.list-protocols' })
|
|
||||||
dwnProtocols.value = res.protocols || []
|
|
||||||
} catch {
|
|
||||||
dwnProtocols.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function registerDwnProtocol() {
|
|
||||||
if (registeringProtocol.value || !newProtocolUri.value.trim()) return
|
|
||||||
registeringProtocol.value = true
|
|
||||||
try {
|
|
||||||
await rpcClient.call({ method: 'dwn.register-protocol', params: { protocol: newProtocolUri.value.trim(), published: newProtocolPublished.value } })
|
|
||||||
newProtocolUri.value = ''
|
|
||||||
newProtocolPublished.value = false
|
|
||||||
showRegisterProtocol.value = false
|
|
||||||
await loadDwnProtocols()
|
|
||||||
await loadDwnStatus()
|
|
||||||
} catch {
|
|
||||||
if (import.meta.env.DEV) console.error('Failed to register protocol')
|
|
||||||
} finally {
|
|
||||||
registeringProtocol.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeDwnProtocol(protocol: string) {
|
|
||||||
removingProtocol.value = protocol
|
|
||||||
try {
|
|
||||||
await rpcClient.call({ method: 'dwn.remove-protocol', params: { protocol } })
|
|
||||||
await loadDwnProtocols()
|
|
||||||
await loadDwnStatus()
|
|
||||||
} catch {
|
|
||||||
if (import.meta.env.DEV) console.error('Failed to remove protocol')
|
|
||||||
} finally {
|
|
||||||
removingProtocol.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleDwnMessages() {
|
|
||||||
showDwnMessages.value = !showDwnMessages.value
|
|
||||||
if (showDwnMessages.value) {
|
|
||||||
await loadDwnMessages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDwnMessages() {
|
|
||||||
const hadMessages = dwnMessages.value.length > 0
|
|
||||||
loadingDwnMessages.value = true
|
|
||||||
try {
|
|
||||||
const res = await rpcClient.call<{ messages: DwnMessageEntry[]; count: number }>({ method: 'dwn.query-messages', params: { limit: 50 } })
|
|
||||||
dwnMessages.value = res.messages || []
|
|
||||||
} catch {
|
|
||||||
if (!hadMessages) dwnMessages.value = []
|
|
||||||
} finally {
|
|
||||||
loadingDwnMessages.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ loadDwnStatus, loadDwnProtocols, loadDwnMessages, dwnMessages, showDwnMessages })
|
|
||||||
</script>
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import Web5DWN from '../Web5DWN.vue'
|
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
|
||||||
import { useSyncStore } from '@/stores/sync'
|
|
||||||
import { PackageState } from '@/types/api'
|
|
||||||
import type { DwnMessageEntry } from '../types'
|
|
||||||
|
|
||||||
vi.mock('vue-i18n', () => ({
|
|
||||||
useI18n: () => ({ t: (key: string) => key }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/api/rpc-client', () => ({
|
|
||||||
rpcClient: {
|
|
||||||
call: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
function makeMessage(recordId: string): DwnMessageEntry {
|
|
||||||
return {
|
|
||||||
record_id: recordId,
|
|
||||||
author: 'did:key:alice',
|
|
||||||
date_created: '2026-06-10T10:00:00Z',
|
|
||||||
descriptor: {
|
|
||||||
interface: 'Records',
|
|
||||||
method: 'Write',
|
|
||||||
protocol: 'https://example.com/protocol',
|
|
||||||
},
|
|
||||||
data: { title: recordId },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deferred<T>() {
|
|
||||||
let resolve!: (value: T) => void
|
|
||||||
let reject!: (reason?: unknown) => void
|
|
||||||
const promise = new Promise<T>((res, rej) => {
|
|
||||||
resolve = res
|
|
||||||
reject = rej
|
|
||||||
})
|
|
||||||
return { promise, resolve, reject }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Web5DWN', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setActivePinia(createPinia())
|
|
||||||
vi.clearAllMocks()
|
|
||||||
useSyncStore().data = {
|
|
||||||
'package-data': {
|
|
||||||
dwn: {
|
|
||||||
state: PackageState.Running,
|
|
||||||
manifest: { id: 'dwn', title: 'DWN' },
|
|
||||||
'static-files': {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as never
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps stored messages visible while refresh is pending or fails', async () => {
|
|
||||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
|
||||||
messages: [makeMessage('record-one')],
|
|
||||||
count: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const wrapper = mount(Web5DWN, {
|
|
||||||
global: {
|
|
||||||
stubs: { RouterLink: true },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
;(wrapper.vm as unknown as { showDwnMessages: boolean }).showDwnMessages = true
|
|
||||||
await (wrapper.vm as unknown as { loadDwnMessages: () => Promise<void> }).loadDwnMessages()
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('record-o')
|
|
||||||
|
|
||||||
const pending = deferred<{ messages: DwnMessageEntry[]; count: number }>()
|
|
||||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
|
||||||
|
|
||||||
const refresh = (wrapper.vm as unknown as { loadDwnMessages: () => Promise<void> }).loadDwnMessages()
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('record-o')
|
|
||||||
expect(wrapper.text()).toContain('Refreshing messages...')
|
|
||||||
expect(wrapper.text()).not.toContain('Loading messages...')
|
|
||||||
|
|
||||||
pending.reject(new Error('offline'))
|
|
||||||
await refresh
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('record-o')
|
|
||||||
expect(wrapper.text()).not.toContain('Refreshing messages...')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -3,7 +3,8 @@
|
|||||||
"release_date": "2026-06-11",
|
"release_date": "2026-06-11",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.",
|
"Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.",
|
||||||
"Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and sendrawtransaction-focused whitelist.",
|
"Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist.",
|
||||||
|
"The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC.",
|
||||||
"The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.",
|
"The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.",
|
||||||
"Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.",
|
"Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.",
|
||||||
"Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path."
|
"Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path."
|
||||||
|
|||||||
@ -180,7 +180,7 @@ load_spec_bitcoin-knots() {
|
|||||||
local btc_rpc_headroom="-rpcthreads=16 -rpcworkqueue=256"
|
local btc_rpc_headroom="-rpcthreads=16 -rpcworkqueue=256"
|
||||||
local btc_txrelay_flags="-rpcwhitelistdefault=0"
|
local btc_txrelay_flags="-rpcwhitelistdefault=0"
|
||||||
if [ -f "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth" ]; then
|
if [ -f "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth" ]; then
|
||||||
btc_txrelay_flags="$btc_txrelay_flags -rpcauth=$(cat "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth") -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee"
|
btc_txrelay_flags="$btc_txrelay_flags -rpcauth=$(cat "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth") -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"
|
||||||
fi
|
fi
|
||||||
# Dynamic: prune on small disk
|
# Dynamic: prune on small disk
|
||||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||||
|
|||||||
@ -298,6 +298,32 @@ deploy_node() {
|
|||||||
' 2>/dev/null || true
|
' 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
step "Syncing kiosk display helpers"
|
||||||
|
KIOSK_LAUNCHER="$PROJECT_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh"
|
||||||
|
if [ -f "$KIOSK_LAUNCHER" ]; then
|
||||||
|
scp $SSH_OPTS "$KIOSK_LAUNCHER" "$TARGET:/tmp/archipelago-kiosk-launcher" 2>/dev/null || true
|
||||||
|
ssh $SSH_OPTS "$TARGET" '
|
||||||
|
sudo install -m 755 /tmp/archipelago-kiosk-launcher /usr/local/bin/archipelago-kiosk-launcher
|
||||||
|
rm -f /tmp/archipelago-kiosk-launcher
|
||||||
|
echo " Kiosk launcher updated"
|
||||||
|
' 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
for unit in archipelago-kiosk.service archipelago-kiosk-watchdog.service; do
|
||||||
|
KIOSK_UNIT="$PROJECT_DIR/image-recipe/configs/$unit"
|
||||||
|
[ -f "$KIOSK_UNIT" ] || continue
|
||||||
|
scp $SSH_OPTS "$KIOSK_UNIT" "$TARGET:/tmp/$unit" 2>/dev/null || true
|
||||||
|
ssh $SSH_OPTS "$TARGET" "
|
||||||
|
if ! diff -q '/tmp/$unit' '/etc/systemd/system/$unit' >/dev/null 2>&1; then
|
||||||
|
sudo install -m 644 '/tmp/$unit' '/etc/systemd/system/$unit'
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
echo ' $unit updated'
|
||||||
|
else
|
||||||
|
echo ' $unit unchanged'
|
||||||
|
fi
|
||||||
|
rm -f '/tmp/$unit'
|
||||||
|
" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
# ── Step 13: Rootless podman prereqs ─────────────────────────────
|
# ── Step 13: Rootless podman prereqs ─────────────────────────────
|
||||||
step "Setting up rootless podman prerequisites"
|
step "Setting up rootless podman prerequisites"
|
||||||
ssh $SSH_OPTS "$TARGET" '
|
ssh $SSH_OPTS "$TARGET" '
|
||||||
|
|||||||
@ -703,6 +703,33 @@ if [ "$LIVE" = true ]; then
|
|||||||
rm -f /tmp/archipelago.service
|
rm -f /tmp/archipelago.service
|
||||||
' 2>/dev/null || true
|
' 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Sync kiosk display helpers and units for HDMI/TV nodes. Existing nodes may
|
||||||
|
# not have a git checkout, so live deploy must carry these outside OTA too.
|
||||||
|
KIOSK_LAUNCHER="$PROJECT_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh"
|
||||||
|
if [ -f "$KIOSK_LAUNCHER" ]; then
|
||||||
|
scp $SSH_OPTS "$KIOSK_LAUNCHER" "$TARGET_HOST:/tmp/archipelago-kiosk-launcher" 2>/dev/null || true
|
||||||
|
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||||
|
sudo install -m 755 /tmp/archipelago-kiosk-launcher /usr/local/bin/archipelago-kiosk-launcher
|
||||||
|
rm -f /tmp/archipelago-kiosk-launcher
|
||||||
|
echo " Kiosk launcher updated"
|
||||||
|
' 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
for unit in archipelago-kiosk.service archipelago-kiosk-watchdog.service; do
|
||||||
|
KIOSK_UNIT="$PROJECT_DIR/image-recipe/configs/$unit"
|
||||||
|
[ -f "$KIOSK_UNIT" ] || continue
|
||||||
|
scp $SSH_OPTS "$KIOSK_UNIT" "$TARGET_HOST:/tmp/$unit" 2>/dev/null || true
|
||||||
|
ssh $SSH_OPTS "$TARGET_HOST" "
|
||||||
|
if ! diff -q '/tmp/$unit' '/etc/systemd/system/$unit' >/dev/null 2>&1; then
|
||||||
|
sudo install -m 644 '/tmp/$unit' '/etc/systemd/system/$unit'
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
echo ' $unit updated'
|
||||||
|
else
|
||||||
|
echo ' $unit unchanged'
|
||||||
|
fi
|
||||||
|
rm -f '/tmp/$unit'
|
||||||
|
" 2>/dev/null || true
|
||||||
|
done
|
||||||
if [ -n "${TELEMETRY_COLLECTOR_URL:-}" ]; then
|
if [ -n "${TELEMETRY_COLLECTOR_URL:-}" ]; then
|
||||||
progress "Syncing telemetry collector config"
|
progress "Syncing telemetry collector config"
|
||||||
TMP_TELEMETRY_ENV="$(mktemp)"
|
TMP_TELEMETRY_ENV="$(mktemp)"
|
||||||
|
|||||||
@ -44,7 +44,7 @@ SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
|
|||||||
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
|
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
|
||||||
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
|
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
|
||||||
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
|
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
|
||||||
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:latest"
|
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.19.4"
|
||||||
|
|
||||||
# Networking
|
# Networking
|
||||||
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
|
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
|
||||||
@ -90,7 +90,6 @@ INDEEDHUB_REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8-alpine"
|
|||||||
GITEA_IMAGE="docker.io/gitea/gitea:1.23"
|
GITEA_IMAGE="docker.io/gitea/gitea:1.23"
|
||||||
|
|
||||||
# DWN (Decentralized Web Node)
|
# DWN (Decentralized Web Node)
|
||||||
DWN_SERVER_IMAGE="$ARCHY_REGISTRY/dwn-server:main"
|
|
||||||
|
|
||||||
# Immich stack
|
# Immich stack
|
||||||
IMMICH_POSTGRES_IMAGE="$ARCHY_REGISTRY/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0"
|
IMMICH_POSTGRES_IMAGE="$ARCHY_REGISTRY/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0"
|
||||||
|
|||||||
@ -312,9 +312,16 @@ if [ -n "$UI_REBUILD_LIST" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Update kiosk display helpers used by HDMI/TV installs.
|
||||||
|
if [ -f "$REPO_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" ]; then
|
||||||
|
sudo install -m 755 "$REPO_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" \
|
||||||
|
/usr/local/bin/archipelago-kiosk-launcher
|
||||||
|
ok "Updated archipelago-kiosk-launcher"
|
||||||
|
fi
|
||||||
|
|
||||||
# Update systemd services if changed
|
# Update systemd services if changed
|
||||||
SYSTEMD_UNITS_CHANGED=false
|
SYSTEMD_UNITS_CHANGED=false
|
||||||
for unit in archipelago.service archipelago-fips.service; do
|
for unit in archipelago.service archipelago-fips.service archipelago-kiosk.service archipelago-kiosk-watchdog.service; do
|
||||||
src="$REPO_DIR/image-recipe/configs/$unit"
|
src="$REPO_DIR/image-recipe/configs/$unit"
|
||||||
dst="/etc/systemd/system/$unit"
|
dst="/etc/systemd/system/$unit"
|
||||||
[ -f "$src" ] || continue
|
[ -f "$src" ] || continue
|
||||||
|
|||||||
@ -155,7 +155,6 @@ image_for() {
|
|||||||
electrumx) echo "146.59.87.168:3000/lfg2025/electrumx:v1.18.0" ;;
|
electrumx) echo "146.59.87.168:3000/lfg2025/electrumx:v1.18.0" ;;
|
||||||
fedimint) echo "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0" ;;
|
fedimint) echo "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0" ;;
|
||||||
indeedhub) echo "146.59.87.168:3000/lfg2025/indeedhub:1.0.0" ;;
|
indeedhub) echo "146.59.87.168:3000/lfg2025/indeedhub:1.0.0" ;;
|
||||||
dwn) echo "146.59.87.168:3000/lfg2025/dwn-server:main" ;;
|
|
||||||
botfights) echo "146.59.87.168:3000/lfg2025/botfights:1.1.0" ;;
|
botfights) echo "146.59.87.168:3000/lfg2025/botfights:1.1.0" ;;
|
||||||
gitea) echo "docker.io/gitea/gitea:1.23" ;;
|
gitea) echo "docker.io/gitea/gitea:1.23" ;;
|
||||||
meshtastic) echo "docker.io/meshtastic/meshtasticd:daily-alpine" ;;
|
meshtastic) echo "docker.io/meshtastic/meshtasticd:daily-alpine" ;;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user