Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e65e76cd9d | ||
|
|
6d03ed5a69 | ||
|
|
522c046525 | ||
|
|
56f956973e | ||
|
|
bd69ef41d5 | ||
|
|
eeb08fc78f | ||
|
|
1836b035b4 | ||
|
|
3e01e57c8d | ||
|
|
ca3e2ee0ca | ||
|
|
5859ef77e7 | ||
|
|
f0bd49d03d | ||
|
|
cede77f3bc | ||
|
|
dd8a6cd9d7 | ||
|
|
ab96c97cb9 | ||
|
|
881779005a | ||
|
|
20bc9f250c | ||
|
|
87be717f40 | ||
|
|
75d147b69f | ||
|
|
edaece8716 | ||
|
|
ab27fb97f8 | ||
|
|
d736364ad7 | ||
|
|
e9898ead76 | ||
|
|
b25d41c5c6 | ||
|
|
32902d3891 | ||
|
|
92c578d3d9 | ||
|
|
6240064acf | ||
|
|
19dbf60f03 | ||
|
|
b49d8f1f8a | ||
|
|
ec36ac7e2c | ||
|
|
7104ba0cbf | ||
|
|
d0b08d2790 | ||
|
|
76288f541e | ||
|
|
b701e125b4 | ||
|
|
837ba63466 | ||
|
|
8191d92bed | ||
|
|
ae8359da4b | ||
|
|
d91b858d9b | ||
|
|
19f2125a4d | ||
|
|
a992abcd06 | ||
|
|
4d6b4f76af | ||
|
|
0a94c0097f | ||
|
|
413d50116e | ||
|
|
daad50325b | ||
|
|
e05e356d64 | ||
|
|
cfb304a001 |
+147
@@ -1,5 +1,152 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.75-alpha (2026-05-19)
|
||||
|
||||
- Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger.
|
||||
- Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy.
|
||||
- Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens.
|
||||
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release.
|
||||
|
||||
## v1.7.74-alpha (2026-05-19)
|
||||
|
||||
- App-session right panels now re-focus the iframe after load and when the frame area is activated, so wheel/touch scrolling works immediately after switching tabs or selecting an app on shorter screens.
|
||||
- NetBird now launches through a unified local origin on port `8087` that proxies the dashboard plus `/oauth2`, `/api`, relay, WebSocket, and gRPC routes to `netbird-server`, fixing the embedded login flow that previously ended in `Unauthenticated` or `404 page not found` after logout.
|
||||
- Existing NetBird installs are repaired on adopt/start by rewriting `config.yaml`, `dashboard.env`, and the local nginx proxy config, then creating the missing `netbird-dashboard` and `netbird` proxy containers when needed while preserving NetBird data.
|
||||
- Saleor is still pending and is not included in this release; its registry/installer work remains local until it can be validated separately.
|
||||
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.73-alpha (2026-05-19)
|
||||
|
||||
- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap.
|
||||
- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps.
|
||||
- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details.
|
||||
- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`.
|
||||
- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click.
|
||||
|
||||
## v1.7.72-alpha (2026-05-19)
|
||||
|
||||
- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.
|
||||
- The follow-up release carries the NetBird install fix, Gitea icon polish, mobile app-session fallback updates, and rounder app icon masks from `v1.7.71-alpha` with the Settings modal notes included.
|
||||
- The local Cargo lockfile version metadata is kept in sync with the release bump after the previous release build updated it.
|
||||
|
||||
## v1.7.71-alpha (2026-05-19)
|
||||
|
||||
- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on `100.70.96.88` where Podman rejected the missing host directory.
|
||||
- NetBird start/restart ordering now starts `netbird-server` before the dashboard container so lifecycle actions bring the control plane up before the UI.
|
||||
- App-session invalid IDs and panel-mode fallbacks now return to `/dashboard/apps`, avoiding the stale `/apps` route that could render a 404.
|
||||
- Mobile launches for apps that block iframes now stay inside the Archipelago app-session fallback instead of automatically opening an external browser tab.
|
||||
- Installed Gitea containers now report the packaged Gitea icon, and app icon masks use a rounder radius on mobile grids, app cards, and detail headers.
|
||||
- Validation passed with `npm run type-check`, focused Vitest app-session/app-grid tests, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.70-alpha (2026-05-19)
|
||||
|
||||
- NetBird is being corrected from the peer/client daemon image to the self-hosted NetBird control-plane stack with a launchable dashboard on port `8087`, a combined management/signal/relay server on `8086`, and STUN on UDP `3478`.
|
||||
- App sessions now always launch local apps through direct host ports and carry an explicit dashboard return target, so closing an iframe returns to the launching dashboard screen instead of falling through to browser history or a 404.
|
||||
- Mobile app launches ignore stale desktop panel state and route into the full app-session webview consistently.
|
||||
- The desktop sidebar now pins the logo/version at the top and controller/online/mode controls at the bottom, with only the navigation section scrolling on shorter screens.
|
||||
- Validation passed with catalog JSON checks, `scripts/image-versions.sh` syntax check, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.69-alpha (2026-05-19)
|
||||
|
||||
- App installs now allow up to 10 minutes for the initial `package.install` RPC to return, matching slow container image pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors.
|
||||
- Live diagnostics on `100.70.96.88` confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds.
|
||||
- Gitea and other Docker-image app installs now stay visible during slow registry pulls instead of being marked as failed by the browser before backend install progress can complete.
|
||||
- Gitea is now categorized as a known Data app in My Apps, so a running Gitea container appears with installed apps instead of being filtered into the Websites/Services split.
|
||||
- NetBird `0.71.2` is now available in the app catalog and fallback marketplace data as a recommended networking app using the official `docker.io/netbirdio/netbird:0.71.2` image.
|
||||
- NetBird installs get persistent state under `/var/lib/archipelago/netbird`, `NET_ADMIN`/`NET_RAW`, `/dev/net/tun`, `slirp4netns`, image-version pinning, backend metadata, and health checks through `netbird status`.
|
||||
- The Archipelago terminal now includes `nano` on new disk installs and ISO builds, and self-update installs it on existing nodes if it is missing.
|
||||
- Validation passed with catalog JSON checks, shell syntax checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.68-alpha (2026-05-19)
|
||||
|
||||
- BTCPay Server now ships on the official `docker.io/btcpayserver/btcpayserver:2.3.9` image, fixing the plugin catalog crash caused by newer plugin dependency version metadata while preserving existing datadirs and Postgres databases.
|
||||
- BTCPay release and first-boot health checks no longer depend on `curl` inside the container; they use a bash TCP probe that works with the official image out of the box.
|
||||
- Host nginx now serves Nginx Proxy Manager HTTP-01 challenge files before the Archipelago SPA fallback and is marked as the default HTTP/HTTPS virtual host, so public proxy hosts can issue certificates without hijacking local API traffic.
|
||||
- Nginx Proxy Manager first-boot, runtime repair, and container-doctor paths now pre-create the ACME webroot, keep bind mounts owned by the rootless Archipelago user, and sync issued public proxy hosts into host nginx vhosts.
|
||||
- The Nginx Proxy Manager host-nginx sync now skips proxy hosts with missing certificate files and rolls back the generated nginx include if validation fails, preventing a bad certificate path from poisoning later nginx reloads.
|
||||
- App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry.
|
||||
- System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel.
|
||||
- Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell.
|
||||
- Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.
|
||||
- Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates.
|
||||
|
||||
## v1.7.67-alpha (2026-05-18)
|
||||
|
||||
- Home dashboard status cards now keep the last known good system, VPN, Bitcoin, and FIPS values while route changes or transient RPC failures are in flight, avoiding false "not configured" or "not running" flashes.
|
||||
- Home, Web5 Monitoring, and the Monitoring page headline cards now share the same live system-stat snapshot for CPU, memory, disk, uptime, and load so the visible numbers agree across the UI.
|
||||
- Settings What's New is filled through `v1.7.67-alpha`, including the missing historical `v1.7.44-alpha` through `v1.7.66-alpha` entries.
|
||||
- Bitcoin/Knots/Core shell lifecycle specs now match the Rust app config memory policy: 8 GiB on normal hosts, 4 GiB on low-memory hosts, and pruned Knots uses a larger dbcache on hosts with enough RAM to improve IBD throughput.
|
||||
- ElectrumX/electrs shell lifecycle specs now match the 4 GiB memory policy used by the Rust app config, reducing drift between first boot, reconcile, and app lifecycle paths.
|
||||
- Live assessment of `100.70.96.88` identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup.
|
||||
|
||||
## v1.7.66-alpha (2026-05-18)
|
||||
|
||||
- Nginx Proxy Manager stale-port repair now detects stopped or `Created` Podman records by inspecting `podman ps -a` port metadata, covering records where `podman port nginx-proxy-manager` returns no mapping until start.
|
||||
- Live recovery on `100.70.96.88` removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data.
|
||||
- Validation confirmed Nginx Proxy Manager recovered as healthy and responds through direct admin port `8081`, host compatibility port `81`, and `/app/nginx-proxy-manager/`.
|
||||
|
||||
## v1.7.65-alpha (2026-05-18)
|
||||
|
||||
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
|
||||
- Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
|
||||
|
||||
## v1.7.64-alpha (2026-05-18)
|
||||
|
||||
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
|
||||
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
|
||||
|
||||
## v1.7.63-alpha (2026-05-18)
|
||||
|
||||
- Release automation now rebuilds the Rust backend after bumping the version and before hashing release artifacts, preventing OTA manifests from pointing at a stale backend binary.
|
||||
- This corrected release carries the Nginx Proxy Manager stale-port repair in an updated backend binary, so nodes running `1.7.61-alpha` can actually receive and execute the fix.
|
||||
- Validation confirmed the previously published `v1.7.62-alpha` backend artifact still contained `1.7.61-alpha`, explaining why nodes did not advance after applying that update.
|
||||
|
||||
## v1.7.62-alpha (2026-05-18)
|
||||
|
||||
- Nginx Proxy Manager start and restart now repair stale Podman containers that still publish the admin UI on host port `81`, which conflicts with host nginx on updated nodes.
|
||||
- The repair recreates only the stale Nginx Proxy Manager container metadata while preserving `/var/lib/archipelago/nginx-proxy-manager` data and using the current `8081:81`, `8084:80`, and `8444:443` mappings.
|
||||
- Runtime stale-listener cleanup for Nginx Proxy Manager is shared across start and restart paths so rootless port helper leftovers are still cleared before lifecycle retries.
|
||||
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml` and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.61-alpha (2026-05-18)
|
||||
|
||||
- Multi-container stack installs now keep their app card in the `Installing` state for up to 20 minutes while dependency containers are being pulled and prepared.
|
||||
- BTCPay Server installs no longer appear to vanish or fail after two minutes while Postgres and NBXplorer are still being created before the primary `btcpay-server` container exists.
|
||||
- The stale-transition escape hatch remains short for start, stop, restart, update, and removal operations, so genuinely wedged lifecycle actions still recover quickly.
|
||||
- Live validation on `100.70.96.88` confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect.
|
||||
|
||||
## v1.7.60-alpha (2026-05-18)
|
||||
|
||||
- Meshtastic serial detection now rejects malformed or incomplete handshakes instead of accepting unrelated serial devices as a fallback Meshtastic radio.
|
||||
- Mesh radio auto-detection now skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks, avoiding interference with production peripherals.
|
||||
- Meshtastic config sync now sends `want_config_id` with the correct protobuf wire type, fixing radio-side `ignore malformed toradio` errors and allowing node-info/contact ingestion.
|
||||
- The stable `/dev/mesh-radio` udev rule no longer claims every `ttyACM*` device; it only matches known mesh USB serial adapters and known USB CDC ACM radio vendors.
|
||||
- Live validation on `100.70.96.88` confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts.
|
||||
|
||||
## v1.7.59-alpha (2026-05-17)
|
||||
|
||||
- Mobile app launching now keeps known container apps inside Archipelago's app-session flow instead of forcing desktop-only new-tab behavior on phones.
|
||||
- App sessions on mobile now respect the status-bar safe area so foreground iframe content starts below the device chrome while the fullscreen backdrop remains edge-to-edge.
|
||||
- Prepackaged website launch buttons now resolve their curated website URLs before website-container fallback logic, restoring launches for the L484 sites and adding the Arch Presentation bookmark.
|
||||
- Meshtastic contact discovery now drains the radio config stream through completion and retries config sync when the contact cache is empty, so nearby nodes already known by the radio are more likely to appear in Archipelago.
|
||||
- The Apps page now includes a compact sideload button and modal for installing trusted Docker images with optional title, description, and port mapping metadata.
|
||||
- Sideloaded app title and description metadata now persist through the backend app-config file so refreshed package scans do not collapse custom apps back to generic IDs.
|
||||
- Validation passed with `npm test -- appLauncher`, `npm run build`, `cargo check -p archipelago`, and `cargo fmt --all --check`.
|
||||
|
||||
## v1.7.58-alpha (2026-05-17)
|
||||
|
||||
- Mesh networking now supports Meshtastic radios over the Meshtastic serial API in addition to existing MeshCore Companion USB radios.
|
||||
- The mesh listener now probes preferred and auto-detected serial paths for both MeshCore and Meshtastic firmware, preserving the existing reconnect loop so unplug/replug and firmware hot-swap behavior stays consistent.
|
||||
- Meshtastic text packets are translated into the existing Archipelago mesh frame pipeline, so current RPC handlers, transport routing, message storage, typed-message decoding, and UI state continue to work without a separate frontend path.
|
||||
- Meshtastic node information is surfaced as normal mesh contacts using stable synthetic public keys derived from Meshtastic node numbers, allowing peer refresh and message attribution to reuse existing MeshCore contact handling.
|
||||
- Outbound Archipelago mesh messages can now be sent through Meshtastic as channel text packets using the same command path used by MeshCore channel broadcasts.
|
||||
- Device status now reports the detected firmware family as `meshcore` or `meshtastic` from the shared listener abstraction.
|
||||
- Radio udev rules now include USB CDC ACM serial devices (`ttyACM*`) alongside CP2102, CH340, and FTDI adapters so Meshtastic boards are more likely to appear through the stable `/dev/mesh-radio` symlink.
|
||||
- Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell.
|
||||
- The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates.
|
||||
- OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy.
|
||||
- Local validation passed with `cargo check -p archipelago`; live SSH testing against `100.70.96.88` was not completed because temporary public-key authentication was rejected on the target.
|
||||
|
||||
## v1.7.57-alpha (2026-05-17)
|
||||
|
||||
- Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.
|
||||
|
||||
@@ -52,18 +52,35 @@
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "1.13.7",
|
||||
"version": "2.3.9",
|
||||
"description": "Self-hosted Bitcoin payment processor.",
|
||||
"icon": "/assets/img/app-icons/btcpay-server.png",
|
||||
"author": "BTCPay Server Foundation",
|
||||
"category": "commerce",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7",
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "saleor",
|
||||
"title": "Saleor",
|
||||
"version": "3.23",
|
||||
"description": "Composable commerce platform with GraphQL API, dashboard, worker, mail testing, and tracing.",
|
||||
"icon": "/assets/img/app-icons/saleor.svg",
|
||||
"author": "Saleor",
|
||||
"category": "commerce",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "ghcr.io/saleor/saleor:3.23",
|
||||
"repoUrl": "https://github.com/saleor/saleor",
|
||||
"containerConfig": {
|
||||
"ports": ["9000:80", "8000:8000", "8025:8025", "16686:16686"],
|
||||
"volumes": ["/var/lib/archipelago/saleor:/app/media", "/var/lib/archipelago/saleor-db:/var/lib/postgresql/data"],
|
||||
"notes": "Installed as a Saleor stack: dashboard on 9000, API on 8000, Mailpit on 8025, and Jaeger on 16686. Supporting containers include PostgreSQL, Valkey, Celery worker, and services required by Saleor."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
@@ -275,6 +292,23 @@
|
||||
"args": ["sh", "-c", "tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "netbird",
|
||||
"title": "NetBird",
|
||||
"version": "0.71.2",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/netbirdio/dashboard:v2.38.0",
|
||||
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||
"containerConfig": {
|
||||
"ports": ["8087:80", "8086:80", "3478:3478/udp"],
|
||||
"volumes": ["/var/lib/archipelago/netbird:/var/lib/netbird"],
|
||||
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "uptime-kuma",
|
||||
"title": "Uptime Kuma",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
app:
|
||||
id: btcpay-server
|
||||
name: BTCPay Server
|
||||
version: 1.13.7
|
||||
version: 2.3.9
|
||||
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/btcpayserver:1.13.7
|
||||
image: docker.io/btcpayserver/btcpayserver:2.3.9
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
|
||||
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.57-alpha"
|
||||
version = "1.7.74-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.57-alpha"
|
||||
version = "1.7.75-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -31,6 +31,7 @@ fn is_platform_managed_app(app_id: &str) -> bool {
|
||||
| "fedimint"
|
||||
| "fedimint-gateway"
|
||||
| "indeedhub"
|
||||
| "saleor"
|
||||
| "immich"
|
||||
)
|
||||
}
|
||||
@@ -217,7 +218,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
],
|
||||
// Nostr VPN and FIPS: mesh networking daemons need TUN + NET_ADMIN
|
||||
// VPN/mesh daemons need TUN + NET_ADMIN.
|
||||
// Note: --device=/dev/net/tun is added separately in install.rs
|
||||
"nostr-vpn" | "fips" => vec![
|
||||
"--cap-add=NET_ADMIN".to_string(),
|
||||
@@ -262,7 +263,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => return vec![],
|
||||
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
|
||||
"btcpay-server" | "btcpayserver" => {
|
||||
("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
|
||||
("bash -ec '</dev/tcp/127.0.0.1/49392'", "30s", "3")
|
||||
}
|
||||
"mempool-api" => (
|
||||
http_probe_cmd("http://localhost:8999/api/v1/backend-info"),
|
||||
@@ -389,6 +390,7 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
"nostr-rs-relay" | "nostr-relay" => "256m",
|
||||
"routstr" => "512m",
|
||||
"nostr-vpn" => "256m",
|
||||
"netbird" => "1g",
|
||||
"fips" => "256m",
|
||||
"nginx-proxy-manager" => "256m",
|
||||
// Databases
|
||||
@@ -494,6 +496,20 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"indeedhub-ffmpeg".into(),
|
||||
"indeedhub".into(),
|
||||
],
|
||||
"netbird" => vec![
|
||||
"netbird".into(),
|
||||
"netbird-dashboard".into(),
|
||||
"netbird-server".into(),
|
||||
],
|
||||
"saleor" => vec![
|
||||
"saleor-db".into(),
|
||||
"saleor-cache".into(),
|
||||
"saleor-api".into(),
|
||||
"saleor-worker".into(),
|
||||
"saleor-jaeger".into(),
|
||||
"saleor-mailpit".into(),
|
||||
"saleor".into(),
|
||||
],
|
||||
"nostr-vpn" => vec![
|
||||
"nostr-vpn".into(),
|
||||
"archy-nostr-vpn".into(),
|
||||
@@ -582,6 +598,8 @@ pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
format!("{}/penpot-assets", base),
|
||||
format!("{}/penpot-postgres", base),
|
||||
],
|
||||
"netbird" => vec![format!("{}/netbird", base)],
|
||||
"saleor" => vec![format!("{}/saleor", base), format!("{}/saleor-db", base)],
|
||||
_ => vec![format!("{}/{}", base, package_id)],
|
||||
}
|
||||
}
|
||||
@@ -1061,6 +1079,13 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"saleor" => (
|
||||
vec!["9000:80".to_string(), "8000:8000".to_string()],
|
||||
vec!["/var/lib/archipelago/saleor:/app/media".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"nostr-rs-relay" => (
|
||||
vec!["18081:8080".to_string()],
|
||||
vec!["/var/lib/archipelago/nostr-rs-relay:/usr/src/app/db".to_string()],
|
||||
|
||||
@@ -288,6 +288,16 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"saleor" => &[
|
||||
"saleor-db",
|
||||
"saleor-cache",
|
||||
"saleor-jaeger",
|
||||
"saleor-mailpit",
|
||||
"saleor-api",
|
||||
"saleor-worker",
|
||||
"saleor",
|
||||
],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
@@ -389,6 +399,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn netbird_start_order_starts_server_before_dashboard() {
|
||||
assert_eq!(
|
||||
startup_order("netbird"),
|
||||
&["netbird-server", "netbird-dashboard", "netbird"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
|
||||
for package_id in [
|
||||
|
||||
@@ -241,6 +241,12 @@ impl RpcHandler {
|
||||
if package_id == "indeedhub" {
|
||||
return self.install_indeedhub_stack().await;
|
||||
}
|
||||
if package_id == "netbird" {
|
||||
return self.install_netbird_stack().await;
|
||||
}
|
||||
if package_id == "saleor" {
|
||||
return self.install_saleor_stack().await;
|
||||
}
|
||||
|
||||
// Dependency checks. Prefer the scanner's cached package state so a
|
||||
// congested Podman API does not turn an already-running dependency into
|
||||
@@ -376,7 +382,8 @@ impl RpcHandler {
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await {
|
||||
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await
|
||||
{
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
|
||||
package_id, e
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::config::{get_containers_for_app, get_data_dirs_for_app, is_valid_docker_image};
|
||||
use super::config::{
|
||||
get_app_capabilities, get_containers_for_app, get_data_dirs_for_app, get_health_check_args,
|
||||
get_memory_limit, is_valid_docker_image,
|
||||
};
|
||||
use super::dependencies::ordered_containers_for_start;
|
||||
use super::install::install_log;
|
||||
use super::validation::validate_app_id;
|
||||
@@ -665,6 +668,7 @@ async fn do_orchestrator_package_start(
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
repair_before_package_start(name).await;
|
||||
match orchestrator.start(name).await {
|
||||
Ok(()) => wait_after_orchestrator_start(name).await,
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
@@ -863,11 +867,147 @@ async fn repair_before_package_start(container_name: &str) {
|
||||
repair_nextcloud_dirs().await;
|
||||
cleanup_stale_pasta_port("8085").await;
|
||||
}
|
||||
"nginx-proxy-manager" => repair_nginx_proxy_manager_container().await,
|
||||
"gitea" => cleanup_gitea_stale_ports().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_nginx_proxy_manager_container() {
|
||||
repair_nginx_proxy_manager_dirs().await;
|
||||
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
||||
cleanup_nginx_proxy_manager_ports().await;
|
||||
return;
|
||||
}
|
||||
|
||||
install_log(
|
||||
"START REPAIR: nginx-proxy-manager - recreating stale container using host port 8081",
|
||||
)
|
||||
.await;
|
||||
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
|
||||
cleanup_nginx_proxy_manager_ports().await;
|
||||
if let Err(err) = recreate_nginx_proxy_manager_container().await {
|
||||
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_nginx_proxy_manager_dirs() {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"chown",
|
||||
"-R",
|
||||
"1000:1000",
|
||||
"/var/lib/archipelago/nginx-proxy-manager",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
|
||||
if let Ok(output) = podman_control(&["port", "nginx-proxy-manager", "81/tcp"]).await {
|
||||
if output.status.success()
|
||||
&& String::from_utf8_lossy(&output.stdout).lines().any(|line| {
|
||||
line.rsplit(':')
|
||||
.next()
|
||||
.is_some_and(|port| port.trim() == "81")
|
||||
})
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(output) = podman_control(&[
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Ports}}",
|
||||
"--filter",
|
||||
"name=^nginx-proxy-manager$",
|
||||
])
|
||||
.await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
let ports = String::from_utf8_lossy(&output.stdout);
|
||||
ports.contains(":81->81/tcp") || ports.contains(":8443->443/tcp")
|
||||
}
|
||||
|
||||
async fn recreate_nginx_proxy_manager_container() -> Result<()> {
|
||||
tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to create nginx-proxy-manager data directories")?;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"chown",
|
||||
"-R",
|
||||
"1000:1000",
|
||||
"/var/lib/archipelago/nginx-proxy-manager",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let image = crate::container::image_versions::pinned_image_for_app("nginx-proxy-manager")
|
||||
.unwrap_or_else(|| "docker.io/jc21/nginx-proxy-manager:latest".to_string());
|
||||
let mut args = vec![
|
||||
"run".to_string(),
|
||||
"-d".to_string(),
|
||||
"--name".to_string(),
|
||||
"nginx-proxy-manager".to_string(),
|
||||
"--restart=unless-stopped".to_string(),
|
||||
"--network=slirp4netns:allow_host_loopback=true".to_string(),
|
||||
"--cap-drop=ALL".to_string(),
|
||||
"--security-opt=no-new-privileges:true".to_string(),
|
||||
"--pids-limit=4096".to_string(),
|
||||
];
|
||||
args.extend(get_app_capabilities("nginx-proxy-manager"));
|
||||
args.extend([
|
||||
"-p".to_string(),
|
||||
"8081:81".to_string(),
|
||||
"-p".to_string(),
|
||||
"8084:80".to_string(),
|
||||
"-p".to_string(),
|
||||
"8444:443".to_string(),
|
||||
"-v".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
"-v".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
"--memory".to_string(),
|
||||
get_memory_limit("nginx-proxy-manager").to_string(),
|
||||
"--cpus=2".to_string(),
|
||||
]);
|
||||
args.extend(get_health_check_args("nginx-proxy-manager", ""));
|
||||
args.push(image);
|
||||
|
||||
let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let output = podman_control(&refs).await?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman run nginx-proxy-manager failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
|
||||
let Some(port) = runtime_required_host_port(container_name) else {
|
||||
return Ok(());
|
||||
@@ -1075,15 +1215,17 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
|
||||
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
|
||||
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
|
||||
"nextcloud" => cleanup_stale_pasta_port("8085").await,
|
||||
"nginx-proxy-manager" => {
|
||||
cleanup_stale_pasta_port("8081").await;
|
||||
cleanup_stale_pasta_port("8084").await;
|
||||
cleanup_stale_pasta_port("8444").await;
|
||||
}
|
||||
"nginx-proxy-manager" => cleanup_nginx_proxy_manager_ports().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_nginx_proxy_manager_ports() {
|
||||
cleanup_stale_pasta_port("8081").await;
|
||||
cleanup_stale_pasta_port("8084").await;
|
||||
cleanup_stale_pasta_port("8444").await;
|
||||
}
|
||||
|
||||
async fn cleanup_stale_pasta_port(port: &str) {
|
||||
let kill_listener = format!(
|
||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::InstallPhase;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use tracing::info;
|
||||
|
||||
use super::install::{install_log, patch_indeedhub_nostr_provider};
|
||||
@@ -97,6 +98,8 @@ async fn repair_stack_before_adopt(stack_name: &str) {
|
||||
}
|
||||
}
|
||||
"indeedhub" => repair_indeedhub_network_aliases().await,
|
||||
"netbird" => repair_netbird_unified_origin().await,
|
||||
"saleor" => repair_saleor_network_aliases().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +146,131 @@ pub(in crate::api::rpc::package) async fn repair_indeedhub_network_aliases() {
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_netbird_unified_origin() {
|
||||
let host_ip = detect_netbird_public_host_ip()
|
||||
.await
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let _ = write_netbird_config_files(&host_ip).await;
|
||||
|
||||
for container in ["netbird", "netbird-dashboard"] {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", container])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "netbird-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = pull_image_with_retry(NETBIRD_DASHBOARD_IMAGE).await;
|
||||
let _ = pull_image_with_retry(NETBIRD_PROXY_IMAGE).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"netbird-dashboard",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--restart=unless-stopped",
|
||||
"--env-file",
|
||||
"/var/lib/archipelago/netbird/dashboard.env",
|
||||
NETBIRD_DASHBOARD_IMAGE,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"netbird",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--restart=unless-stopped",
|
||||
"-p",
|
||||
"8087:80",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
|
||||
NETBIRD_PROXY_IMAGE,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"disconnect",
|
||||
"-f",
|
||||
"netbird-net",
|
||||
"netbird-server",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"connect",
|
||||
"--alias",
|
||||
"netbird-server",
|
||||
"netbird-net",
|
||||
"netbird-server",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", "netbird-server"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn repair_saleor_network_aliases() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "saleor-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
for (container, alias) in [
|
||||
("saleor-db", "db"),
|
||||
("saleor-cache", "cache"),
|
||||
("saleor-jaeger", "jaeger"),
|
||||
("saleor-mailpit", "mailpit"),
|
||||
("saleor-api", "api"),
|
||||
("saleor-worker", "worker"),
|
||||
("saleor", "saleor"),
|
||||
] {
|
||||
let exists = tokio::process::Command::new("podman")
|
||||
.args(["container", "exists", container])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !exists {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "disconnect", "-f", "saleor-net", container])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"connect",
|
||||
"--alias",
|
||||
alias,
|
||||
"saleor-net",
|
||||
container,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_required_stack_command(
|
||||
stack_name: &str,
|
||||
label: &str,
|
||||
@@ -310,6 +438,16 @@ fn mempool_stack_app_ids() -> &'static [&'static str] {
|
||||
|
||||
const REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
||||
|
||||
const NETBIRD_DASHBOARD_IMAGE: &str = "docker.io/netbirdio/dashboard:v2.38.0";
|
||||
const NETBIRD_SERVER_IMAGE: &str = "docker.io/netbirdio/netbird-server:0.71.2";
|
||||
const NETBIRD_PROXY_IMAGE: &str = "docker.io/library/nginx:1.27-alpine";
|
||||
const SALEOR_API_IMAGE: &str = "ghcr.io/saleor/saleor:3.23";
|
||||
const SALEOR_DASHBOARD_IMAGE: &str = "ghcr.io/saleor/saleor-dashboard:3.23";
|
||||
const SALEOR_POSTGRES_IMAGE: &str = "docker.io/library/postgres:15-alpine";
|
||||
const SALEOR_VALKEY_IMAGE: &str = "docker.io/valkey/valkey:8.1-alpine";
|
||||
const SALEOR_JAEGER_IMAGE: &str = "docker.io/jaegertracing/jaeger:latest";
|
||||
const SALEOR_MAILPIT_IMAGE: &str = "docker.io/axllent/mailpit:latest";
|
||||
|
||||
/// Pull an image with retry and exponential backoff (3 attempts).
|
||||
async fn pull_image_with_retry(image: &str) -> Result<()> {
|
||||
const MAX_ATTEMPTS: u32 = 3;
|
||||
@@ -587,7 +725,7 @@ impl RpcHandler {
|
||||
let images = [
|
||||
&format!("{}/postgres:15.17", REGISTRY),
|
||||
&format!("{}/nbxplorer:2.6.0", REGISTRY),
|
||||
&format!("{}/btcpayserver:1.13.7", REGISTRY),
|
||||
"docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||
];
|
||||
self.set_install_phase("btcpay-server", InstallPhase::PullingImage)
|
||||
.await;
|
||||
@@ -743,7 +881,7 @@ impl RpcHandler {
|
||||
"BTCPAY_POSTGRES=User ID=btcpay;Password={};Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true",
|
||||
db_pass
|
||||
),
|
||||
&format!("{}/btcpayserver:1.13.7", REGISTRY),
|
||||
"docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
@@ -1357,6 +1495,688 @@ impl RpcHandler {
|
||||
"message": "IndeedHub stack installed (7 containers)",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Install self-hosted NetBird (dashboard + combined management/signal/relay server).
|
||||
pub(super) async fn install_netbird_stack(&self) -> Result<serde_json::Value> {
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"netbird",
|
||||
"netbird",
|
||||
&["netbird-server", "netbird-dashboard", "netbird"],
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(adopted);
|
||||
}
|
||||
|
||||
install_log("INSTALL START: netbird stack (dashboard + server)").await;
|
||||
info!("Installing self-hosted NetBird stack");
|
||||
|
||||
self.set_install_phase("netbird", InstallPhase::PullingImage)
|
||||
.await;
|
||||
for (i, image) in [
|
||||
NETBIRD_DASHBOARD_IMAGE,
|
||||
NETBIRD_SERVER_IMAGE,
|
||||
NETBIRD_PROXY_IMAGE,
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
self.set_install_progress("netbird", i as u64, 3).await;
|
||||
pull_image_with_retry(image)
|
||||
.await
|
||||
.with_context(|| format!("Failed to pull NetBird image: {}", image))?;
|
||||
}
|
||||
self.set_install_progress("netbird", 3, 3).await;
|
||||
|
||||
for name in ["netbird", "netbird-dashboard", "netbird-server"] {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", name])
|
||||
.status()
|
||||
.await;
|
||||
}
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "rm", "-f", "netbird-net"])
|
||||
.status()
|
||||
.await;
|
||||
|
||||
self.set_install_phase("netbird", InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
tokio::fs::create_dir_all("/var/lib/archipelago/netbird/data")
|
||||
.await
|
||||
.context("Failed to create NetBird data directory")?;
|
||||
|
||||
let host_ip = detect_netbird_public_host_ip()
|
||||
.await
|
||||
.unwrap_or_else(|| self.config.host_ip.clone());
|
||||
write_netbird_config_files(&host_ip).await?;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "netbird-net"])
|
||||
.status()
|
||||
.await;
|
||||
|
||||
let mut server_cmd = tokio::process::Command::new("podman");
|
||||
server_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"netbird-server",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--network-alias",
|
||||
"netbird-server",
|
||||
"--restart=unless-stopped",
|
||||
"-p",
|
||||
"8086:80",
|
||||
"-p",
|
||||
"3478:3478/udp",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/data:/var/lib/netbird",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/config.yaml:/etc/netbird/config.yaml:ro",
|
||||
NETBIRD_SERVER_IMAGE,
|
||||
"--config",
|
||||
"/etc/netbird/config.yaml",
|
||||
]);
|
||||
run_required_stack_command("netbird", "create server", &mut server_cmd).await?;
|
||||
|
||||
self.set_install_phase("netbird", InstallPhase::StartingContainer)
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let mut dashboard_cmd = tokio::process::Command::new("podman");
|
||||
dashboard_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"netbird-dashboard",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--restart=unless-stopped",
|
||||
"--env-file",
|
||||
"/var/lib/archipelago/netbird/dashboard.env",
|
||||
NETBIRD_DASHBOARD_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("netbird", "create dashboard", &mut dashboard_cmd).await?;
|
||||
|
||||
let mut proxy_cmd = tokio::process::Command::new("podman");
|
||||
proxy_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"netbird",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--restart=unless-stopped",
|
||||
"-p",
|
||||
"8087:80",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
|
||||
NETBIRD_PROXY_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("netbird", "create unified proxy", &mut proxy_cmd).await?;
|
||||
|
||||
wait_for_stack_containers(
|
||||
"netbird",
|
||||
&["netbird-server", "netbird-dashboard", "netbird"],
|
||||
60,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.set_install_phase("netbird", InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
self.set_install_phase("netbird", InstallPhase::PostInstall)
|
||||
.await;
|
||||
self.set_install_phase("netbird", InstallPhase::Done).await;
|
||||
self.clear_install_progress("netbird").await;
|
||||
|
||||
install_log("INSTALL OK: netbird stack").await;
|
||||
info!("NetBird stack installed");
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": "netbird",
|
||||
"message": "NetBird self-hosted stack installed",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Install Saleor stack (PostgreSQL + Valkey + API + worker + dashboard + Jaeger + Mailpit).
|
||||
pub(super) async fn install_saleor_stack(&self) -> Result<serde_json::Value> {
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"saleor",
|
||||
"saleor",
|
||||
&[
|
||||
"saleor-db",
|
||||
"saleor-cache",
|
||||
"saleor-jaeger",
|
||||
"saleor-mailpit",
|
||||
"saleor-api",
|
||||
"saleor-worker",
|
||||
"saleor",
|
||||
],
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(adopted);
|
||||
}
|
||||
|
||||
install_log("INSTALL START: saleor stack (postgres + valkey + api + worker + dashboard)")
|
||||
.await;
|
||||
info!("Installing Saleor stack");
|
||||
|
||||
let images = [
|
||||
SALEOR_POSTGRES_IMAGE,
|
||||
SALEOR_VALKEY_IMAGE,
|
||||
SALEOR_API_IMAGE,
|
||||
SALEOR_DASHBOARD_IMAGE,
|
||||
SALEOR_JAEGER_IMAGE,
|
||||
SALEOR_MAILPIT_IMAGE,
|
||||
];
|
||||
self.set_install_phase("saleor", InstallPhase::PullingImage)
|
||||
.await;
|
||||
let n_images = images.len() as u64;
|
||||
for (i, image) in images.iter().enumerate() {
|
||||
self.set_install_progress("saleor", i as u64, n_images)
|
||||
.await;
|
||||
pull_image_with_retry(image)
|
||||
.await
|
||||
.with_context(|| format!("Failed to pull Saleor image: {}", image))?;
|
||||
}
|
||||
self.set_install_progress("saleor", n_images, n_images)
|
||||
.await;
|
||||
|
||||
for name in [
|
||||
"saleor",
|
||||
"saleor-api",
|
||||
"saleor-worker",
|
||||
"saleor-db",
|
||||
"saleor-cache",
|
||||
"saleor-jaeger",
|
||||
"saleor-mailpit",
|
||||
] {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", name])
|
||||
.status()
|
||||
.await;
|
||||
}
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "rm", "-f", "saleor-net"])
|
||||
.status()
|
||||
.await;
|
||||
|
||||
self.set_install_phase("saleor", InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/saleor",
|
||||
"/var/lib/archipelago/saleor-db",
|
||||
"/var/lib/archipelago/saleor-cache",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let user = std::env::var("USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||
for dir in [
|
||||
"/var/lib/archipelago/saleor",
|
||||
"/var/lib/archipelago/saleor-db",
|
||||
"/var/lib/archipelago/saleor-cache",
|
||||
] {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "-R", &format!("{}:{}", user, user), dir])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "saleor-net"])
|
||||
.status()
|
||||
.await;
|
||||
|
||||
let db_pass = super::config::read_or_generate_secret("saleor-db-password").await;
|
||||
let secret_key = super::config::read_or_generate_secret("saleor-secret-key").await;
|
||||
let host_ip = &self.config.host_ip;
|
||||
let dashboard_url = format!("http://{}:9000/", host_ip);
|
||||
let api_url = format!("http://{}:8000/graphql/", host_ip);
|
||||
let allowed_hosts = format!("localhost,127.0.0.1,api,saleor-api,{}", host_ip);
|
||||
let database_url = format!("postgres://saleor:{}@db/saleor", db_pass);
|
||||
|
||||
let mut db_cmd = tokio::process::Command::new("podman");
|
||||
db_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor-db",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--network-alias",
|
||||
"db",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=FOWNER",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=4096",
|
||||
"--health-cmd=pg_isready -U saleor || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
"-v",
|
||||
"/var/lib/archipelago/saleor-db:/var/lib/postgresql/data",
|
||||
"-e",
|
||||
"POSTGRES_USER=saleor",
|
||||
"-e",
|
||||
&format!("POSTGRES_PASSWORD={}", db_pass),
|
||||
"-e",
|
||||
"POSTGRES_DB=saleor",
|
||||
SALEOR_POSTGRES_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("saleor", "create postgres", &mut db_cmd).await?;
|
||||
|
||||
let mut cache_cmd = tokio::process::Command::new("podman");
|
||||
cache_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor-cache",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--network-alias",
|
||||
"cache",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=128m",
|
||||
"--pids-limit=2048",
|
||||
"--health-cmd=valkey-cli ping || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
"-v",
|
||||
"/var/lib/archipelago/saleor-cache:/data",
|
||||
SALEOR_VALKEY_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("saleor", "create cache", &mut cache_cmd).await?;
|
||||
|
||||
let mut jaeger_cmd = tokio::process::Command::new("podman");
|
||||
jaeger_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor-jaeger",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--network-alias",
|
||||
"jaeger",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=4096",
|
||||
"-p",
|
||||
"16686:16686",
|
||||
"-p",
|
||||
"4317:4317",
|
||||
"-p",
|
||||
"4318:4318",
|
||||
"--tmpfs",
|
||||
"/tmp:rw,nosuid,nodev,size=128m",
|
||||
SALEOR_JAEGER_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("saleor", "create jaeger", &mut jaeger_cmd).await?;
|
||||
|
||||
let mut mailpit_cmd = tokio::process::Command::new("podman");
|
||||
mailpit_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor-mailpit",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--network-alias",
|
||||
"mailpit",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=128m",
|
||||
"--pids-limit=2048",
|
||||
"-p",
|
||||
"1025:1025",
|
||||
"-p",
|
||||
"8025:8025",
|
||||
SALEOR_MAILPIT_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("saleor", "create mailpit", &mut mailpit_cmd).await?;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
|
||||
|
||||
let saleor_env = vec![
|
||||
"-e".to_string(),
|
||||
"CACHE_URL=redis://cache:6379/0".to_string(),
|
||||
"-e".to_string(),
|
||||
"CELERY_BROKER_URL=redis://cache:6379/1".to_string(),
|
||||
"-e".to_string(),
|
||||
format!("DATABASE_URL={}", database_url),
|
||||
"-e".to_string(),
|
||||
"DEFAULT_CHANNEL_SLUG=default-channel".to_string(),
|
||||
"-e".to_string(),
|
||||
"DEFAULT_FROM_EMAIL=noreply@example.com".to_string(),
|
||||
"-e".to_string(),
|
||||
"EMAIL_URL=smtp://mailpit:1025".to_string(),
|
||||
"-e".to_string(),
|
||||
format!("SECRET_KEY={}", secret_key),
|
||||
"-e".to_string(),
|
||||
"OTEL_SERVICE_NAME=saleor".to_string(),
|
||||
"-e".to_string(),
|
||||
"OTEL_TRACES_EXPORTER=otlp".to_string(),
|
||||
"-e".to_string(),
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317".to_string(),
|
||||
"-e".to_string(),
|
||||
"HTTP_IP_FILTER_ALLOW_LOOPBACK_IPS=True".to_string(),
|
||||
"-e".to_string(),
|
||||
"HTTP_IP_FILTER_ENABLED=False".to_string(),
|
||||
"-e".to_string(),
|
||||
format!("DASHBOARD_URL={}", dashboard_url),
|
||||
"-e".to_string(),
|
||||
format!("ALLOWED_HOSTS={}", allowed_hosts),
|
||||
];
|
||||
|
||||
let mut migrate_cmd = tokio::process::Command::new("podman");
|
||||
migrate_cmd.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"-v",
|
||||
"/var/lib/archipelago/saleor:/app/media",
|
||||
]);
|
||||
migrate_cmd.args(&saleor_env);
|
||||
migrate_cmd.args([SALEOR_API_IMAGE, "python3", "manage.py", "migrate"]);
|
||||
run_required_stack_command("saleor", "run migrations", &mut migrate_cmd).await?;
|
||||
|
||||
let mut populate_cmd = tokio::process::Command::new("podman");
|
||||
populate_cmd.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"-v",
|
||||
"/var/lib/archipelago/saleor:/app/media",
|
||||
]);
|
||||
populate_cmd.args(&saleor_env);
|
||||
populate_cmd.args([
|
||||
SALEOR_API_IMAGE,
|
||||
"python3",
|
||||
"manage.py",
|
||||
"populatedb",
|
||||
"--createsuperuser",
|
||||
]);
|
||||
let populate = populate_cmd.output().await;
|
||||
if let Ok(output) = populate {
|
||||
if !output.status.success() {
|
||||
install_log(&format!(
|
||||
"INSTALL WARN: saleor - populate sample data skipped: {}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let mut api_cmd = tokio::process::Command::new("podman");
|
||||
api_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor-api",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--network-alias",
|
||||
"api",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=FOWNER",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=1g",
|
||||
"--pids-limit=4096",
|
||||
"-p",
|
||||
"8000:8000",
|
||||
"-v",
|
||||
"/var/lib/archipelago/saleor:/app/media",
|
||||
]);
|
||||
api_cmd.args(&saleor_env);
|
||||
api_cmd.arg(SALEOR_API_IMAGE);
|
||||
run_required_stack_command("saleor", "create api", &mut api_cmd).await?;
|
||||
|
||||
let mut worker_cmd = tokio::process::Command::new("podman");
|
||||
worker_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor-worker",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=FOWNER",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=1g",
|
||||
"--pids-limit=4096",
|
||||
"-v",
|
||||
"/var/lib/archipelago/saleor:/app/media",
|
||||
]);
|
||||
worker_cmd.args(&saleor_env);
|
||||
worker_cmd.args([
|
||||
SALEOR_API_IMAGE,
|
||||
"celery",
|
||||
"-A",
|
||||
"saleor",
|
||||
"--app=saleor.celeryconf:app",
|
||||
"worker",
|
||||
"--loglevel=info",
|
||||
"-B",
|
||||
]);
|
||||
run_required_stack_command("saleor", "create worker", &mut worker_cmd).await?;
|
||||
|
||||
self.set_install_phase("saleor", InstallPhase::StartingContainer)
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let mut dashboard_cmd = tokio::process::Command::new("podman");
|
||||
dashboard_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=256m",
|
||||
"--pids-limit=2048",
|
||||
"-p",
|
||||
"9000:80",
|
||||
"-e",
|
||||
&format!("API_URL={}", api_url),
|
||||
"-e",
|
||||
&format!("APP_MOUNT_URI={}", dashboard_url),
|
||||
SALEOR_DASHBOARD_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("saleor", "create dashboard", &mut dashboard_cmd).await?;
|
||||
|
||||
wait_for_stack_containers(
|
||||
"saleor",
|
||||
&[
|
||||
"saleor-db",
|
||||
"saleor-cache",
|
||||
"saleor-jaeger",
|
||||
"saleor-mailpit",
|
||||
"saleor-api",
|
||||
"saleor-worker",
|
||||
"saleor",
|
||||
],
|
||||
120,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.set_install_phase("saleor", InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
self.set_install_phase("saleor", InstallPhase::PostInstall)
|
||||
.await;
|
||||
self.set_install_phase("saleor", InstallPhase::Done).await;
|
||||
self.clear_install_progress("saleor").await;
|
||||
|
||||
install_log("INSTALL OK: saleor stack").await;
|
||||
info!("Saleor stack installed");
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": "saleor",
|
||||
"message": "Saleor stack installed (7 containers)",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_or_generate_b64_secret(name: &str) -> String {
|
||||
let path = format!("/var/lib/archipelago/secrets/{}", name);
|
||||
if let Ok(val) = tokio::fs::read_to_string(&path).await {
|
||||
let trimmed = val.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
let mut buf = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf);
|
||||
let secret = base64::engine::general_purpose::STANDARD.encode(buf);
|
||||
let _ = tokio::fs::create_dir_all("/var/lib/archipelago/secrets").await;
|
||||
let _ = tokio::fs::write(&path, &secret).await;
|
||||
secret
|
||||
}
|
||||
|
||||
async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
|
||||
let public_origin = format!("http://{}:8087", host_ip);
|
||||
let server_origin = format!("http://{}:8086", host_ip);
|
||||
let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await;
|
||||
let encryption_key = read_or_generate_b64_secret("netbird-store-encryption-key").await;
|
||||
let config = format!(
|
||||
r#"server:
|
||||
listenAddress: ":80"
|
||||
exposedAddress: "{public_origin}"
|
||||
stunPorts:
|
||||
- 3478
|
||||
metricsPort: 9090
|
||||
healthcheckAddress: ":9000"
|
||||
logLevel: "info"
|
||||
logFile: "console"
|
||||
authSecret: "{relay_secret}"
|
||||
dataDir: "/var/lib/netbird"
|
||||
auth:
|
||||
issuer: "{public_origin}/oauth2"
|
||||
localAuthDisabled: false
|
||||
signKeyRefreshEnabled: true
|
||||
dashboardRedirectURIs:
|
||||
- "{public_origin}/nb-auth"
|
||||
- "{public_origin}/nb-silent-auth"
|
||||
dashboardPostLogoutRedirectURIs:
|
||||
- "{public_origin}/"
|
||||
cliRedirectURIs:
|
||||
- "http://localhost:53000/"
|
||||
store:
|
||||
engine: "sqlite"
|
||||
encryptionKey: "{encryption_key}"
|
||||
"#
|
||||
);
|
||||
tokio::fs::write("/var/lib/archipelago/netbird/config.yaml", config)
|
||||
.await
|
||||
.context("Failed to write NetBird config.yaml")?;
|
||||
|
||||
let dashboard_env = format!(
|
||||
r#"NETBIRD_MGMT_API_ENDPOINT={public_origin}
|
||||
NETBIRD_MGMT_GRPC_API_ENDPOINT={public_origin}
|
||||
AUTH_AUDIENCE=netbird-dashboard
|
||||
AUTH_CLIENT_ID=netbird-dashboard
|
||||
AUTH_CLIENT_SECRET=
|
||||
AUTH_AUTHORITY={public_origin}/oauth2
|
||||
USE_AUTH0=false
|
||||
AUTH_SUPPORTED_SCOPES=openid profile email groups
|
||||
AUTH_REDIRECT_URI=/nb-auth
|
||||
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
||||
NETBIRD_TOKEN_SOURCE=accessToken
|
||||
NGINX_SSL_PORT=443
|
||||
LETSENCRYPT_DOMAIN=none
|
||||
"#
|
||||
);
|
||||
tokio::fs::write("/var/lib/archipelago/netbird/dashboard.env", dashboard_env)
|
||||
.await
|
||||
.context("Failed to write NetBird dashboard.env")?;
|
||||
|
||||
let nginx_conf = format!(
|
||||
r#"server {{
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
location ~ ^/(relay|ws-proxy/) {{
|
||||
proxy_pass http://netbird-server:80;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 1d;
|
||||
}}
|
||||
|
||||
location ~ ^/(api|oauth2)(/|$) {{
|
||||
proxy_pass http://netbird-server:80;
|
||||
}}
|
||||
|
||||
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService)/ {{
|
||||
grpc_pass grpc://netbird-server:80;
|
||||
grpc_read_timeout 1d;
|
||||
grpc_send_timeout 1d;
|
||||
}}
|
||||
|
||||
location / {{
|
||||
proxy_pass http://netbird-dashboard:80;
|
||||
}}
|
||||
}}
|
||||
|
||||
# Direct server remains available for diagnostics at {server_origin}.
|
||||
"#
|
||||
);
|
||||
tokio::fs::write("/var/lib/archipelago/netbird/nginx.conf", nginx_conf)
|
||||
.await
|
||||
.context("Failed to write NetBird nginx.conf")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn detect_netbird_public_host_ip() -> Option<String> {
|
||||
let output = tokio::process::Command::new("hostname")
|
||||
.args(["-I"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout
|
||||
.split_whitespace()
|
||||
.find(|ip| ip.starts_with("100.") && ip.contains('.'))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -233,6 +233,24 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
}
|
||||
|
||||
let configs = runtime_dir.join("image-recipe/configs");
|
||||
let nginx_src = configs.join("nginx-archipelago.conf");
|
||||
if nginx_src.exists() {
|
||||
let src_s = nginx_src.to_string_lossy().to_string();
|
||||
let status = host_sudo(&[
|
||||
"install",
|
||||
"-m",
|
||||
"644",
|
||||
&src_s,
|
||||
"/etc/nginx/sites-available/archipelago",
|
||||
])
|
||||
.await
|
||||
.context("install nginx-archipelago.conf")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("install nginx-archipelago.conf exited with {}", status);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for unit in ["archipelago-doctor.service", "archipelago-doctor.timer"] {
|
||||
let src = configs.join(unit);
|
||||
if src.exists() {
|
||||
@@ -250,6 +268,19 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
|
||||
if changed {
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
if nginx_src.exists() {
|
||||
match host_sudo(&["nginx", "-t"]).await {
|
||||
Ok(status) if status.success() => {
|
||||
let _ = host_sudo(&["systemctl", "reload", "nginx"]).await;
|
||||
}
|
||||
Ok(status) => {
|
||||
tracing::warn!("nginx config test failed after runtime sync: {}", status);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to test nginx config after runtime sync: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,14 @@ impl DockerPackageScanner {
|
||||
"indeedhub-build_minio-init_1",
|
||||
"indeedhub-build_relay_1",
|
||||
"indeedhub-build_ffmpeg-worker_1",
|
||||
"netbird-server",
|
||||
"netbird-dashboard",
|
||||
"saleor-api",
|
||||
"saleor-worker",
|
||||
"saleor-db",
|
||||
"saleor-cache",
|
||||
"saleor-jaeger",
|
||||
"saleor-mailpit",
|
||||
"buildx_buildkit_default",
|
||||
];
|
||||
|
||||
@@ -281,7 +289,8 @@ fn get_app_tier(app_id: &str) -> &'static str {
|
||||
"uptime-kuma" => "recommended",
|
||||
"grafana" => "recommended",
|
||||
"searxng" => "recommended",
|
||||
"tailscale" => "recommended",
|
||||
"saleor" => "recommended",
|
||||
"tailscale" | "netbird" => "recommended",
|
||||
"portainer" => "recommended",
|
||||
// Optional: everything else
|
||||
_ => "optional",
|
||||
@@ -479,6 +488,27 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/tailscale/tailscale".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"netbird" => AppMetadata {
|
||||
title: "NetBird".to_string(),
|
||||
description: "Self-hosted WireGuard mesh VPN control plane and dashboard".to_string(),
|
||||
icon: "/assets/img/app-icons/netbird.svg".to_string(),
|
||||
repo: "https://github.com/netbirdio/netbird".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"saleor" => AppMetadata {
|
||||
title: "Saleor".to_string(),
|
||||
description: "Composable commerce platform with GraphQL API and dashboard".to_string(),
|
||||
icon: "/assets/img/app-icons/saleor.svg".to_string(),
|
||||
repo: "https://github.com/saleor/saleor".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"gitea" => AppMetadata {
|
||||
title: "Gitea".to_string(),
|
||||
description: "Self-hosted Git service with repository and package hosting".to_string(),
|
||||
icon: "/assets/img/app-icons/gitea.svg".to_string(),
|
||||
repo: "https://gitea.com".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"indeedhub" | "indeehub" => AppMetadata {
|
||||
title: "IndeedHub".to_string(),
|
||||
description: "Decentralized media streaming platform".to_string(),
|
||||
@@ -557,10 +587,37 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
tier: "",
|
||||
},
|
||||
};
|
||||
apply_dynamic_metadata(app_id, &mut meta);
|
||||
meta.tier = get_app_tier(app_id);
|
||||
meta
|
||||
}
|
||||
|
||||
fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
|
||||
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
|
||||
let Ok(data) = std::fs::read_to_string(config_path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) else {
|
||||
return;
|
||||
};
|
||||
if let Some(title) = cfg
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty() && s.len() <= 80)
|
||||
{
|
||||
meta.title = title.to_string();
|
||||
}
|
||||
if let Some(description) = cfg
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty() && s.len() <= 240)
|
||||
{
|
||||
meta.description = description.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Map app_id to Tor hidden service directory name.
|
||||
/// "archipelago" is the main web UI (nginx port 80).
|
||||
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
|
||||
@@ -673,6 +730,7 @@ fn requires_reachable_launch(app_id: &str) -> bool {
|
||||
| "tailscale"
|
||||
| "immich"
|
||||
| "searxng"
|
||||
| "saleor"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +168,15 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||
"nginx-proxy-manager" => Some("NPM_IMAGE"),
|
||||
"portainer" => Some("PORTAINER_IMAGE"),
|
||||
"tailscale" => Some("TAILSCALE_IMAGE"),
|
||||
"netbird" => Some("NETBIRD_DASHBOARD_IMAGE"),
|
||||
"netbird-dashboard" => Some("NETBIRD_DASHBOARD_IMAGE"),
|
||||
"netbird-server" => Some("NETBIRD_SERVER_IMAGE"),
|
||||
"saleor" => Some("SALEOR_DASHBOARD_IMAGE"),
|
||||
"saleor-api" | "saleor-worker" => Some("SALEOR_API_IMAGE"),
|
||||
"saleor-db" => Some("SALEOR_POSTGRES_IMAGE"),
|
||||
"saleor-cache" => Some("SALEOR_VALKEY_IMAGE"),
|
||||
"saleor-jaeger" => Some("SALEOR_JAEGER_IMAGE"),
|
||||
"saleor-mailpit" => Some("SALEOR_MAILPIT_IMAGE"),
|
||||
|
||||
// Fedimint
|
||||
"fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"),
|
||||
@@ -299,6 +308,20 @@ pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
|
||||
("penpot-exporter", "PENPOT_EXPORTER_IMAGE"),
|
||||
("penpot-frontend", "PENPOT_FRONTEND_IMAGE"),
|
||||
],
|
||||
"netbird" => vec![
|
||||
("netbird", "NETBIRD_PROXY_IMAGE"),
|
||||
("netbird-dashboard", "NETBIRD_DASHBOARD_IMAGE"),
|
||||
("netbird-server", "NETBIRD_SERVER_IMAGE"),
|
||||
],
|
||||
"saleor" => vec![
|
||||
("saleor-db", "SALEOR_POSTGRES_IMAGE"),
|
||||
("saleor-cache", "SALEOR_VALKEY_IMAGE"),
|
||||
("saleor-api", "SALEOR_API_IMAGE"),
|
||||
("saleor-worker", "SALEOR_API_IMAGE"),
|
||||
("saleor-jaeger", "SALEOR_JAEGER_IMAGE"),
|
||||
("saleor-mailpit", "SALEOR_MAILPIT_IMAGE"),
|
||||
("saleor", "SALEOR_DASHBOARD_IMAGE"),
|
||||
],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,140 @@
|
||||
//! Mesh session lifecycle: connect, initialize, main loop.
|
||||
|
||||
use super::super::meshtastic::MeshtasticDevice;
|
||||
use super::super::serial::MeshcoreDevice;
|
||||
use super::super::types::*;
|
||||
use super::{
|
||||
frames, MeshCommand, MeshState, ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES, SYNC_INTERVAL,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Scan all candidate serial ports and open the first Meshcore device found.
|
||||
async fn auto_detect_and_open() -> Result<(String, MeshcoreDevice, DeviceInfo)> {
|
||||
enum MeshRadioDevice {
|
||||
Meshcore(MeshcoreDevice),
|
||||
Meshtastic(MeshtasticDevice),
|
||||
}
|
||||
|
||||
impl MeshRadioDevice {
|
||||
fn device_type(&self) -> DeviceType {
|
||||
match self {
|
||||
Self::Meshcore(_) => DeviceType::Meshcore,
|
||||
Self::Meshtastic(_) => DeviceType::Meshtastic,
|
||||
}
|
||||
}
|
||||
|
||||
fn advert_name(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.advert_name.clone(),
|
||||
Self::Meshtastic(device) => device.advert_name(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.set_advert_name(name).await,
|
||||
Self::Meshtastic(device) => device.set_advert_name(name).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_self_advert(&mut self) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_self_advert().await,
|
||||
Self::Meshtastic(device) => device.send_self_advert().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_channel_text(&mut self, channel: u8, payload: &[u8]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_channel_text(channel, payload).await,
|
||||
Self::Meshtastic(device) => device.send_channel_text(channel, payload).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.get_contacts().await,
|
||||
Self::Meshtastic(device) => device.get_contacts().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.reset_contact_path(pubkey).await,
|
||||
Self::Meshtastic(device) => device.reset_contact_path(pubkey).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_messages(&mut self) -> Result<Vec<super::super::protocol::InboundFrame>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.sync_messages().await,
|
||||
Self::Meshtastic(device) => device.sync_messages().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_recv_frame(&mut self) -> Result<Option<super::super::protocol::InboundFrame>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.try_recv_frame().await,
|
||||
Self::Meshtastic(device) => device.try_recv_frame().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan all candidate serial ports and open the first supported mesh device found.
|
||||
async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let paths = super::super::serial::detect_serial_devices().await;
|
||||
if paths.is_empty() {
|
||||
anyhow::bail!("No serial devices found in /dev");
|
||||
}
|
||||
for path in &paths {
|
||||
debug!(path = %path, "Probing for Meshcore device");
|
||||
debug!(path = %path, "Probing for mesh radio device");
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
|
||||
return Ok((path.clone(), dev, info));
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
|
||||
}
|
||||
}
|
||||
anyhow::bail!(
|
||||
"No Meshcore device found on {} candidate ports: {:?}",
|
||||
"No supported mesh radio found on {} candidate ports: {:?}",
|
||||
paths.len(),
|
||||
paths
|
||||
)
|
||||
}
|
||||
|
||||
async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)> {
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
|
||||
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
|
||||
Err(e) => Err(e).context("Preferred path is not Meshtastic"),
|
||||
},
|
||||
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII marker for the original DM-via-channel format:
|
||||
/// `@DM:` + base64(`[dest_prefix(6)][inner…]`). No sender info on the wire,
|
||||
/// so the receiver has to guess the sender from its contact table — which
|
||||
@@ -90,7 +188,7 @@ fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
|
||||
/// with both the recipient and sender prefixes so attribution works on
|
||||
/// the receiver side.
|
||||
async fn send_dm_via_channel(
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
payload: &[u8],
|
||||
@@ -166,7 +264,7 @@ async fn send_dm_via_channel(
|
||||
}
|
||||
|
||||
/// Fetch the contacts list from the device and update the peer cache.
|
||||
async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
|
||||
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
|
||||
match device.get_contacts().await {
|
||||
Ok(contacts) => {
|
||||
// Skip firmware contacts the user has explicitly wiped via
|
||||
@@ -239,7 +337,7 @@ async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
|
||||
/// Drain any queued messages from the device.
|
||||
/// Returns `true` if a write/communication error occurred (for failure tracking).
|
||||
async fn sync_queued_messages(
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
) -> bool {
|
||||
@@ -274,20 +372,11 @@ pub(super) async fn run_mesh_session(
|
||||
) -> Result<()> {
|
||||
// Detect device — try preferred path first, fall back to auto-detect
|
||||
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => (path.to_string(), dev, info),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} handshake failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open().await?
|
||||
}
|
||||
},
|
||||
match open_preferred_path(path).await {
|
||||
Ok((dev, info)) => (path.to_string(), dev, info),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} open failed: {} — trying auto-detect",
|
||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open().await?
|
||||
@@ -301,11 +390,11 @@ pub(super) async fn run_mesh_session(
|
||||
{
|
||||
let mut status = state.status.write().await;
|
||||
status.device_connected = true;
|
||||
status.device_type = DeviceType::Meshcore;
|
||||
status.device_type = device.device_type();
|
||||
status.device_path = Some(device_path.clone());
|
||||
status.firmware_version = Some(device_info.firmware_version.clone());
|
||||
status.self_node_id = Some(device_info.node_id);
|
||||
status.self_advert_name = device.advert_name.clone();
|
||||
status.self_advert_name = device.advert_name();
|
||||
}
|
||||
|
||||
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
|
||||
@@ -434,7 +523,7 @@ pub(super) async fn run_mesh_session(
|
||||
/// Process a single outbound command from MeshService.
|
||||
async fn handle_send_command(
|
||||
cmd: MeshCommand,
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
consecutive_write_failures: &mut u32,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
//! Async serial driver for Meshtastic devices.
|
||||
//!
|
||||
//! Meshtastic uses protobuf payloads over a SLIP-like serial stream. This
|
||||
//! module implements only the small subset Archipelago needs: connect,
|
||||
//! discover the local node, send/receive text packets, and provide synthetic
|
||||
//! contacts to the existing mesh listener.
|
||||
|
||||
use super::protocol::{InboundFrame, ParsedContact};
|
||||
use super::types::{DeviceInfo, DeviceType};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const BAUD_RATE: u32 = 115200;
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const READ_BUF_SIZE: usize = 512;
|
||||
|
||||
const START1: u8 = 0x94;
|
||||
const START2: u8 = 0xc3;
|
||||
const TO_RADIO_MAX: usize = 512;
|
||||
const BROADCAST_NUM: u32 = 0xffff_ffff;
|
||||
const TEXT_MESSAGE_APP: u32 = 1;
|
||||
|
||||
const TO_RADIO_PACKET: u64 = 1;
|
||||
const TO_RADIO_WANT_CONFIG_ID: u64 = 3;
|
||||
const TO_RADIO_HEARTBEAT: u64 = 7;
|
||||
|
||||
const FROM_RADIO_PACKET: u64 = 2;
|
||||
const FROM_RADIO_MY_INFO: u64 = 3;
|
||||
const FROM_RADIO_NODE_INFO: u64 = 4;
|
||||
const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7;
|
||||
const FROM_RADIO_REBOOTED: u64 = 8;
|
||||
|
||||
/// Async Meshtastic device handle.
|
||||
pub struct MeshtasticDevice {
|
||||
port: serial2_tokio::SerialPort,
|
||||
read_buf: Vec<u8>,
|
||||
node_num: Option<u32>,
|
||||
user_id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
contacts: HashMap<u32, ParsedContact>,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
impl MeshtasticDevice {
|
||||
pub async fn open(path: &str) -> Result<Self> {
|
||||
match tokio::fs::metadata(path).await {
|
||||
Ok(meta) => {
|
||||
debug!(path = %path, permissions = ?meta.permissions(), "Device node exists")
|
||||
}
|
||||
Err(e) => anyhow::bail!("Serial device {} not accessible: {}", path, e),
|
||||
}
|
||||
|
||||
let port = serial2_tokio::SerialPort::open(path, BAUD_RATE).context(format!(
|
||||
"Failed to open serial port {} (permission denied? device busy?)",
|
||||
path
|
||||
))?;
|
||||
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
read_buf: Vec::with_capacity(READ_BUF_SIZE),
|
||||
node_num: None,
|
||||
user_id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
contacts: HashMap::new(),
|
||||
device_path: path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn initialize(&mut self) -> Result<DeviceInfo> {
|
||||
info!(path = %self.device_path, "Starting Meshtastic handshake");
|
||||
self.send_to_radio(&encode_want_config()).await?;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + READ_TIMEOUT;
|
||||
let mut saw_meshtastic_frame = false;
|
||||
let mut saw_config_complete = false;
|
||||
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(
|
||||
remaining.min(Duration::from_millis(250)),
|
||||
self.read_from_radio(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(frame))) => {
|
||||
saw_meshtastic_frame = true;
|
||||
if matches!(
|
||||
decode_top_level_variant(&frame),
|
||||
Some((FROM_RADIO_CONFIG_COMPLETE_ID, _))
|
||||
) {
|
||||
saw_config_complete = true;
|
||||
}
|
||||
self.handle_from_radio(&frame);
|
||||
if saw_config_complete && self.node_num.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Ok(None)) | Err(_) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_meshtastic_frame {
|
||||
anyhow::bail!("No Meshtastic serial API response");
|
||||
}
|
||||
|
||||
let node_id = self
|
||||
.node_num
|
||||
.ok_or_else(|| anyhow::anyhow!("Meshtastic serial API did not provide MyInfo"))?;
|
||||
if self.user_id.is_none() && self.long_name.is_none() && self.short_name.is_none() {
|
||||
anyhow::bail!("Meshtastic serial API did not provide node identity");
|
||||
}
|
||||
let firmware_version = self
|
||||
.long_name
|
||||
.clone()
|
||||
.or_else(|| self.user_id.clone())
|
||||
.unwrap_or_else(|| "Meshtastic".to_string());
|
||||
|
||||
info!(node_id, name = %firmware_version, "Meshtastic identity");
|
||||
Ok(DeviceInfo {
|
||||
firmware_version,
|
||||
node_id,
|
||||
max_contacts: 200,
|
||||
device_type: DeviceType::Meshtastic,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
self.long_name = Some(name.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_self_advert(&mut self) -> Result<()> {
|
||||
self.send_to_radio(&encode_heartbeat()).await
|
||||
}
|
||||
|
||||
pub async fn send_channel_text(&mut self, _channel: u8, msg: &[u8]) -> Result<()> {
|
||||
let text = String::from_utf8_lossy(msg);
|
||||
let packet = encode_mesh_packet(BROADCAST_NUM, TEXT_MESSAGE_APP, text.as_bytes());
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
|
||||
if self.contacts.is_empty() {
|
||||
self.send_to_radio(&encode_want_config()).await?;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match self.read_from_radio().await? {
|
||||
Some(frame) => {
|
||||
let config_complete = matches!(
|
||||
decode_top_level_variant(&frame),
|
||||
Some((FROM_RADIO_CONFIG_COMPLETE_ID, _))
|
||||
);
|
||||
self.handle_from_radio(&frame);
|
||||
if config_complete || !self.contacts.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => tokio::time::sleep(Duration::from_millis(50)).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(self.contacts.values().cloned().collect())
|
||||
}
|
||||
|
||||
pub async fn reset_contact_path(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_messages(&mut self) -> Result<Vec<InboundFrame>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
|
||||
let Some(frame) = self.read_from_radio().await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.handle_from_radio(&frame))
|
||||
}
|
||||
|
||||
pub fn advert_name(&self) -> Option<String> {
|
||||
self.long_name
|
||||
.clone()
|
||||
.or_else(|| self.short_name.clone())
|
||||
.or_else(|| self.user_id.clone())
|
||||
}
|
||||
|
||||
async fn send_to_radio(&mut self, payload: &[u8]) -> Result<()> {
|
||||
if payload.len() > TO_RADIO_MAX {
|
||||
anyhow::bail!("Meshtastic payload too large: {} bytes", payload.len());
|
||||
}
|
||||
let mut frame = Vec::with_capacity(4 + payload.len());
|
||||
frame.push(START1);
|
||||
frame.push(START2);
|
||||
frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
|
||||
frame.extend_from_slice(payload);
|
||||
tokio::time::timeout(WRITE_TIMEOUT, self.port.write_all(&frame))
|
||||
.await
|
||||
.context("Meshtastic serial write timed out")?
|
||||
.context("Meshtastic serial write failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_from_radio(&mut self) -> Result<Option<Vec<u8>>> {
|
||||
if let Some(frame) = decode_serial_frame(&mut self.read_buf) {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
|
||||
let mut tmp = [0u8; READ_BUF_SIZE];
|
||||
match tokio::time::timeout(Duration::from_millis(50), self.port.read(&mut tmp)).await {
|
||||
Ok(Ok(0)) => anyhow::bail!("Meshtastic serial port closed"),
|
||||
Ok(Ok(n)) => self.read_buf.extend_from_slice(&tmp[..n]),
|
||||
Ok(Err(e)) => return Err(e).context("Meshtastic serial read error"),
|
||||
Err(_) => return Ok(None),
|
||||
}
|
||||
|
||||
Ok(decode_serial_frame(&mut self.read_buf))
|
||||
}
|
||||
|
||||
fn handle_from_radio(&mut self, frame: &[u8]) -> Option<InboundFrame> {
|
||||
let Some((field, value)) = decode_top_level_variant(frame) else {
|
||||
return None;
|
||||
};
|
||||
match field {
|
||||
FROM_RADIO_MY_INFO => {
|
||||
if let Some((node_num, user_id)) = parse_my_info(value) {
|
||||
self.node_num = Some(node_num);
|
||||
if let Some(user_id) = user_id {
|
||||
self.user_id = Some(user_id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
FROM_RADIO_NODE_INFO => {
|
||||
self.update_node_info(value);
|
||||
None
|
||||
}
|
||||
FROM_RADIO_PACKET => self.packet_to_inbound_frame(value),
|
||||
FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None,
|
||||
other => {
|
||||
debug!(
|
||||
field = other,
|
||||
len = value.len(),
|
||||
"Unhandled Meshtastic FromRadio field"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_node_info(&mut self, data: &[u8]) {
|
||||
if let Some(node) = parse_node_info(data) {
|
||||
let key = synthetic_pubkey(node.num);
|
||||
let name = node
|
||||
.long_name
|
||||
.or(node.short_name)
|
||||
.or(node.id)
|
||||
.unwrap_or_else(|| format!("Meshtastic !{:08x}", node.num));
|
||||
if Some(node.num) == self.node_num {
|
||||
self.long_name = Some(name.clone());
|
||||
}
|
||||
self.contacts.insert(
|
||||
node.num,
|
||||
ParsedContact {
|
||||
public_key_hex: hex::encode(key),
|
||||
advert_name: name,
|
||||
last_advert: node.last_heard.unwrap_or_default(),
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn packet_to_inbound_frame(&mut self, data: &[u8]) -> Option<InboundFrame> {
|
||||
let packet = parse_mesh_packet(data)?;
|
||||
if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let from = packet.from.unwrap_or(0);
|
||||
if Some(from) == self.node_num {
|
||||
return None;
|
||||
}
|
||||
let from_key = synthetic_pubkey(from);
|
||||
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||
advert_name: format!("Meshtastic !{:08x}", from),
|
||||
last_advert: 0,
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
});
|
||||
|
||||
let mut payload = Vec::with_capacity(15 + packet.payload.len());
|
||||
payload.push(0); // SNR unknown
|
||||
payload.extend_from_slice(&[0, 0]); // reserved
|
||||
payload.extend_from_slice(&from_key[..6]);
|
||||
payload.push(0xff); // unknown/flood path
|
||||
payload.push(0); // text type
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
payload.extend_from_slice(&packet.payload);
|
||||
Some(InboundFrame {
|
||||
code: super::protocol::RESP_CONTACT_MSG_V3,
|
||||
data: payload,
|
||||
bytes_consumed: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_serial_frame(buf: &mut Vec<u8>) -> Option<Vec<u8>> {
|
||||
let start = buf.windows(2).position(|w| w == [START1, START2])?;
|
||||
if start > 0 {
|
||||
buf.drain(..start);
|
||||
}
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
|
||||
if buf.len() < 4 + len {
|
||||
return None;
|
||||
}
|
||||
let payload = buf[4..4 + len].to_vec();
|
||||
buf.drain(..4 + len);
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
fn encode_want_config() -> Vec<u8> {
|
||||
encode_varint_field(TO_RADIO_WANT_CONFIG_ID, 1)
|
||||
}
|
||||
|
||||
fn encode_heartbeat() -> Vec<u8> {
|
||||
encode_to_radio_variant(TO_RADIO_HEARTBEAT, &[])
|
||||
}
|
||||
|
||||
fn encode_to_radio_variant(field: u64, bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_len_field(field, bytes, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_mesh_packet(to: u32, portnum: u32, payload: &[u8]) -> Vec<u8> {
|
||||
let mut decoded = Vec::new();
|
||||
encode_varint_field_into(1, portnum as u64, &mut decoded);
|
||||
encode_len_field(2, payload, &mut decoded);
|
||||
|
||||
let mut packet = Vec::new();
|
||||
encode_fixed32_field(2, to, &mut packet);
|
||||
encode_len_field(4, &decoded, &mut packet);
|
||||
packet
|
||||
}
|
||||
|
||||
fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> {
|
||||
let mut idx = 0;
|
||||
while idx < buf.len() {
|
||||
let (key, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
let field = key >> 3;
|
||||
match key & 0x07 {
|
||||
0 => {
|
||||
let (_, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
if matches!(field, FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED) {
|
||||
return Some((field, &[]));
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
let (len, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
let end = idx.checked_add(len as usize)?;
|
||||
if end > buf.len() {
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
field,
|
||||
FROM_RADIO_PACKET | FROM_RADIO_MY_INFO | FROM_RADIO_NODE_INFO
|
||||
) {
|
||||
return Some((field, &buf[idx..end]));
|
||||
}
|
||||
idx = end;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_my_info(data: &[u8]) -> Option<(u32, Option<String>)> {
|
||||
let mut idx = 0;
|
||||
let mut node_num = None;
|
||||
let mut user_id = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => node_num = Some(v as u32),
|
||||
(1, FieldValue::Fixed32(v)) => node_num = Some(v),
|
||||
(3, FieldValue::Bytes(b)) => user_id = parse_user(b).and_then(|u| u.id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
node_num.map(|n| (n, user_id))
|
||||
}
|
||||
|
||||
struct ParsedNode {
|
||||
num: u32,
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
last_heard: Option<u32>,
|
||||
}
|
||||
|
||||
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
let mut idx = 0;
|
||||
let mut node = ParsedNode {
|
||||
num: 0,
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
last_heard: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => node.num = v as u32,
|
||||
(1, FieldValue::Fixed32(v)) => node.num = v,
|
||||
(2, FieldValue::Bytes(b)) => {
|
||||
if let Some(user) = parse_user(b) {
|
||||
node.id = user.id;
|
||||
node.long_name = user.long_name;
|
||||
node.short_name = user.short_name;
|
||||
}
|
||||
}
|
||||
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if node.num == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(node)
|
||||
}
|
||||
}
|
||||
|
||||
struct ParsedUser {
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
let mut idx = 0;
|
||||
let mut user = ParsedUser {
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
|
||||
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
|
||||
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(user)
|
||||
}
|
||||
|
||||
struct ParsedPacket {
|
||||
from: Option<u32>,
|
||||
portnum: u32,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
let mut idx = 0;
|
||||
let mut from = None;
|
||||
let mut decoded = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
||||
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let decoded = decoded?;
|
||||
let mut didx = 0;
|
||||
let mut portnum = 0;
|
||||
let mut payload = Vec::new();
|
||||
while didx < decoded.len() {
|
||||
let (field, value, next) = next_field(decoded, didx)?;
|
||||
didx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => portnum = v as u32,
|
||||
(2, FieldValue::Bytes(b)) => payload = b.to_vec(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(ParsedPacket {
|
||||
from,
|
||||
portnum,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
enum FieldValue<'a> {
|
||||
Varint(u64),
|
||||
Fixed32(u32),
|
||||
Bytes(&'a [u8]),
|
||||
}
|
||||
|
||||
fn next_field(buf: &[u8], idx: usize) -> Option<(u64, FieldValue<'_>, usize)> {
|
||||
let (key, n) = read_varint(&buf[idx..])?;
|
||||
let field = key >> 3;
|
||||
let mut pos = idx + n;
|
||||
match key & 0x07 {
|
||||
0 => {
|
||||
let (v, n) = read_varint(&buf[pos..])?;
|
||||
pos += n;
|
||||
Some((field, FieldValue::Varint(v), pos))
|
||||
}
|
||||
2 => {
|
||||
let (len, n) = read_varint(&buf[pos..])?;
|
||||
pos += n;
|
||||
let end = pos.checked_add(len as usize)?;
|
||||
if end > buf.len() {
|
||||
return None;
|
||||
}
|
||||
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
|
||||
}
|
||||
5 => {
|
||||
let end = pos.checked_add(4)?;
|
||||
if end > buf.len() {
|
||||
None
|
||||
} else {
|
||||
let value =
|
||||
u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
|
||||
Some((field, FieldValue::Fixed32(value), end))
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let end = pos.checked_add(8)?;
|
||||
if end > buf.len() {
|
||||
None
|
||||
} else {
|
||||
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
|
||||
}
|
||||
}
|
||||
wire => {
|
||||
warn!(wire, "Unsupported Meshtastic protobuf wire type");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_varint(buf: &[u8]) -> Option<(u64, usize)> {
|
||||
let mut out = 0u64;
|
||||
for (i, b) in buf.iter().copied().enumerate().take(10) {
|
||||
out |= ((b & 0x7f) as u64) << (7 * i);
|
||||
if b & 0x80 == 0 {
|
||||
return Some((out, i + 1));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn encode_varint_field(field: u64, value: u64) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_varint_field_into(field, value, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_varint_field_into(field: u64, value: u64, out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 0, out);
|
||||
write_varint(value, out);
|
||||
}
|
||||
|
||||
fn encode_len_field(field: u64, bytes: &[u8], out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 2, out);
|
||||
write_varint(bytes.len() as u64, out);
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn encode_fixed32_field(field: u64, value: u32, out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 5, out);
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_varint(mut value: u64, out: &mut Vec<u8>) {
|
||||
while value >= 0x80 {
|
||||
out.push((value as u8 & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
out.push(value as u8);
|
||||
}
|
||||
|
||||
fn string_field(bytes: &[u8]) -> Option<String> {
|
||||
std::str::from_utf8(bytes).ok().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn synthetic_pubkey(node_num: u32) -> [u8; 32] {
|
||||
let mut out = [0u8; 32];
|
||||
out[..4].copy_from_slice(&node_num.to_le_bytes());
|
||||
out[4..15].copy_from_slice(b"meshtastic:");
|
||||
out
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
//! Mesh networking: Meshcore LoRa radio integration for offline peer discovery
|
||||
//! Mesh networking: LoRa radio integration for offline peer discovery
|
||||
//! and encrypted messaging between Archipelago nodes.
|
||||
//!
|
||||
//! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2,
|
||||
//! and other ESP32/nRF52-based LoRa boards via USB serial (Companion USB mode).
|
||||
//! Supports Meshcore firmware via Companion USB and Meshtastic firmware via
|
||||
//! the Meshtastic serial API on Heltec, T-Beam, RAK WisBlock, Station G2,
|
||||
//! and other ESP32/nRF52-based LoRa boards.
|
||||
|
||||
pub mod alerts;
|
||||
pub mod bitcoin_relay;
|
||||
pub mod crypto;
|
||||
pub mod listener;
|
||||
pub mod meshtastic;
|
||||
pub mod message_types;
|
||||
pub mod outbox;
|
||||
pub mod protocol;
|
||||
|
||||
@@ -323,6 +323,7 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
||||
}
|
||||
|
||||
/// Parsed contact from RESP_CONTACT (0x03).
|
||||
#[derive(Clone)]
|
||||
pub struct ParsedContact {
|
||||
pub public_key_hex: String,
|
||||
pub advert_name: String,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use super::protocol::{self, InboundFrame};
|
||||
use super::types::DeviceInfo;
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
@@ -400,12 +401,43 @@ const SERIAL_CANDIDATES: &[&str] = &[
|
||||
"/dev/ttyACM2",
|
||||
];
|
||||
|
||||
const SKIP_SERIAL_MODEL_SUBSTRINGS: &[&str] = &["Sierra_Wireless", "Z-Wave", "Zooz"];
|
||||
|
||||
fn likely_non_mesh_serial_device(path: &str) -> bool {
|
||||
let Some(name) = Path::new(path).file_name().and_then(|s| s.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let by_id = Path::new("/dev/serial/by-id");
|
||||
let Ok(entries) = std::fs::read_dir(by_id) else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
if !SKIP_SERIAL_MODEL_SUBSTRINGS
|
||||
.iter()
|
||||
.any(|needle| file_name.contains(needle))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(target) = std::fs::read_link(entry.path()) {
|
||||
if target.file_name().and_then(|s| s.to_str()) == Some(name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Scan for serial devices that could be Meshcore radios.
|
||||
/// Returns paths to existing serial device files.
|
||||
pub async fn detect_serial_devices() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
for path in SERIAL_CANDIDATES {
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
if likely_non_mesh_serial_device(path) {
|
||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||
continue;
|
||||
}
|
||||
devices.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,10 @@ impl EndpointRateLimiter {
|
||||
limits.insert("backup.upload-s3".to_string(), (3, 600));
|
||||
limits.insert("backup.download-s3".to_string(), (3, 600));
|
||||
// System operations
|
||||
limits.insert("update.apply".to_string(), (2, 600));
|
||||
// Update apply is an authenticated local admin action. Keep a guard
|
||||
// against accidental button storms without locking operators out for
|
||||
// ten minutes during OTA troubleshooting.
|
||||
limits.insert("update.apply".to_string(), (10, 60));
|
||||
limits.insert("system.reboot".to_string(), (2, 300));
|
||||
limits.insert("system.shutdown".to_string(), (2, 300));
|
||||
// Password and TOTP changes
|
||||
|
||||
@@ -840,6 +840,20 @@ const CONTAINER_ABSENCE_THRESHOLD: u32 = 3;
|
||||
/// scanner's authoritative view. Applies to all transitional variants.
|
||||
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Multi-container installs can legitimately spend several minutes before the
|
||||
/// primary user-facing container exists. BTCPay, for example, pulls/starts
|
||||
/// Postgres and NBXplorer before `btcpay-server`; do not erase its installing
|
||||
/// card just because the primary container is absent during that setup window.
|
||||
const INSTALLING_STUCK_TIMEOUT: Duration = Duration::from_secs(20 * 60);
|
||||
|
||||
fn transitional_stuck_timeout(state: &crate::data_model::PackageState) -> Duration {
|
||||
if *state == crate::data_model::PackageState::Installing {
|
||||
INSTALLING_STUCK_TIMEOUT
|
||||
} else {
|
||||
TRANSITIONAL_STUCK_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `state` is one of the transitional variants that a
|
||||
/// `spawn_transitional`-style background task owns. While such a state is
|
||||
/// set, the package scanner must not overwrite it with whatever podman
|
||||
@@ -961,13 +975,14 @@ async fn scan_and_update_packages(
|
||||
let overwrite = match existing {
|
||||
Some(existing_entry) if is_transitional(&existing_entry.state) => {
|
||||
let entered = *transitional_since.entry(id.clone()).or_insert(now);
|
||||
let stuck = now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT;
|
||||
let timeout = transitional_stuck_timeout(&existing_entry.state);
|
||||
let stuck = now.duration_since(entered) > timeout;
|
||||
if stuck {
|
||||
warn!(
|
||||
"Container {} stuck in {:?} for >{}s; overriding with scan state {:?}",
|
||||
id,
|
||||
existing_entry.state,
|
||||
TRANSITIONAL_STUCK_TIMEOUT.as_secs(),
|
||||
timeout.as_secs(),
|
||||
pkg.state
|
||||
);
|
||||
transitional_since.remove(id);
|
||||
@@ -1015,12 +1030,13 @@ async fn scan_and_update_packages(
|
||||
if let Some(entry) = merged.get(&id) {
|
||||
if is_transitional(&entry.state) {
|
||||
let entered = *transitional_since.entry(id.clone()).or_insert(now);
|
||||
if now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT {
|
||||
let timeout = transitional_stuck_timeout(&entry.state);
|
||||
if now.duration_since(entered) > timeout {
|
||||
warn!(
|
||||
"Container {} stuck in {:?} and absent for >{}s; removing stale transitional state",
|
||||
id,
|
||||
entry.state,
|
||||
TRANSITIONAL_STUCK_TIMEOUT.as_secs()
|
||||
timeout.as_secs()
|
||||
);
|
||||
merged.remove(&id);
|
||||
transitional_since.remove(&id);
|
||||
@@ -1247,4 +1263,13 @@ mod merge_tests {
|
||||
assert!(!is_transitional(&s), "{:?} should NOT be transitional", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installing_uses_longer_stale_timeout_than_other_transitions() {
|
||||
assert!(transitional_stuck_timeout(&PackageState::Installing) > TRANSITIONAL_STUCK_TIMEOUT);
|
||||
assert_eq!(
|
||||
transitional_stuck_timeout(&PackageState::Stopping),
|
||||
TRANSITIONAL_STUCK_TIMEOUT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Release Notes Backlog
|
||||
|
||||
## Next Release Required Work
|
||||
|
||||
- Backfill missing or thin historical release notes before cutting the next release.
|
||||
- Audit every `CHANGELOG.md` section from `v1.7.44-alpha` through the current release.
|
||||
- Replace raw commit-hash entries with user/operator-facing bullets that explain behavior changes, operational impact, validation, and known limitations.
|
||||
- Ensure `releases/manifest.json` changelog entries come from curated `CHANGELOG.md` notes only.
|
||||
|
||||
## Release Note Policy
|
||||
|
||||
- Every release must have at least three curated bullets.
|
||||
- Raw `git log --oneline` output is not acceptable release documentation.
|
||||
- Notes should answer what changed, why it matters, what operators should expect, and any known limitations.
|
||||
- `scripts/check-release-manifest.sh` is the enforcement gate before publishing artifacts.
|
||||
@@ -1169,7 +1169,7 @@
|
||||
<div class="card-header"><span class="card-name">btcpay-server</span><span class="card-badge badge-red">archy-net</span></div>
|
||||
<div class="card-desc">Self-hosted Bitcoin payment processor. Accept Bitcoin payments with invoices, checkout pages, and POS.</div>
|
||||
<div class="card-layman">Your own payment terminal for Bitcoin. Create invoices, get paid, no middleman taking a cut.</div>
|
||||
<div class="card-image">btcpayserver:1.13.7</div>
|
||||
<div class="card-image">btcpayserver:2.3.9</div>
|
||||
<div class="card-ports">Ports: <span>23000</span></div>
|
||||
<div class="card-details">
|
||||
<div class="detail-row"><span class="detail-label">Memory</span><span class="detail-value">1 GB</span></div>
|
||||
|
||||
@@ -337,6 +337,7 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
||||
curl \
|
||||
git \
|
||||
vim-tiny \
|
||||
nano \
|
||||
ca-certificates \
|
||||
openssl \
|
||||
chrony \
|
||||
|
||||
@@ -179,6 +179,7 @@ chroot /mnt/archipelago apt-get install -y \
|
||||
wget \
|
||||
htop \
|
||||
vim-tiny \
|
||||
nano \
|
||||
ca-certificates \
|
||||
chrony
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Stable symlink for USB serial adapters used as mesh radios.
|
||||
# Creates /dev/mesh-radio pointing to the underlying ttyUSB device.
|
||||
# Supports: CP2102 (Heltec V3), CH340 (T-Beam), FTDI (RAK WisBlock).
|
||||
# Supports MeshCore and Meshtastic radios using CP2102 (Heltec V3),
|
||||
# CH340 (T-Beam), FTDI (RAK WisBlock), and known USB CDC ACM radios.
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="239a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="2e8a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
|
||||
@@ -8,7 +8,7 @@ resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
|
||||
resolver_timeout 5s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
root /opt/archipelago/web-ui;
|
||||
@@ -23,6 +23,13 @@ server {
|
||||
add_header X-DNS-Prefetch-Control "off" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
|
||||
|
||||
# Serve Nginx Proxy Manager HTTP-01 challenge files before the SPA fallback.
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
default_type text/plain;
|
||||
root /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# AIUI SPA (Chat mode iframe) — SPA fallback for client-side routing
|
||||
location /aiui/ {
|
||||
try_files $uri $uri/ /aiui/index.html;
|
||||
@@ -105,9 +112,25 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Versioned Vite assets must never fall through to index.html. During OTA
|
||||
# a browser can keep an old HTML shell that references now-removed hashed
|
||||
# chunks; returning HTML for /assets/*.js triggers strict MIME failures.
|
||||
# A real 404 plus immutable/no-cache split lets the app/browser recover on
|
||||
# refresh without caching the wrong content type.
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
}
|
||||
|
||||
location ~* ^/(registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
}
|
||||
|
||||
# Serve static files (Vue.js SPA)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# Peer-to-peer node messaging (receives from other nodes over Tor)
|
||||
@@ -892,7 +915,7 @@ server {
|
||||
|
||||
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen 443 ssl default_server;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/archipelago/ssl/archipelago.crt;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.57-alpha",
|
||||
"version": "1.7.75-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.57-alpha",
|
||||
"version": "1.7.75-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.57-alpha",
|
||||
"version": "1.7.75-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="NetBird">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="18" y1="14" x2="110" y2="116" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#24c8ff"/>
|
||||
<stop offset="1" stop-color="#3157ff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="28" fill="#071422"/>
|
||||
<path d="M28 72c16-30 39-46 72-50-11 13-18 26-21 40 10-1 19 1 29 5-19 4-35 13-48 27-8 8-18 12-30 12 7-7 12-14 15-22-7 0-13-4-17-12Z" fill="url(#g)"/>
|
||||
<circle cx="82" cy="43" r="6" fill="#fff" opacity=".95"/>
|
||||
<path d="M36 72c10 3 20 4 30 2" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" opacity=".8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 702 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Saleor">
|
||||
<rect width="128" height="128" rx="30" fill="#111827"/>
|
||||
<path d="M34 42c0-10 9-18 22-18h38v16H56c-5 0-8 2-8 5 0 4 4 5 13 7l15 3c15 3 24 11 24 24 0 15-12 25-31 25H31V88h39c8 0 13-3 13-8 0-4-4-6-12-8l-16-3C41 66 34 57 34 42Z" fill="#fff"/>
|
||||
<path d="M29 103h70v8H29z" fill="#7C3AED"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
@@ -52,18 +52,35 @@
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "1.13.7",
|
||||
"version": "2.3.9",
|
||||
"description": "Self-hosted Bitcoin payment processor.",
|
||||
"icon": "/assets/img/app-icons/btcpay-server.png",
|
||||
"author": "BTCPay Server Foundation",
|
||||
"category": "commerce",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7",
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "saleor",
|
||||
"title": "Saleor",
|
||||
"version": "3.23",
|
||||
"description": "Composable commerce platform with GraphQL API, dashboard, worker, mail testing, and tracing.",
|
||||
"icon": "/assets/img/app-icons/saleor.svg",
|
||||
"author": "Saleor",
|
||||
"category": "commerce",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "ghcr.io/saleor/saleor:3.23",
|
||||
"repoUrl": "https://github.com/saleor/saleor",
|
||||
"containerConfig": {
|
||||
"ports": ["9000:80", "8000:8000", "8025:8025", "16686:16686"],
|
||||
"volumes": ["/var/lib/archipelago/saleor:/app/media", "/var/lib/archipelago/saleor-db:/var/lib/postgresql/data"],
|
||||
"notes": "Installed as a Saleor stack: dashboard on 9000, API on 8000, Mailpit on 8025, and Jaeger on 16686. Supporting containers include PostgreSQL, Valkey, Celery worker, and services required by Saleor."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
@@ -275,6 +292,23 @@
|
||||
"args": ["sh", "-c", "tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "netbird",
|
||||
"title": "NetBird",
|
||||
"version": "0.71.2",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/netbirdio/dashboard:v2.38.0",
|
||||
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||
"containerConfig": {
|
||||
"ports": ["8087:80", "8086:80", "3478:3478/udp"],
|
||||
"volumes": ["/var/lib/archipelago/netbird:/var/lib/netbird"],
|
||||
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "uptime-kuma",
|
||||
"title": "Uptime Kuma",
|
||||
|
||||
@@ -532,7 +532,7 @@ class RPCClient {
|
||||
return this.call({
|
||||
method: 'package.install',
|
||||
params: { id, 'marketplace-url': marketplaceUrl, version },
|
||||
timeout: 15000,
|
||||
timeout: 600000,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -940,4 +940,3 @@ class RPCClient {
|
||||
}
|
||||
|
||||
export const rpcClient = new RPCClient()
|
||||
|
||||
|
||||
@@ -112,6 +112,22 @@ describe('useAppLauncherStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('routes desktop new-tab apps into app session on mobile', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' } })
|
||||
})
|
||||
|
||||
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
@@ -219,6 +235,35 @@ describe('useAppLauncherStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('opens known prepackaged websites in new tab on desktop when requested', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'https://nwnn.l484.com', title: 'Next Web News Network', openInNewTab: true })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'https://nwnn.l484.com',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes prepackaged websites into app session on mobile', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'https://present.l484.com', title: 'Arch Presentation', openInNewTab: true })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' } })
|
||||
})
|
||||
|
||||
it('routes HTTPS same-host apps via session view', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
|
||||
|
||||
@@ -31,6 +31,10 @@ function mustOpenInNewTab(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isMobileViewport(): boolean {
|
||||
return typeof window !== 'undefined' && window.innerWidth < 768
|
||||
}
|
||||
|
||||
function inferAppIdFromTitle(title?: string): string | null {
|
||||
const t = (title || '').toLowerCase()
|
||||
if (!t) return null
|
||||
@@ -89,6 +93,8 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'8334': 'bitcoin-knots',
|
||||
'8888': 'searxng',
|
||||
'9000': 'portainer',
|
||||
'8087': 'netbird',
|
||||
'8086': 'netbird',
|
||||
'9980': 'onlyoffice',
|
||||
'11434': 'ollama',
|
||||
'2283': 'immich',
|
||||
@@ -147,14 +153,20 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const panelAppId = ref<string | null>(null)
|
||||
|
||||
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
|
||||
function dashboardReturnPath(): string {
|
||||
const current = router.currentRoute.value
|
||||
const fullPath = current.fullPath || '/dashboard/apps'
|
||||
if (!fullPath.startsWith('/dashboard') || current.name === 'app-session') return '/dashboard/apps'
|
||||
return fullPath
|
||||
}
|
||||
|
||||
function openSession(appId: string) {
|
||||
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
if (mode === 'panel' && !isMobile) {
|
||||
if (mode === 'panel' && !isMobileViewport()) {
|
||||
panelAppId.value = appId
|
||||
} else {
|
||||
panelAppId.value = null
|
||||
router.push({ name: 'app-session', params: { appId } })
|
||||
router.push({ name: 'app-session', params: { appId }, query: { returnTo: dashboardReturnPath() } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,8 +180,15 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||
|
||||
// Force selected apps to open directly in new tab
|
||||
if (resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
||||
if (!isMobileViewport() && payload.openInNewTab) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// Force selected apps to open directly in new tab on desktop only. On
|
||||
// phones, route through the app session/webview so app icons behave like
|
||||
// native launchers and keep the user inside Archipelago.
|
||||
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
@@ -181,7 +200,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
}
|
||||
|
||||
// Unknown apps that block iframes — open directly in new tab
|
||||
if (payload.openInNewTab || mustOpenInNewTab(launchUrl)) {
|
||||
if (!isMobileViewport() && mustOpenInNewTab(launchUrl)) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
interface SystemStatsSnapshot {
|
||||
cpuPercent: number
|
||||
memUsed: number
|
||||
memTotal: number
|
||||
memPercent: number
|
||||
diskUsed: number
|
||||
diskTotal: number
|
||||
diskPercent: number
|
||||
uptimeSecs: number
|
||||
loadAvg1: number
|
||||
loadAvg5: number
|
||||
loadAvg15: number
|
||||
bitcoinSyncPercent: number
|
||||
bitcoinBlockHeight: number
|
||||
bitcoinAvailable: boolean | null
|
||||
}
|
||||
|
||||
const emptyStats = (): SystemStatsSnapshot => ({
|
||||
cpuPercent: 0,
|
||||
memUsed: 0,
|
||||
memTotal: 0,
|
||||
memPercent: 0,
|
||||
diskUsed: 0,
|
||||
diskTotal: 0,
|
||||
diskPercent: 0,
|
||||
uptimeSecs: 0,
|
||||
loadAvg1: 0,
|
||||
loadAvg5: 0,
|
||||
loadAvg15: 0,
|
||||
bitcoinSyncPercent: 0,
|
||||
bitcoinBlockHeight: 0,
|
||||
bitcoinAvailable: null,
|
||||
})
|
||||
|
||||
export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
const stats = reactive<SystemStatsSnapshot>(emptyStats())
|
||||
const systemLoadState = ref<LoadState>('idle')
|
||||
const bitcoinLoadState = ref<LoadState>('idle')
|
||||
const vpnLoadState = ref<LoadState>('idle')
|
||||
const fipsLoadState = ref<LoadState>('idle')
|
||||
const lastSystemRefreshAt = ref<number | null>(null)
|
||||
const lastBitcoinRefreshAt = ref<number | null>(null)
|
||||
const lastVpnRefreshAt = ref<number | null>(null)
|
||||
const lastFipsRefreshAt = ref<number | null>(null)
|
||||
|
||||
const vpnStatus = ref<{
|
||||
connected: boolean | null
|
||||
provider: string
|
||||
}>({ connected: null, provider: '' })
|
||||
|
||||
const fipsStatus = ref<{
|
||||
installed: boolean
|
||||
service_active: boolean
|
||||
key_present: boolean
|
||||
anchor_connected?: boolean
|
||||
authenticated_peer_count?: number
|
||||
} | null>(null)
|
||||
|
||||
const systemStatsLoaded = computed(() => systemLoadState.value === 'ready')
|
||||
const bitcoinKnown = computed(() => stats.bitcoinAvailable !== null)
|
||||
const vpnKnown = computed(() => vpnStatus.value.connected !== null)
|
||||
|
||||
async function refreshSystemStats() {
|
||||
systemLoadState.value = systemLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
cpu_usage_percent: number
|
||||
mem_used_bytes: number
|
||||
mem_total_bytes: number
|
||||
disk_used_bytes: number
|
||||
disk_total_bytes: number
|
||||
uptime_secs: number
|
||||
load_avg_1?: number
|
||||
load_avg_5?: number
|
||||
load_avg_15?: number
|
||||
}>({ method: 'system.stats' })
|
||||
stats.cpuPercent = res.cpu_usage_percent
|
||||
stats.memUsed = res.mem_used_bytes
|
||||
stats.memTotal = res.mem_total_bytes
|
||||
stats.memPercent = res.mem_total_bytes > 0 ? (res.mem_used_bytes / res.mem_total_bytes) * 100 : 0
|
||||
stats.diskUsed = res.disk_used_bytes
|
||||
stats.diskTotal = res.disk_total_bytes
|
||||
stats.diskPercent = res.disk_total_bytes > 0 ? (res.disk_used_bytes / res.disk_total_bytes) * 100 : 0
|
||||
stats.uptimeSecs = res.uptime_secs
|
||||
stats.loadAvg1 = res.load_avg_1 ?? 0
|
||||
stats.loadAvg5 = res.load_avg_5 ?? 0
|
||||
stats.loadAvg15 = res.load_avg_15 ?? 0
|
||||
systemLoadState.value = 'ready'
|
||||
lastSystemRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
systemLoadState.value = stats.uptimeSecs > 0 ? 'ready' : 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBitcoin(packages: Record<string, PackageDataEntry>) {
|
||||
bitcoinLoadState.value = bitcoinLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
|
||||
method: 'bitcoin.getinfo',
|
||||
timeout: 5000,
|
||||
})
|
||||
stats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100
|
||||
stats.bitcoinBlockHeight = btc.block_height ?? 0
|
||||
stats.bitcoinAvailable = true
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
const btcPkg = packages['bitcoin-knots'] || packages['bitcoin-core'] || packages.bitcoin
|
||||
if (btcPkg?.state === PackageState.Running) {
|
||||
stats.bitcoinAvailable = true
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
|
||||
if (btcPkg && (btcPkg.state === PackageState.Stopped || btcPkg.state === PackageState.Exited)) {
|
||||
stats.bitcoinAvailable = false
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
|
||||
// No authoritative package data yet. Keep the previous known value
|
||||
// rather than flashing "Not running" during route changes/scans.
|
||||
bitcoinLoadState.value = stats.bitcoinAvailable === null ? 'error' : 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshVpn(packages: Record<string, PackageDataEntry>) {
|
||||
vpnLoadState.value = vpnLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const status = await rpcClient.vpnStatus()
|
||||
vpnStatus.value = {
|
||||
connected: status.connected,
|
||||
provider: status.provider ?? status.configured_provider ?? '',
|
||||
}
|
||||
vpnLoadState.value = 'ready'
|
||||
lastVpnRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
const tailscale = packages.tailscale
|
||||
if (tailscale?.state === PackageState.Running) {
|
||||
vpnStatus.value = { connected: true, provider: 'tailscale' }
|
||||
vpnLoadState.value = 'ready'
|
||||
lastVpnRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
vpnLoadState.value = vpnStatus.value.connected === null ? 'error' : 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFips() {
|
||||
fipsLoadState.value = fipsLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const status = await rpcClient.call<{
|
||||
installed: boolean
|
||||
service_active: boolean
|
||||
key_present: boolean
|
||||
anchor_connected?: boolean
|
||||
authenticated_peer_count?: number
|
||||
}>({ method: 'fips.status' })
|
||||
fipsStatus.value = status
|
||||
fipsLoadState.value = 'ready'
|
||||
lastFipsRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
fipsLoadState.value = fipsStatus.value ? 'ready' : 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(packages: Record<string, PackageDataEntry>) {
|
||||
await Promise.all([
|
||||
refreshSystemStats(),
|
||||
refreshBitcoin(packages),
|
||||
refreshVpn(packages),
|
||||
refreshFips(),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
stats,
|
||||
systemLoadState,
|
||||
bitcoinLoadState,
|
||||
vpnLoadState,
|
||||
fipsLoadState,
|
||||
systemStatsLoaded,
|
||||
bitcoinKnown,
|
||||
vpnKnown,
|
||||
vpnStatus,
|
||||
fipsStatus,
|
||||
lastSystemRefreshAt,
|
||||
lastBitcoinRefreshAt,
|
||||
lastVpnRefreshAt,
|
||||
lastFipsRefreshAt,
|
||||
refresh,
|
||||
refreshSystemStats,
|
||||
refreshBitcoin,
|
||||
refreshVpn,
|
||||
refreshFips,
|
||||
}
|
||||
})
|
||||
+21
-3
@@ -1659,6 +1659,15 @@ html:has(body.video-background-active)::before {
|
||||
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.mobile-filter-btn {
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
|
||||
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.mobile-filter-sheet {
|
||||
padding-bottom: calc(var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 1.5rem);
|
||||
}
|
||||
|
||||
/* ── Cloud Audio Player (mini bar) ──── */
|
||||
|
||||
.cloud-audio-player {
|
||||
@@ -2098,7 +2107,7 @@ html:has(body.video-background-active)::before {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 14px;
|
||||
border-radius: 18px;
|
||||
overflow: visible;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
@@ -2108,7 +2117,16 @@ html:has(body.video-background-active)::before {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 14px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.app-card-icon {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.app-detail-icon {
|
||||
border-radius: 22px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Status dot — top-right of icon */
|
||||
@@ -2140,7 +2158,7 @@ html:has(body.video-background-active)::before {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 14px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.app-icon-label {
|
||||
|
||||
@@ -158,7 +158,7 @@ const { t } = useI18n()
|
||||
const appId = computed(() => {
|
||||
const id = route.params.id
|
||||
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
||||
router.replace('/apps')
|
||||
router.replace('/dashboard/apps')
|
||||
return ''
|
||||
}
|
||||
return id
|
||||
@@ -288,16 +288,19 @@ function goBack() {
|
||||
function launchApp() {
|
||||
if (!pkg.value) return
|
||||
const id = appId.value
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title })
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title, openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
|
||||
if (isWebsitePackage(id, pkg.value)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg.value)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
if (url) {
|
||||
useAppLauncherStore().open({ url, title: pkg.value.manifest.title, openInNewTab: !isMobile })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ const displayMode = ref<DisplayMode>(
|
||||
const appId = computed(() => {
|
||||
const id = props.appIdProp || (route.params.appId as string)
|
||||
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
||||
router.replace('/apps')
|
||||
router.replace('/dashboard/apps')
|
||||
return ''
|
||||
}
|
||||
return id
|
||||
@@ -160,6 +160,14 @@ const appUrl = computed(() => {
|
||||
return resolveAppUrl(appId.value, route.query.path as string | undefined)
|
||||
})
|
||||
|
||||
function closeRouteSession() {
|
||||
const fallback = route.query.returnTo
|
||||
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
|
||||
? fallback
|
||||
: '/dashboard/apps'
|
||||
router.replace(fallbackPath).catch(() => {})
|
||||
}
|
||||
|
||||
// --- Identity & Nostr bridge ---
|
||||
|
||||
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
|
||||
@@ -180,7 +188,8 @@ function setMode(mode: DisplayMode) {
|
||||
if (isInlinePanel.value && mode !== 'panel') {
|
||||
const id = appId.value
|
||||
emit('close')
|
||||
router.push({ name: 'app-session', params: { appId: id } })
|
||||
const returnTo = route.fullPath.startsWith('/dashboard') ? route.fullPath : '/dashboard/apps'
|
||||
router.push({ name: 'app-session', params: { appId: id }, query: { returnTo } })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -188,7 +197,11 @@ function setMode(mode: DisplayMode) {
|
||||
if (!isInlinePanel.value && mode === 'panel') {
|
||||
const id = appId.value
|
||||
const launcher = useAppLauncherStore()
|
||||
router.push({ name: 'apps' }).then(() => {
|
||||
const fallback = route.query.returnTo
|
||||
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
|
||||
? fallback
|
||||
: '/dashboard/apps'
|
||||
router.push(fallbackPath).then(() => {
|
||||
launcher.panelAppId = id
|
||||
})
|
||||
return
|
||||
@@ -295,7 +308,7 @@ function handleBackdropClick() {
|
||||
function closeSession() {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else router.back()
|
||||
else closeRouteSession()
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
@@ -328,11 +341,11 @@ watch(displayMode, (mode) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Apps that block iframes (X-Frame-Options) -- open in new tab, close session
|
||||
// Apps that block iframes open externally instead of landing in a broken webview.
|
||||
if (mustOpenNewTab.value && appUrl.value) {
|
||||
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else router.back()
|
||||
else closeRouteSession()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -518,6 +531,12 @@ onBeforeUnmount(() => {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.app-session-frame-scroll-host {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
|
||||
@media (max-width: 767px) {
|
||||
.app-session-root {
|
||||
@@ -530,6 +549,8 @@ onBeforeUnmount(() => {
|
||||
.app-session-fullscreen {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
|
||||
background: black;
|
||||
}
|
||||
.app-session-panel.glass-card {
|
||||
border: none !important;
|
||||
@@ -543,8 +564,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.app-session-frame-safe {
|
||||
flex: none !important;
|
||||
height: calc(100vh - var(--app-session-mobile-bar-height, 84px));
|
||||
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px));
|
||||
height: calc(100vh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
+314
-16
@@ -18,24 +18,48 @@
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="min-w-0 flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn"
|
||||
aria-label="Sideload app"
|
||||
title="Sideload app"
|
||||
@click="showSideload = true"
|
||||
>
|
||||
<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="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
|
||||
<div class="md:hidden">
|
||||
<!-- Mobile: search + sideload button (tabs handled by Dashboard.vue header) -->
|
||||
<div class="md:hidden flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
class="min-w-0 flex-1 px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn sideload-icon-btn-mobile"
|
||||
aria-label="Sideload app"
|
||||
title="Sideload app"
|
||||
@click="showSideload = true"
|
||||
>
|
||||
<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="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,10 +106,34 @@
|
||||
</div>
|
||||
|
||||
<!-- No Results -->
|
||||
<div v-if="filteredPackageEntries.length === 0 && searchQuery" class="text-center py-12">
|
||||
<div v-if="filteredPackageEntries.length === 0 && marketplaceMatches.length === 0 && searchQuery" class="text-center py-12">
|
||||
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="marketplaceMatches.length > 0" class="mb-5">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="discover-terminal-tag">app store</span>
|
||||
<h2 class="text-lg font-bold text-white">Available in Discover</h2>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<button
|
||||
v-for="app in marketplaceMatches"
|
||||
:key="app.id"
|
||||
type="button"
|
||||
class="glass-card p-4 text-left flex items-center gap-3 hover:bg-orange-500/5 hover:border-orange-500/15 transition-colors"
|
||||
@click="openMarketplaceResult(app)"
|
||||
>
|
||||
<img v-if="app.icon" :src="app.icon" :alt="app.title" class="w-12 h-12 rounded-xl object-cover bg-white/10" />
|
||||
<div v-else class="w-12 h-12 rounded-xl bg-white/10 flex-shrink-0"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold text-white truncate">{{ app.title }}</p>
|
||||
<p class="text-xs text-white/50 truncate">Available in App Store</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: iPhone-style icon grid -->
|
||||
<div class="md:hidden">
|
||||
<AppIconGrid
|
||||
@@ -125,6 +173,63 @@
|
||||
@confirm="onConfirmUninstall"
|
||||
/>
|
||||
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showSideload"
|
||||
class="fixed inset-0 z-[2600] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
|
||||
@click.self="closeSideload"
|
||||
>
|
||||
<form class="sideload-modal" @submit.prevent="submitSideload">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Sideload app</h2>
|
||||
<p class="text-sm text-white/55 mt-1">Install a trusted Docker image with a simple web UI.</p>
|
||||
</div>
|
||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeSideload">×</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="block">
|
||||
<span class="sideload-label">App ID</span>
|
||||
<input v-model.trim="sideloadForm.id" class="sideload-input" placeholder="excalidraw" pattern="[a-z0-9][a-z0-9-]{0,63}" required />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Docker image</span>
|
||||
<input v-model.trim="sideloadForm.image" class="sideload-input" placeholder="docker.io/excalidraw/excalidraw:latest" required />
|
||||
</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label class="block">
|
||||
<span class="sideload-label">Title</span>
|
||||
<input v-model.trim="sideloadForm.title" class="sideload-input" placeholder="Excalidraw" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Port mapping</span>
|
||||
<input v-model.trim="sideloadForm.port" class="sideload-input" placeholder="3009:80" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Description</span>
|
||||
<input v-model.trim="sideloadForm.description" class="sideload-input" placeholder="Collaborative whiteboard" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="sideloadError" class="alert-error mt-4 text-sm">{{ sideloadError }}</div>
|
||||
|
||||
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
|
||||
<p class="font-medium text-white/80 mb-2">Easy sources</p>
|
||||
<p>Use images from Docker Hub, GHCR, git.tx1138.com, the VPS2 Gitea registry, or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex gap-3">
|
||||
<button type="button" class="flex-1 glass-button px-4 py-3 rounded-lg" @click="closeSideload">Cancel</button>
|
||||
<button type="submit" class="flex-1 glass-button px-4 py-3 rounded-lg font-semibold" :disabled="sideloading">
|
||||
{{ sideloading ? 'Installing...' : 'Install' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actions.actionError.value" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
@@ -149,15 +254,18 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { type PackageDataEntry, type PackageState } from '@/types/api'
|
||||
import AppCard from './apps/AppCard.vue'
|
||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { useAppsActions } from './apps/useAppsActions'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import {
|
||||
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
||||
WEB_ONLY_APPS, buildAllCategories, useCategoriesWithApps,
|
||||
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
|
||||
} from './apps/appsConfig'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, type MarketplaceApp } from './marketplace/marketplaceData'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -165,6 +273,17 @@ const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const serverStore = useServerStore()
|
||||
const actions = useAppsActions()
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
const showSideload = ref(false)
|
||||
const sideloading = ref(false)
|
||||
const sideloadError = ref('')
|
||||
const sideloadForm = ref({
|
||||
id: '',
|
||||
image: '',
|
||||
title: '',
|
||||
port: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
// Only stagger-animate on first mount
|
||||
const showStagger = !appsAnimationDone
|
||||
@@ -174,6 +293,10 @@ const activeTab = ref<AppsTab>(
|
||||
route.query.tab === 'websites' || route.query.tab === 'services' ? 'websites' : 'apps'
|
||||
)
|
||||
|
||||
watch(() => route.query.tab, (tab) => {
|
||||
activeTab.value = tab === 'websites' || tab === 'services' ? 'websites' : 'apps'
|
||||
})
|
||||
|
||||
// Search (debounced)
|
||||
const searchQuery = ref('')
|
||||
const debouncedSearchQuery = ref('')
|
||||
@@ -217,6 +340,19 @@ const packages = computed(() => {
|
||||
|
||||
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
|
||||
|
||||
const curatedApps = getCuratedAppList()
|
||||
const marketplaceMatches = computed(() => {
|
||||
const q = debouncedSearchQuery.value.trim().toLowerCase()
|
||||
if (!q || activeTab.value !== 'apps') return [] as MarketplaceApp[]
|
||||
return curatedApps.filter(app => {
|
||||
if (isInstalledInMyApps(app.id)) return false
|
||||
return app.title?.toLowerCase().includes(q) ||
|
||||
app.id.toLowerCase().includes(q) ||
|
||||
app.author?.toLowerCase().includes(q) ||
|
||||
(typeof app.description === 'string' && app.description.toLowerCase().includes(q))
|
||||
}).slice(0, 6)
|
||||
})
|
||||
|
||||
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
|
||||
|
||||
// Connection error state
|
||||
@@ -260,6 +396,17 @@ const filteredPackageEntries = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function isInstalledInMyApps(appId: string): boolean {
|
||||
if (appId in packages.value) return true
|
||||
const aliases = INSTALLED_ALIASES[appId]
|
||||
return aliases ? aliases.some(alias => alias in packages.value) : false
|
||||
}
|
||||
|
||||
function openMarketplaceResult(app: MarketplaceApp) {
|
||||
setCurrentApp(app)
|
||||
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'apps' } }).catch(() => {})
|
||||
}
|
||||
|
||||
// Uninstall modal
|
||||
const uninstallModal = ref({ show: false, appId: '', appTitle: '' })
|
||||
|
||||
@@ -286,12 +433,20 @@ function goToApp(id: string) {
|
||||
|
||||
function launchApp(id: string) {
|
||||
const pkg = packages.value[id]
|
||||
if (pkg && isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (pkg && webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.manifest.title, openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
if (pkg && opensInTab(id)) {
|
||||
if (pkg && isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
useAppLauncherStore().open({ url, title: pkg.manifest.title, openInNewTab: !isMobile })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isMobile && pkg && opensInTab(id)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
@@ -308,4 +463,147 @@ async function updateApp(id: string) {
|
||||
actions.actionError.value = `Failed to update ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||
}
|
||||
}
|
||||
|
||||
function closeSideload() {
|
||||
if (sideloading.value) return
|
||||
showSideload.value = false
|
||||
sideloadError.value = ''
|
||||
}
|
||||
|
||||
function inferPortMapping(image: string): string {
|
||||
const lower = image.toLowerCase()
|
||||
if (lower.includes('excalidraw')) return '3009:80'
|
||||
if (lower.includes('stirling')) return '3011:8080'
|
||||
if (lower.includes('freshrss')) return '3012:80'
|
||||
if (lower.includes('wallabag')) return '3013:80'
|
||||
if (lower.includes('hedgedoc')) return '3014:3000'
|
||||
if (lower.includes('cyberchef')) return '3015:80'
|
||||
if (lower.includes('mealie')) return '3016:9000'
|
||||
if (lower.includes('pairdrop')) return '3017:3000'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function submitSideload() {
|
||||
const id = sideloadForm.value.id.trim().toLowerCase()
|
||||
const image = sideloadForm.value.image.trim()
|
||||
const title = sideloadForm.value.title.trim() || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
const port = sideloadForm.value.port.trim() || inferPortMapping(image)
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) {
|
||||
sideloadError.value = 'Use lowercase letters, numbers, and hyphens only.'
|
||||
return
|
||||
}
|
||||
if (!image || !image.includes('/')) {
|
||||
sideloadError.value = 'Enter a full image name, for example docker.io/library/nginx:alpine.'
|
||||
return
|
||||
}
|
||||
sideloading.value = true
|
||||
sideloadError.value = ''
|
||||
const containerConfig: Record<string, unknown> = {}
|
||||
containerConfig.title = title
|
||||
if (sideloadForm.value.description.trim()) containerConfig.description = sideloadForm.value.description.trim()
|
||||
if (port) containerConfig.ports = [port]
|
||||
try {
|
||||
serverStore.setInstallProgress(id, {
|
||||
id,
|
||||
title,
|
||||
status: 'downloading',
|
||||
progress: 2,
|
||||
message: 'Sideload queued...',
|
||||
attempt: 0,
|
||||
})
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: {
|
||||
id,
|
||||
dockerImage: image,
|
||||
version: 'sideload',
|
||||
containerConfig,
|
||||
},
|
||||
timeout: 600000,
|
||||
})
|
||||
closeSideload()
|
||||
sideloadForm.value = { id: '', image: '', title: '', port: '', description: '' }
|
||||
} catch (err) {
|
||||
sideloadError.value = err instanceof Error ? err.message : 'Install failed'
|
||||
serverStore.setInstallProgress(id, {
|
||||
id,
|
||||
title,
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: sideloadError.value,
|
||||
attempt: 0,
|
||||
})
|
||||
} finally {
|
||||
sideloading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sideload-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.sideload-icon-btn:hover,
|
||||
.sideload-icon-btn:focus-visible {
|
||||
border-color: rgba(255, 255, 255, 0.38);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
.sideload-icon-btn-mobile {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.sideload-modal {
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem 1.5rem 0 0;
|
||||
background: rgba(8, 10, 18, 0.94);
|
||||
padding: 1.25rem;
|
||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.sideload-close-btn {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.sideload-label {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.sideload-input {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.75rem 0.9rem;
|
||||
color: white;
|
||||
outline: none;
|
||||
}
|
||||
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
|
||||
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
|
||||
@media (min-width: 768px) {
|
||||
.sideload-modal {
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
data-controller-zone="main"
|
||||
class="flex-1 overflow-hidden relative pb-0 glass-piece z-10"
|
||||
:class="{ 'glass-throw-main': showZoomIn }"
|
||||
tabindex="-1"
|
||||
@pointerenter="activateMainScroll"
|
||||
@wheel.capture="activateMainScroll"
|
||||
>
|
||||
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
||||
<!-- Controller zone entry point - no switcher -->
|
||||
@@ -234,6 +237,14 @@ function restoreScroll(path: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function activateMainScroll() {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (active?.closest?.('[data-controller-zone="sidebar"]')) {
|
||||
active.blur()
|
||||
document.getElementById('main-content')?.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.path, (newPath) => {
|
||||
const isAppDetails = isDetailRoute(newPath)
|
||||
const wasAppDetails = showAltBackground.value
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
|
||||
<!-- Mobile: search -->
|
||||
<div class="md:hidden mb-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="discover-terminal-tag">discover</span>
|
||||
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -312,6 +316,9 @@ const categoriesWithApps = computed(() => {
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||
apps = apps.filter(app => app.category === selectedCategory.value)
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
apps = apps.filter(app =>
|
||||
@@ -383,7 +390,7 @@ function isStartingUp(appId: string): boolean {
|
||||
|
||||
function getAppTier(appId: string): string {
|
||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
|
||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'portainer']
|
||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
||||
if (core.includes(appId)) return 'core'
|
||||
if (recommended.includes(appId)) return 'recommended'
|
||||
return 'optional'
|
||||
@@ -487,7 +494,7 @@ async function installApp(app: MarketplaceApp) {
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
try {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version }, timeout: 15000 })
|
||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version }, timeout: 600000 })
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
||||
failInstall(app, err)
|
||||
@@ -504,7 +511,7 @@ async function installCommunityApp(app: MarketplaceApp) {
|
||||
if ((app as Record<string, unknown>).containerConfig) {
|
||||
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
|
||||
}
|
||||
await rpcClient.call({ method: 'package.install', params: installParams, timeout: 15000 })
|
||||
await rpcClient.call({ method: 'package.install', params: installParams, timeout: 600000 })
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('[Discover] Installation failed:', err)
|
||||
failInstall(app, err)
|
||||
|
||||
+40
-23
@@ -144,12 +144,12 @@
|
||||
<span class="text-sm font-medium" :class="torConnected ? 'text-purple-400' : 'text-white/40'">{{ torConnected ? 'Connected' : 'Offline' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="vpnConnected ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">VPN</span></div>
|
||||
<span class="text-sm font-medium" :class="vpnConnected ? 'text-orange-400' : 'text-white/40'">{{ vpnConnected ? 'WireGuard' : 'Not configured' }}</span>
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="vpnDotClass"></div><span class="text-sm text-white/80">VPN</span></div>
|
||||
<span class="text-sm font-medium" :class="vpnTextClass">{{ vpnStatusLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="systemStats.bitcoinAvailable ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Bitcoin</span></div>
|
||||
<span class="text-sm font-medium" :class="systemStats.bitcoinAvailable ? 'text-orange-400' : 'text-white/40'">{{ bitcoinSyncDisplay }}</span>
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="bitcoinDotClass"></div><span class="text-sm text-white/80">Bitcoin</span></div>
|
||||
<span class="text-sm font-medium" :class="bitcoinTextClass">{{ bitcoinSyncDisplay }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="fipsDotClass"></div><span class="text-sm text-white/80">FIPS</span></div>
|
||||
@@ -229,7 +229,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
||||
@@ -239,6 +239,7 @@ import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||
import { PackageState } from '../types/api'
|
||||
import { playTypingSound } from '@/composables/useLoginSounds'
|
||||
import { GOALS } from '@/data/goals'
|
||||
@@ -261,6 +262,7 @@ const QUICK_START_KEY = 'archipelago-quick-start-dismissed'
|
||||
const QUICK_START_RESHOW_LOGINS = 5
|
||||
|
||||
const store = useAppStore()
|
||||
const homeStatus = useHomeStatusStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
const LINE1 = t('home.title')
|
||||
@@ -315,11 +317,19 @@ const torConnected = computed(() => {
|
||||
const torAddr = store.data?.['server-info']?.['tor-address']
|
||||
return !!torAddr && torAddr.length > 0
|
||||
})
|
||||
const vpnStatus = ref({ connected: false, provider: '' })
|
||||
const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
|
||||
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
|
||||
const vpnConnected = computed(() => homeStatus.vpnStatus.connected === true || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
|
||||
const vpnDotClass = computed(() => {
|
||||
if (vpnConnected.value) return 'bg-orange-400'
|
||||
return homeStatus.vpnKnown ? 'bg-white/40' : 'bg-white/25 animate-pulse'
|
||||
})
|
||||
const vpnTextClass = computed(() => vpnConnected.value ? 'text-orange-400' : (homeStatus.vpnKnown ? 'text-white/40' : 'text-white/50'))
|
||||
const vpnStatusLabel = computed(() => {
|
||||
if (vpnConnected.value) return homeStatus.vpnStatus.provider || 'WireGuard'
|
||||
if (!homeStatus.vpnKnown) return 'Checking…'
|
||||
return 'Not configured'
|
||||
})
|
||||
const fipsDotClass = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
const s = homeStatus.fipsStatus
|
||||
if (!s || !s.installed) return 'bg-white/40'
|
||||
if (!s.service_active) return 'bg-white/40'
|
||||
// Active but no anchor = degraded, not fully green
|
||||
@@ -327,15 +337,15 @@ const fipsDotClass = computed(() => {
|
||||
return 'bg-green-400'
|
||||
})
|
||||
const fipsTextClass = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
const s = homeStatus.fipsStatus
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
if (!s.service_active) return 'text-white/40'
|
||||
if (s.anchor_connected === false) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
const fipsStatusLabel = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
if (!s) return '…'
|
||||
const s = homeStatus.fipsStatus
|
||||
if (!s) return homeStatus.fipsLoadState === 'loading' ? 'Checking…' : '…'
|
||||
if (!s.installed) return 'Not installed'
|
||||
if (!s.service_active) {
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
@@ -348,11 +358,18 @@ const fipsStatusLabel = computed(() => {
|
||||
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
|
||||
})
|
||||
const bitcoinSyncDisplay = computed(() => {
|
||||
if (!systemStats.bitcoinAvailable) return 'Not running'
|
||||
if (systemStats.bitcoinSyncPercent >= 99.9) return 'Synced'
|
||||
if (systemStats.bitcoinSyncPercent < 0.01 && systemStats.bitcoinBlockHeight === 0) return 'Loading...'
|
||||
return `${systemStats.bitcoinSyncPercent.toFixed(1)}%`
|
||||
if (homeStatus.stats.bitcoinAvailable === null) return 'Checking…'
|
||||
if (!homeStatus.stats.bitcoinAvailable) return 'Not running'
|
||||
if (homeStatus.stats.bitcoinSyncPercent >= 99.9) return 'Synced'
|
||||
if (homeStatus.stats.bitcoinSyncPercent < 0.01 && homeStatus.stats.bitcoinBlockHeight === 0) return 'Loading...'
|
||||
return `${homeStatus.stats.bitcoinSyncPercent.toFixed(1)}%`
|
||||
})
|
||||
const bitcoinDotClass = computed(() => {
|
||||
if (homeStatus.stats.bitcoinAvailable === true) return 'bg-orange-400'
|
||||
if (homeStatus.stats.bitcoinAvailable === false) return 'bg-white/40'
|
||||
return 'bg-white/25 animate-pulse'
|
||||
})
|
||||
const bitcoinTextClass = computed(() => homeStatus.stats.bitcoinAvailable ? 'text-orange-400' : (homeStatus.stats.bitcoinAvailable === null ? 'text-white/50' : 'text-white/40'))
|
||||
|
||||
// Quick Start
|
||||
const quickStartDismissed = ref(false)
|
||||
@@ -382,8 +399,6 @@ const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? Stri
|
||||
onMounted(async () => {
|
||||
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
|
||||
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 30000); checkUpdateStatus(); loadWeb5Status()
|
||||
rpcClient.vpnStatus().then(s => { vpnStatus.value = { connected: s.connected, provider: s.provider ?? '' } }).catch(() => {})
|
||||
rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean }>({ method: 'fips.status' }).then(s => { fipsStatus.value = s }).catch(() => {})
|
||||
})
|
||||
|
||||
// Wallet modals
|
||||
@@ -403,15 +418,17 @@ async function loadWeb5Status() {
|
||||
}
|
||||
|
||||
// System stats
|
||||
const systemStatsLoaded = ref(false)
|
||||
const systemStats = reactive({ cpuPercent: 0, memUsed: 0, memTotal: 0, memPercent: 0, diskUsed: 0, diskTotal: 0, diskPercent: 0, uptimeSecs: 0, bitcoinSyncPercent: 0, bitcoinBlockHeight: 0, bitcoinAvailable: false })
|
||||
const systemUptimeDisplay = computed(() => { if (systemStats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(systemStats.uptimeSecs / 86400); const hours = Math.floor((systemStats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((systemStats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
|
||||
const systemStatsLoaded = computed(() => homeStatus.systemStatsLoaded)
|
||||
const systemStats = computed(() => ({
|
||||
...homeStatus.stats,
|
||||
bitcoinAvailable: homeStatus.stats.bitcoinAvailable === true,
|
||||
}))
|
||||
const systemUptimeDisplay = computed(() => { if (homeStatus.stats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(homeStatus.stats.uptimeSecs / 86400); const hours = Math.floor((homeStatus.stats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((homeStatus.stats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
|
||||
|
||||
let systemStatsInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadSystemStats() {
|
||||
try { const res = await rpcClient.call<{ cpu_usage_percent: number; mem_used_bytes: number; mem_total_bytes: number; disk_used_bytes: number; disk_total_bytes: number; uptime_secs: number }>({ method: 'system.stats' }); systemStats.cpuPercent = res.cpu_usage_percent; systemStats.memUsed = res.mem_used_bytes; systemStats.memTotal = res.mem_total_bytes; systemStats.memPercent = res.mem_total_bytes > 0 ? (res.mem_used_bytes / res.mem_total_bytes) * 100 : 0; systemStats.diskUsed = res.disk_used_bytes; systemStats.diskTotal = res.disk_total_bytes; systemStats.diskPercent = res.disk_total_bytes > 0 ? (res.disk_used_bytes / res.disk_total_bytes) * 100 : 0; systemStats.uptimeSecs = res.uptime_secs; systemStatsLoaded.value = true } catch { systemStatsLoaded.value = true }
|
||||
try { const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({ method: 'bitcoin.getinfo', timeout: 5000 }); systemStats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100; systemStats.bitcoinBlockHeight = btc.block_height ?? 0; systemStats.bitcoinAvailable = true } catch { /* RPC failed — check if container is at least running (loading/syncing) */ const btcPkg = packages.value['bitcoin-knots']; systemStats.bitcoinAvailable = btcPkg?.state === PackageState.Running }
|
||||
await homeStatus.refresh(packages.value)
|
||||
}
|
||||
|
||||
function uploadFiles() { const pkg = packages.value['filebrowser']; if (pkg && pkg.state === PackageState.Running) { const host = window.location.hostname; useAppLauncherStore().open({ url: `http://${host}:8083`, title: 'File Browser' }) } else { router.push('/dashboard/cloud') } }
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
|
||||
<!-- Mobile: search (tabs handled by Dashboard.vue header) -->
|
||||
<div class="md:hidden mb-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="discover-terminal-tag">discover</span>
|
||||
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -258,7 +262,7 @@ const categoriesWithApps = computed(() => {
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all') {
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||
apps = apps.filter(app => app.category === selectedCategory.value)
|
||||
}
|
||||
|
||||
@@ -415,7 +419,7 @@ async function installApp(app: MarketplaceApp) {
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: { id: app.id, url: installUrl, version: app.version },
|
||||
timeout: 15000,
|
||||
timeout: 600000,
|
||||
})
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
||||
@@ -441,7 +445,7 @@ async function installCommunityApp(app: MarketplaceApp) {
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: installParams,
|
||||
timeout: 15000,
|
||||
timeout: 600000,
|
||||
})
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', err)
|
||||
|
||||
@@ -616,7 +616,7 @@ async function installApp() {
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: installParams,
|
||||
timeout: 15000,
|
||||
timeout: 600000,
|
||||
})
|
||||
} else {
|
||||
// Package-based installation
|
||||
@@ -628,7 +628,7 @@ async function installApp() {
|
||||
url: installUrl,
|
||||
version: app.value.version,
|
||||
},
|
||||
timeout: 15000,
|
||||
timeout: 600000,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -41,23 +41,23 @@
|
||||
<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">{{ t('monitoring.cpu') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ current?.system.cpu_percent.toFixed(1) ?? '--' }}%</p>
|
||||
<p class="text-xs text-white/40">{{ t('monitoring.load') }} {{ current?.system.load_avg_1.toFixed(2) ?? '--' }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ liveSystem.cpu_percent.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">{{ t('monitoring.load') }} {{ liveSystem.load_avg_1.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.memory') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ memPercent }}%</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(current?.system.mem_used_bytes ?? 0) }} / {{ formatBytes(current?.system.mem_total_bytes ?? 0) }}</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(liveSystem.mem_used_bytes) }} / {{ formatBytes(liveSystem.mem_total_bytes) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.diskUsage') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ diskPercent }}%</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(current?.system.disk_used_bytes ?? 0) }} / {{ formatBytes(current?.system.disk_total_bytes ?? 0) }}</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(liveSystem.disk_used_bytes) }} / {{ formatBytes(liveSystem.disk_total_bytes) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.network') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ formatBytes(current?.system.net_rx_bytes ?? 0) }}</p>
|
||||
<p class="text-xs text-white/40">TX: {{ formatBytes(current?.system.net_tx_bytes ?? 0) }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ formatBytes(liveSystem.net_rx_bytes) }}</p>
|
||||
<p class="text-xs text-white/40">TX: {{ formatBytes(liveSystem.net_tx_bytes) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -236,6 +236,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||
import LineChart from '@/components/LineChart.vue'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||
@@ -297,6 +298,7 @@ interface FiredAlert {
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const homeStatus = useHomeStatusStore()
|
||||
|
||||
const current = ref<MetricSnapshot | null>(null)
|
||||
const history = ref<MetricSnapshot[]>([])
|
||||
@@ -307,14 +309,27 @@ const showAlertConfig = ref(false)
|
||||
const chartWidth = ref(380)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const liveSystem = computed<SystemMetrics>(() => ({
|
||||
cpu_percent: homeStatus.stats.cpuPercent,
|
||||
mem_used_bytes: homeStatus.stats.memUsed,
|
||||
mem_total_bytes: homeStatus.stats.memTotal,
|
||||
disk_used_bytes: homeStatus.stats.diskUsed,
|
||||
disk_total_bytes: homeStatus.stats.diskTotal,
|
||||
net_rx_bytes: current.value?.system.net_rx_bytes ?? 0,
|
||||
net_tx_bytes: current.value?.system.net_tx_bytes ?? 0,
|
||||
load_avg_1: homeStatus.stats.loadAvg1,
|
||||
load_avg_5: homeStatus.stats.loadAvg5,
|
||||
load_avg_15: homeStatus.stats.loadAvg15,
|
||||
}))
|
||||
|
||||
const memPercent = computed(() => {
|
||||
if (!current.value?.system.mem_total_bytes) return '--'
|
||||
return ((current.value.system.mem_used_bytes / current.value.system.mem_total_bytes) * 100).toFixed(1)
|
||||
if (!liveSystem.value.mem_total_bytes) return '--'
|
||||
return ((liveSystem.value.mem_used_bytes / liveSystem.value.mem_total_bytes) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
const diskPercent = computed(() => {
|
||||
if (!current.value?.system.disk_total_bytes) return '--'
|
||||
return ((current.value.system.disk_used_bytes / current.value.system.disk_total_bytes) * 100).toFixed(1)
|
||||
if (!liveSystem.value.disk_total_bytes) return '--'
|
||||
return ((liveSystem.value.disk_used_bytes / liveSystem.value.disk_total_bytes) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
const historyMinutesAgo = computed(() => history.value.length || 60)
|
||||
@@ -460,6 +475,7 @@ async function exportMetrics(format: 'csv' | 'json') {
|
||||
|
||||
async function fetchCurrent() {
|
||||
try {
|
||||
await homeStatus.refreshSystemStats()
|
||||
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
|
||||
method: 'monitoring.current',
|
||||
})
|
||||
|
||||
@@ -344,94 +344,96 @@
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Add-mirror modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="addingMirror" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelAddMirror">
|
||||
<div class="glass-card p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
|
||||
<p class="text-sm text-white/60 mb-5">
|
||||
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
|
||||
</p>
|
||||
<form class="space-y-3" @submit.prevent="submitMirror">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
|
||||
<input
|
||||
v-model="mirrorDraft.url"
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="https://host/.../manifest.json"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
|
||||
<input
|
||||
v-model="mirrorDraft.label"
|
||||
type="text"
|
||||
placeholder="Home VPS"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelAddMirror"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>{{ t('common.cancel') }}</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
|
||||
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
|
||||
<div class="glass-card p-6 max-w-sm w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-3">
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackTitle')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyTitle')
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadTitle')
|
||||
: t('systemUpdate.applyTitle') }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-6">
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackMessage')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyMessage')
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadConfirm')
|
||||
: t('systemUpdate.applyMessage') }}
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="executeConfirm"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
||||
>
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackButton')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.pullAndRebuild')
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadButton')
|
||||
: t('systemUpdate.applyNow') }}
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<!-- Add-mirror modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="addingMirror" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="cancelAddMirror">
|
||||
<div class="glass-card p-6 max-w-md w-full">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
|
||||
<p class="text-sm text-white/60 mb-5">
|
||||
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
|
||||
</p>
|
||||
<form class="space-y-3" @submit.prevent="submitMirror">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
|
||||
<input
|
||||
v-model="mirrorDraft.url"
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="https://host/.../manifest.json"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
|
||||
<input
|
||||
v-model="mirrorDraft.label"
|
||||
type="text"
|
||||
placeholder="Home VPS"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelAddMirror"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>{{ t('common.cancel') }}</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
|
||||
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="confirmAction" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="cancelConfirm">
|
||||
<div class="glass-card p-6 max-w-sm w-full">
|
||||
<h3 class="text-lg font-semibold text-white mb-3">
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackTitle')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyTitle')
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadTitle')
|
||||
: t('systemUpdate.applyTitle') }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-6">
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackMessage')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyMessage')
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadConfirm')
|
||||
: t('systemUpdate.applyMessage') }}
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="executeConfirm"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
||||
>
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackButton')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.pullAndRebuild')
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadButton')
|
||||
: t('systemUpdate.applyNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<img
|
||||
:src="icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
class="app-detail-icon w-20 h-20 shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
<img
|
||||
:src="icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
class="app-detail-icon w-20 h-20 shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
portainer: 'portainer',
|
||||
'uptime-kuma': 'uptime-kuma',
|
||||
tailscale: 'tailscale',
|
||||
netbird: 'netbird',
|
||||
}
|
||||
|
||||
/** Backend may register under variant container names */
|
||||
|
||||
@@ -9,16 +9,24 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<iframe
|
||||
<div
|
||||
v-if="appUrl && !iframeBlocked"
|
||||
ref="iframeRef"
|
||||
:key="refreshKey"
|
||||
:src="appUrl"
|
||||
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
|
||||
title="App content"
|
||||
@load="$emit('iframeLoad')"
|
||||
@error="$emit('iframeError')"
|
||||
/>
|
||||
class="absolute inset-0 app-session-frame-scroll-host"
|
||||
tabindex="-1"
|
||||
@pointerdown="focusIframe"
|
||||
@focusin="focusIframe"
|
||||
>
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
:key="refreshKey"
|
||||
:src="appUrl"
|
||||
class="w-full h-full border-0 iframe-scrollbar-hide"
|
||||
title="App content"
|
||||
tabindex="0"
|
||||
@load="handleIframeLoad"
|
||||
@error="$emit('iframeError')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Iframe blocked fallback -->
|
||||
<Transition name="content-fade">
|
||||
@@ -69,9 +77,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
appUrl: string
|
||||
appId: string
|
||||
appTitle: string
|
||||
@@ -82,7 +90,7 @@ defineProps<{
|
||||
refreshKey: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
iframeLoad: []
|
||||
iframeError: []
|
||||
refresh: []
|
||||
@@ -91,5 +99,21 @@ defineEmits<{
|
||||
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
function focusIframe() {
|
||||
iframeRef.value?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
async function handleIframeLoad() {
|
||||
emit('iframeLoad')
|
||||
await nextTick()
|
||||
requestAnimationFrame(focusIframe)
|
||||
}
|
||||
|
||||
watch(() => [props.appUrl, props.refreshKey, props.iframeBlocked], async () => {
|
||||
if (!props.appUrl || props.iframeBlocked) return
|
||||
await nextTick()
|
||||
requestAnimationFrame(focusIframe)
|
||||
}, { immediate: true })
|
||||
|
||||
defineExpose({ iframeRef })
|
||||
</script>
|
||||
|
||||
@@ -14,6 +14,7 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'archy-electrs-ui': 50002,
|
||||
'mempool-electrs': 50002,
|
||||
'btcpay-server': 23000,
|
||||
'saleor': 9000,
|
||||
'lnd': 18083,
|
||||
'archy-lnd-ui': 18083,
|
||||
'mempool': 4080,
|
||||
@@ -34,6 +35,7 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'nginx-proxy-manager': 8081,
|
||||
'gitea': 3001,
|
||||
'portainer': 9000,
|
||||
'netbird': 8087,
|
||||
'tailscale': 8240,
|
||||
'uptime-kuma': 3002,
|
||||
'fedimint': 8175,
|
||||
@@ -62,6 +64,7 @@ export const EXTERNAL_URLS: Record<string, string> = {
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
'arch-presentation': 'https://present.l484.com',
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
'nostrudel': 'https://nostrudel.ninja',
|
||||
@@ -69,7 +72,7 @@ export const EXTERNAL_URLS: Record<string, string> = {
|
||||
|
||||
export const APP_TITLES: Record<string, string> = {
|
||||
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
|
||||
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
|
||||
'btcpay-server': 'BTCPay Server', 'saleor': 'Saleor', 'indeedhub': 'Indeehub',
|
||||
'botfights': 'BotFights', 'gitea': 'Gitea', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
|
||||
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
|
||||
'nginx-proxy-manager': 'Nginx Proxy Manager',
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<img
|
||||
:src="icon"
|
||||
:alt="title"
|
||||
class="w-14 h-14 rounded-lg object-cover bg-white/10"
|
||||
class="app-card-icon w-14 h-14 object-cover bg-white/10"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
@@ -145,7 +145,7 @@
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{{ t('common.launch') }}
|
||||
<svg v-if="opensInTab(id)" class="w-3.5 h-3.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||
<svg v-if="opensInTab(id)" class="hidden md:block w-3.5 h-3.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||
</button>
|
||||
<!-- Start (play icon) -->
|
||||
<button
|
||||
@@ -266,7 +266,7 @@ const tier = computed(() => {
|
||||
const t = props.pkg.manifest?.tier
|
||||
if (t && t !== '') return t
|
||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
|
||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'portainer']
|
||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
||||
if (core.includes(props.id)) return 'core'
|
||||
if (recommended.includes(props.id)) return 'recommended'
|
||||
return 'optional'
|
||||
|
||||
@@ -79,7 +79,7 @@ import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl } from './appsConfig'
|
||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
|
||||
@@ -120,14 +120,20 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
|
||||
|
||||
function handleTap(id: string, pkg: PackageDataEntry) {
|
||||
if (canLaunch(pkg)) {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
appLauncher.open({ url: webOnlyUrl, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
if (isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
appLauncher.open({ url, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (opensInTab(id)) {
|
||||
if (!isMobile && opensInTab(id)) {
|
||||
const appUrl = resolveRuntimeLaunchUrl(pkg) || resolveAppUrl(id)
|
||||
if (appUrl) {
|
||||
window.open(appUrl, '_blank', 'noopener,noreferrer')
|
||||
|
||||
@@ -35,6 +35,11 @@ describe('AppIconGrid', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 1024,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.198' },
|
||||
writable: true,
|
||||
@@ -55,4 +60,24 @@ describe('AppIconGrid', () => {
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(useAppLauncherStore().panelAppId).toBe('lnd')
|
||||
})
|
||||
|
||||
it('routes desktop new-tab apps through app session on mobile', async () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['gitea', makePkg('gitea')]] },
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(useAppLauncherStore().panelAppId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,9 +55,9 @@ export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
|
||||
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
|
||||
'searxng': 'community', 'ollama': 'community', 'grafana': 'data',
|
||||
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
|
||||
'nostrudel': 'nostr',
|
||||
'tailscale': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
|
||||
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
|
||||
'uptime-kuma': 'networking', 'dwn': 'data',
|
||||
'botfights': 'community', 'nwnn': 'l484', '484-kitchen': 'l484',
|
||||
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
|
||||
@@ -107,6 +107,7 @@ export const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
'arch-presentation': 'https://present.l484.com',
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
}
|
||||
@@ -132,6 +133,11 @@ export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
||||
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
|
||||
},
|
||||
'arch-presentation': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
|
||||
},
|
||||
'syntropy-institute': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
@@ -160,7 +166,8 @@ const APP_ICON_FALLBACKS: Record<string, string> = {
|
||||
}
|
||||
|
||||
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
|
||||
const icon = (pkg["static-files"]?.icon || "").trim()
|
||||
const rawIcon = (pkg["static-files"]?.icon || "").trim()
|
||||
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
|
||||
if (
|
||||
icon.startsWith("/") ||
|
||||
icon.startsWith("http://") ||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
to="/dashboard/apps?tab=websites"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' || route.query.tab === 'websites' }"
|
||||
@click.prevent="router.push({ path: '/dashboard/apps', query: { tab: 'websites' } })"
|
||||
>Websites</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,7 +143,7 @@ const mobileTabBar = ref<HTMLElement | null>(null)
|
||||
|
||||
// App sessions own their mobile controls. Normal mobile launches use the route
|
||||
// session; keeping this guard also protects any desktop-panel state on resize.
|
||||
const isAppSessionActive = computed(() => route.name === 'app-session' || !!appLauncher.panelAppId)
|
||||
const isAppSessionActive = computed(() => route.name === 'app-session')
|
||||
|
||||
// Show persistent tabs for Apps/Marketplace on mobile
|
||||
const showAppsTabs = computed(() => {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<aside
|
||||
v-show="!chatFullscreen"
|
||||
data-controller-zone="sidebar"
|
||||
class="hidden md:flex w-[256px] flex-shrink-0 relative flex-col z-10"
|
||||
class="hidden md:flex w-[256px] h-screen flex-shrink-0 sticky top-0 relative flex-col z-10"
|
||||
:class="{ 'sidebar-animate': showZoomIn }"
|
||||
>
|
||||
<div class="sidebar-shell">
|
||||
<div class="sidebar-inner flex flex-col min-h-full">
|
||||
<div class="sidebar-inner flex flex-col h-full min-h-0">
|
||||
<div class="sidebar-logo flex items-center gap-3 mb-8 p-6 pb-0 shrink-0">
|
||||
<AnimatedLogo />
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -15,7 +15,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4" :aria-label="t('dashboard.mainNav')">
|
||||
<nav class="sidebar-nav flex-1 min-h-0 overflow-y-auto overscroll-contain space-y-2 px-6 py-4" :aria-label="t('dashboard.mainNav')">
|
||||
<RouterLink
|
||||
v-for="(item, idx) in desktopNavItems"
|
||||
:key="item.path"
|
||||
@@ -74,21 +74,23 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-controller px-6 pb-2 shrink-0">
|
||||
<ControllerIndicator />
|
||||
<CompanionIndicator />
|
||||
</div>
|
||||
|
||||
<!-- Online status -->
|
||||
<div class="px-6 pb-2 shrink-0">
|
||||
<div class="rounded-lg bg-white/5 border border-white/10 px-4 py-2.5">
|
||||
<OnlineStatusPill />
|
||||
<div class="sidebar-bottom shrink-0">
|
||||
<div class="sidebar-controller px-6 pb-2">
|
||||
<ControllerIndicator />
|
||||
<CompanionIndicator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode switcher -->
|
||||
<div class="px-6 pb-6 shrink-0">
|
||||
<ModeSwitcher />
|
||||
<!-- Online status -->
|
||||
<div class="px-6 pb-2">
|
||||
<div class="rounded-lg bg-white/5 border border-white/10 px-4 py-2.5">
|
||||
<OnlineStatusPill />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode switcher -->
|
||||
<div class="px-6 pb-6">
|
||||
<ModeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
.sidebar-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
@@ -85,6 +85,24 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sidebar-bottom {
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.18), transparent 100%);
|
||||
}
|
||||
|
||||
/* Only hide sidebar content when doing the login entrance animation */
|
||||
.sidebar-animate .sidebar-inner {
|
||||
opacity: 0;
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="showFilter = true"
|
||||
class="md:hidden fixed right-4 z-40 w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-back-btn"
|
||||
style="left: auto;"
|
||||
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
@@ -17,10 +16,10 @@
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showFilter"
|
||||
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||
@click.self="closeFilter"
|
||||
>
|
||||
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
|
||||
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">Filter</h2>
|
||||
<button @click="closeFilter" class="text-white/60 hover:text-white transition-colors">
|
||||
|
||||
@@ -78,7 +78,8 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
return [
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
|
||||
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
|
||||
{ id: 'btcpay-server', title: 'BTCPay Server', version: '1.13.7', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: `${R}/btcpayserver:1.13.7`, repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
|
||||
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
|
||||
{ id: 'saleor', title: 'Saleor', version: '3.23', category: 'commerce', description: 'Composable commerce platform with GraphQL API, dashboard, worker, mail testing, and tracing.', icon: '/assets/img/app-icons/saleor.svg', author: 'Saleor', dockerImage: 'ghcr.io/saleor/saleor:3.23', repoUrl: 'https://github.com/saleor/saleor' },
|
||||
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.svg', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
|
||||
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
|
||||
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
|
||||
@@ -96,6 +97,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{ id: 'portainer', title: 'Portainer', version: '2.19.4', description: 'Container management UI. Manage your containerized services through the web.', icon: '/assets/img/app-icons/portainer.webp', author: 'Portainer', dockerImage: `${R}/portainer:latest`, repoUrl: 'https://github.com/portainer/portainer' },
|
||||
{ id: 'uptime-kuma', title: 'Uptime Kuma', version: '1.23.0', description: 'Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.', icon: '/assets/img/app-icons/uptime-kuma.webp', author: 'Uptime Kuma', dockerImage: `${R}/uptime-kuma:1`, repoUrl: 'https://github.com/louislam/uptime-kuma' },
|
||||
{ id: 'tailscale', title: 'Tailscale', version: '1.78.0', description: 'Zero-config VPN. Secure remote access with WireGuard mesh networking.', icon: '/assets/img/app-icons/tailscale.webp', author: 'Tailscale', dockerImage: `${R}/tailscale:stable`, repoUrl: 'https://github.com/tailscale/tailscale' },
|
||||
{ id: 'netbird', title: 'NetBird', version: '0.71.2', description: 'Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN.', icon: '/assets/img/app-icons/netbird.svg', author: 'NetBird', dockerImage: 'docker.io/netbirdio/dashboard:v2.38.0', repoUrl: 'https://github.com/netbirdio/netbird' },
|
||||
{ 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: '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' },
|
||||
@@ -119,6 +121,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server'],
|
||||
saleor: ['saleor'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
@@ -132,6 +135,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
lnd: ['lnd'],
|
||||
filebrowser: ['filebrowser'],
|
||||
tailscale: ['tailscale'],
|
||||
netbird: ['netbird'],
|
||||
ollama: ['ollama'],
|
||||
indeedhub: ['indeedhub'],
|
||||
botfights: ['botfights'],
|
||||
@@ -187,11 +191,11 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
|
||||
const combined = `${id} ${title} ${description}`
|
||||
|
||||
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') || id.includes('lnd') || id.includes('electr') || id.includes('fedimint') || id.includes('cashu') || combined.includes('wallet')) return 'money'
|
||||
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
|
||||
if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
|
||||
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('storage') || id.includes('file') || id.includes('photo') || id.includes('immich') || id.includes('jellyfin') || id.includes('media') || id.includes('vault') || combined.includes('password manager')) return 'data'
|
||||
if (id.includes('home-assistant') || id.includes('homeassistant') || combined.includes('home automation')) return 'home'
|
||||
if (id.includes('nostr') || combined.includes('nostr relay')) return 'nostr'
|
||||
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') || id.includes('proxy') || id.includes('dns') || id.includes('tor') || combined.includes('network')) return 'networking'
|
||||
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') || id.includes('netbird') || id.includes('proxy') || id.includes('dns') || id.includes('tor') || combined.includes('network')) return 'networking'
|
||||
if (id.includes('matrix') || id.includes('mastodon') || id.includes('chat') || id.includes('social') || combined.includes('messaging')) return 'community'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="showModal = true"
|
||||
class="md:hidden fixed right-4 z-40 w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-back-btn"
|
||||
style="left: auto;"
|
||||
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
@@ -16,10 +15,10 @@
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||
@click.self="close()"
|
||||
>
|
||||
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
|
||||
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">{{ t('marketplace.filterByCategory') }}</h2>
|
||||
|
||||
@@ -47,6 +47,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
|
||||
saleor: ['saleor'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
@@ -60,13 +61,14 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
lnd: ['lnd', 'archy-lnd-ui'],
|
||||
filebrowser: ['filebrowser'],
|
||||
tailscale: ['tailscale'],
|
||||
netbird: ['netbird'],
|
||||
ollama: ['ollama'],
|
||||
}
|
||||
|
||||
/** Get app tier classification (matches backend get_app_tier) */
|
||||
export function getAppTier(appId: string): string {
|
||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
|
||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'portainer']
|
||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer', 'saleor']
|
||||
if (core.includes(appId)) return 'core'
|
||||
if (recommended.includes(appId)) return 'recommended'
|
||||
return 'optional'
|
||||
@@ -88,7 +90,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
|
||||
return 'money'
|
||||
}
|
||||
|
||||
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') ||
|
||||
if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') ||
|
||||
id.includes('store') || id.includes('pos') || id.includes('payment') ||
|
||||
combined.includes('merchant') || combined.includes('invoice')) {
|
||||
return 'commerce'
|
||||
@@ -113,7 +115,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
|
||||
return 'nostr'
|
||||
}
|
||||
|
||||
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') ||
|
||||
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') || id.includes('netbird') ||
|
||||
id.includes('proxy') || id.includes('dns') || id.includes('pihole') ||
|
||||
id.includes('adguard') || id.includes('nginx') || id.includes('tor') ||
|
||||
combined.includes('network') || combined.includes('firewall')) {
|
||||
@@ -148,14 +150,26 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{
|
||||
id: 'btcpay-server',
|
||||
title: 'BTCPay Server',
|
||||
version: '1.13.7',
|
||||
version: '2.3.9',
|
||||
description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.',
|
||||
icon: '/assets/img/app-icons/btcpay-server.png',
|
||||
author: 'BTCPay Server Foundation',
|
||||
dockerImage: `${REGISTRY}/btcpayserver:1.13.7`,
|
||||
dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/btcpayserver/btcpayserver'
|
||||
},
|
||||
{
|
||||
id: 'saleor',
|
||||
title: 'Saleor',
|
||||
version: '3.23',
|
||||
category: 'commerce',
|
||||
description: 'Composable commerce platform with GraphQL API, dashboard, worker, mail testing, and tracing.',
|
||||
icon: '/assets/img/app-icons/saleor.svg',
|
||||
author: 'Saleor',
|
||||
dockerImage: 'ghcr.io/saleor/saleor:3.23',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/saleor/saleor'
|
||||
},
|
||||
{
|
||||
id: 'lnd',
|
||||
title: 'LND',
|
||||
@@ -365,6 +379,17 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/tailscale/tailscale'
|
||||
},
|
||||
{
|
||||
id: 'netbird',
|
||||
title: 'NetBird',
|
||||
version: '0.71.2',
|
||||
description: 'Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN.',
|
||||
icon: '/assets/img/app-icons/netbird.svg',
|
||||
author: 'NetBird',
|
||||
dockerImage: 'docker.io/netbirdio/dashboard:v2.38.0',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/netbirdio/netbird'
|
||||
},
|
||||
{
|
||||
id: 'fedimint',
|
||||
title: 'Fedimint',
|
||||
|
||||
@@ -180,6 +180,340 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.74-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.74-alpha</span>
|
||||
<span class="text-xs text-white/40">May 19, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>App-session right panels now re-focus the iframe after load and when the frame area is activated, so scrolling works immediately after selecting an app or switching tabs on shorter screens.</p>
|
||||
<p>NetBird now uses a unified local launch origin on port 8087 that serves the dashboard and proxies auth/API routes to the server, fixing the Unauthenticated and 404 logout/login loop.</p>
|
||||
<p>Existing NetBird installs are repaired during adopt/start by rewriting the config files and creating the missing dashboard/proxy containers while preserving data.</p>
|
||||
<p>Saleor is not in this release yet; it remains pending for separate validation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.73-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.73-alpha</span>
|
||||
<span class="text-xs text-white/40">May 19, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Mobile apps that block iframe embedding now open directly in a browser tab instead of first landing in a broken in-shell webview.</p>
|
||||
<p>App Store search covers all apps while searching, My Apps search can surface matching installable App Store entries, and mobile My Apps/Websites tab switching updates the view reliably.</p>
|
||||
<p>NetBird installs prefer a 100.x tailnet address when available, and app sessions gained iframe auto-focus plus a scroll host for right-frame scrolling.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.72-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.72-alpha</span>
|
||||
<span class="text-xs text-white/40">May 19, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Settings What's New is caught up again. The modal now includes the missing entries for v1.7.68-alpha through v1.7.71-alpha instead of stopping at v1.7.67-alpha.</p>
|
||||
<p>The release lockfile metadata is also kept in sync with the previous release bump.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.71-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.71-alpha</span>
|
||||
<span class="text-xs text-white/40">May 19, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>NetBird stack installs now create the exact persistent data directory before binding it into the server container, fixing the failed install path seen on the test node.</p>
|
||||
<p>NetBird start and restart actions bring up the control-plane server before the dashboard, so lifecycle actions use the correct dependency order.</p>
|
||||
<p>App-session fallbacks now return to My Apps under /dashboard, mobile iframe-blocked apps stay inside Archipelago with an explicit fallback, and installed Gitea containers show the packaged Gitea icon with rounder app icon masks.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.70-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.70-alpha</span>
|
||||
<span class="text-xs text-white/40">May 19, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>NetBird was corrected from the peer/client daemon image to the self-hosted control-plane stack, with a launchable dashboard on port 8087, management/signal/relay server on 8086, and STUN on UDP 3478.</p>
|
||||
<p>Local app launches use direct host ports and carry an explicit dashboard return target, so closing an app session goes back to the launching dashboard screen instead of falling through to browser history or a 404.</p>
|
||||
<p>Mobile launches ignore stale desktop panel state and route into the full app-session webview. The desktop sidebar also keeps top and bottom regions pinned while only the middle navigation scrolls on short screens.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.69-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.69-alpha</span>
|
||||
<span class="text-xs text-white/40">May 19, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>App installs now allow up to 10 minutes for slow initial install RPCs, matching large container pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors.</p>
|
||||
<p>Gitea is now categorized as a known Data app and stays visible during slow registry pulls. Live diagnostics confirmed the Gitea container came up healthy on port 3001 after the frontend had previously timed out too early.</p>
|
||||
<p>NetBird was added to the catalog as a recommended networking app, and the Archipelago terminal includes nano on new installs and existing-node self-update fallback.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.68-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.68-alpha</span>
|
||||
<span class="text-xs text-white/40">May 19, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>BTCPay Server now ships on the official btcpayserver image, fixing the plugin catalog crash caused by newer plugin dependency metadata while preserving existing data and Postgres databases.</p>
|
||||
<p>BTCPay health checks no longer require curl inside the container, and Nginx Proxy Manager certificate challenge handling now avoids hijacking local API traffic while syncing issued public proxy hosts into host nginx.</p>
|
||||
<p>System Update confirmation and mirror modals now cover the whole app, app-session close returns to the previous dashboard screen, and mobile app launches stay inside Archipelago's app-session webview.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.67-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.67-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Home status cards are calmer and more honest now. System, VPN, Bitcoin, and FIPS values keep their last known good state while route changes or short RPC failures are in flight, so the dashboard no longer flashes false "not configured" or "not running" states during normal refreshes.</p>
|
||||
<p>Home, Web5 Monitoring, and the full Monitoring page now agree on the headline CPU, memory, disk, uptime, and load numbers. The UI uses one live system-stat snapshot for the visible cards while keeping the Monitoring page's historical store for charts, alerts, and container history.</p>
|
||||
<p>The missing What's New history is filled in through this release, including every curated entry from v1.7.44-alpha through v1.7.66-alpha.</p>
|
||||
<p>Bitcoin lifecycle specs are aligned again across Rust, first boot, and reconcile. Bitcoin Core/Knots get the intended memory headroom on normal hosts, and pruned Knots uses a larger dbcache when the node has enough RAM, improving IBD throughput without raising pressure on low-memory machines.</p>
|
||||
<p>ElectrumX/electrs lifecycle specs now use the same memory policy everywhere, reducing drift between fresh installs, app lifecycle actions, and reconciliation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.66-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.66-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nginx Proxy Manager stale-port repair now catches stopped or Created Podman records that still remember old port mappings. That means a stuck record can be removed and recreated before it blocks the current NPM ports.</p>
|
||||
<p>Live recovery on the field node preserved the existing Nginx Proxy Manager data directory while recreating only the stale container metadata with the current 8081, 8084, and 8444 host ports.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.65-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.65-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Orchestrator-backed app starts now run the same pre-start repair path as the legacy Podman start flow. Nginx Proxy Manager can clean up stale port metadata before the orchestrator tries to bring it online.</p>
|
||||
<p>Diagnostics confirmed host nginx was healthy while Nginx Proxy Manager itself had no listeners on its expected ports, narrowing the outage to NPM container lifecycle repair instead of the system proxy.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.64-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.64-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Authenticated update applies are no longer throttled so aggressively during troubleshooting. The System Update page now allows legitimate retry flows without immediately running into 429 Too Many Requests.</p>
|
||||
<p>The release still includes the corrected backend rebuild protection, so OTA artifacts are built from the fresh Rust binary instead of an older compiled version.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.63-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.63-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The release script now rebuilds the backend after bumping the version and before hashing artifacts. OTA manifests no longer point at a stale backend binary.</p>
|
||||
<p>This corrected the previous stale-artifact issue and carries the Nginx Proxy Manager stale-port repair in a backend binary that nodes can actually install and run.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.62-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.62-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nginx Proxy Manager start and restart now repair stale Podman containers that still publish the admin UI on host port 81, which conflicts with host nginx on updated nodes.</p>
|
||||
<p>The repair recreates only NPM container metadata while preserving its persistent data and using the current 8081, 8084, and 8444 host mappings.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.61-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.61-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Multi-container stack installs stay in Installing for up to 20 minutes while dependency containers are being pulled and prepared. BTCPay no longer appears to vanish after two minutes while Postgres and NBXplorer are still being created.</p>
|
||||
<p>Lifecycle stale-state recovery remains short for start, stop, restart, update, and removal actions, so genuinely wedged operations still clear quickly.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.60-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.60-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Meshtastic serial detection now rejects malformed handshakes and skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks.</p>
|
||||
<p>Meshtastic config sync sends the correct protobuf wire type, allowing node-info and contact ingestion to work reliably. The mesh udev rule also stops claiming every ttyACM device and now targets known mesh adapters/vendors.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.59-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.59-alpha</span>
|
||||
<span class="text-xs text-white/40">May 17, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Mobile app launching now keeps known container apps inside Archipelago's app-session flow instead of forcing desktop-only new-tab behavior.</p>
|
||||
<p>App sessions on mobile respect the status-bar safe area, while the fullscreen backdrop remains edge-to-edge. The Apps page also gained a compact sideload button and modal for trusted Docker images.</p>
|
||||
<p>Sideloaded app title and description metadata now persist through backend app config, and Meshtastic contact discovery retries config sync when the radio contact cache is empty.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.58-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.58-alpha</span>
|
||||
<span class="text-xs text-white/40">May 17, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Mesh networking now supports Meshtastic radios over the serial API alongside existing MeshCore Companion USB radios. The shared listener probes preferred and auto-detected serial paths for both firmware families.</p>
|
||||
<p>Meshtastic text packets are translated into Archipelago's existing mesh frame pipeline, and Meshtastic node information appears as normal mesh contacts using stable synthetic public keys.</p>
|
||||
<p>Frontend OTA behavior improved: hashed assets no longer fall back to index.html, the HTML shell revalidates on every load, and runtime promotion installs the bundled nginx config on update.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.57-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.57-alpha</span>
|
||||
<span class="text-xs text-white/40">May 17, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nginx Proxy Manager now avoids privileged rootless Podman host port 81, preferring 8081 for its admin UI while host nginx keeps a compatibility proxy on :81 for stale launch buttons.</p>
|
||||
<p>App installs allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are occupied. Portainer-created launchable containers now appear in a Websites tab through their discovered host ports.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.56-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.56-alpha</span>
|
||||
<span class="text-xs text-white/40">May 15, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fresh installs include the full Wi-Fi userspace stack and grant the Archipelago service user NetworkManager PolicyKit access, so Intel Wi-Fi scanning and connection changes work from the web UI.</p>
|
||||
<p>Container health and reconciliation are more honest and resilient: stale alerts clear, Stopping containers can be recreated, health states come from Podman, and drifted Quadlet settings trigger proper restarts.</p>
|
||||
<p>Bitcoin Knots and ElectrumX get more CPU and memory headroom, LND helpers tolerate container-owned files better, and the screensaver stays out of media-heavy app sessions.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.55-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.55-alpha</span>
|
||||
<span class="text-xs text-white/40">May 13, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Container reconcile can force-recreate Podman records stuck in Stopping while preserving bind-mounted app data, recovering wedged containers automatically.</p>
|
||||
<p>Lifecycle audits on the hardened container layer passed on the validation node, with direct app probes returning healthy responses.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.54-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.54-alpha</span>
|
||||
<span class="text-xs text-white/40">May 6, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Existing installs now self-repair nginx backend proxy locations for Bitcoin status and app catalog calls, including hosts where the active config is a copied file rather than a symlink.</p>
|
||||
<p>LND UI is consistently served on port 18083 across first boot, Tor config, Quadlet reconciliation, OTA runtime payloads, and ISO scripts. OTA frontend tarballs also carry a cleaner runtime payload so startup promotion does not reintroduce stale host assets.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.53-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.53-alpha</span>
|
||||
<span class="text-xs text-white/40">May 5, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between bitcoin.conf and container command arguments, fixing startup failures from RPC endpoint binding conflicts.</p>
|
||||
<p>Legacy Bitcoin healthchecks no longer depend on bitcoin-cli being present in current images. Update checks now prefer manifest OTA releases over stale git remotes unless git updates are explicitly enabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.52-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.52-alpha</span>
|
||||
<span class="text-xs text-white/40">May 5, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Tailscale now launches its local installed web UI on port 8240 and starts tailscaled before tailscale web, fixing unreachable installs after container creation.</p>
|
||||
<p>Grafana lifecycle actions repair missing rootless host listeners on port 3000, and Debian 13 install paths pull security updates from trixie-security during image/install creation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.49-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.49-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 30, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing stale connection failures while the node warms up.</p>
|
||||
<p>ElectrumX exposes indexed height, local Bitcoin height, known headers, status, and progress source, making long initial indexing states readable. Bitcoin Core and Bitcoin Knots are now mutually exclusive variants with corrected install conflict handling.</p>
|
||||
<p>IndeeHub launches only on its direct web UI port, and BTCPay/NBXplorer Postgres environment formatting was fixed to avoid malformed connection strings.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.48-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.48-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 29, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>archipelago.service now creates /run/containers before startup, fixing systemd mount-namespace failures on nodes where that runtime directory did not already exist.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.47-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.47-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 29, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin Knots/Core sync is significantly faster: containers now use every available core for script verification and have 8 GB of memory so the 4 GB UTXO cache has headroom.</p>
|
||||
<p>ElectrumX initial indexing is faster too, with CPU caps removed, 4 GB of container memory, and a 3 GB internal cache.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.46-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.46-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 29, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Health monitoring no longer pages auto-restart failures for orphaned containers left behind after Bitcoin variant switches.</p>
|
||||
<p>Apps no longer disappear from My Apps when an install fails, and multi-image stack pull progress now advances during the download phase instead of sticking at 20%.</p>
|
||||
<p>Several docker.io images were mirrored into Archipelago registries, reducing first-boot install dependency on Docker Hub.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.45-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.45-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 29, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin RPC auth is durable across container restart, image update, and reboot. The dashboard no longer fails because registry-pulled images shipped stale baked-in credentials.</p>
|
||||
<p>Multi-container apps show real install progress, app cards stay visible while containers are being created, IndeedHub installs cleanly on fresh nodes, and Tailscale install no longer fails from a malformed command.</p>
|
||||
<p>The installer now allocates swap on the encrypted data partition, capped at 8 GB, so image builds and memory spikes are less likely to OOM the system.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.44-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.44-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 28, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Container orchestration migration and release hardening continued, including OTA synchronization for rebuilt UI containers and aligned LND UI port handling across runtime specs.</p>
|
||||
<p>Release packaging moved toward tarball-only artifacts with archived ISO build recipes, keeping update payloads focused on the files existing nodes need.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.43-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -71,22 +71,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||
|
||||
const cpuPercent = ref(0)
|
||||
const memUsed = ref(0)
|
||||
const memTotal = ref(0)
|
||||
const diskUsed = ref(0)
|
||||
const diskTotal = ref(0)
|
||||
const uptimeSecs = ref(0)
|
||||
const homeStatus = useHomeStatusStore()
|
||||
|
||||
const memPercent = computed(() => memTotal.value > 0 ? (memUsed.value / memTotal.value) * 100 : 0)
|
||||
const diskPercent = computed(() => diskTotal.value > 0 ? (diskUsed.value / diskTotal.value) * 100 : 0)
|
||||
const cpuPercent = computed(() => homeStatus.stats.cpuPercent)
|
||||
const memPercent = computed(() => homeStatus.stats.memPercent)
|
||||
const diskPercent = computed(() => homeStatus.stats.diskPercent)
|
||||
|
||||
const uptimeDisplay = computed(() => {
|
||||
const s = uptimeSecs.value
|
||||
const s = homeStatus.stats.uptimeSecs
|
||||
if (s === 0) return '--'
|
||||
const days = Math.floor(s / 86400)
|
||||
const hours = Math.floor((s % 86400) / 3600)
|
||||
@@ -102,22 +98,7 @@ function barColor(pct: number): string {
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
cpu_usage_percent: number
|
||||
mem_used_bytes: number
|
||||
mem_total_bytes: number
|
||||
disk_used_bytes: number
|
||||
disk_total_bytes: number
|
||||
uptime_secs: number
|
||||
}>({ method: 'system.stats' })
|
||||
cpuPercent.value = res.cpu_usage_percent
|
||||
memUsed.value = res.mem_used_bytes
|
||||
memTotal.value = res.mem_total_bytes
|
||||
diskUsed.value = res.disk_used_bytes
|
||||
diskTotal.value = res.disk_total_bytes
|
||||
uptimeSecs.value = res.uptime_secs
|
||||
} catch { /* unavailable */ }
|
||||
await homeStatus.refreshSystemStats()
|
||||
}
|
||||
|
||||
let refreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
+17
-18
@@ -1,29 +1,28 @@
|
||||
{
|
||||
"version": "1.7.57-alpha",
|
||||
"release_date": "2026-05-17",
|
||||
"version": "1.7.75-alpha",
|
||||
"release_date": "2026-05-19",
|
||||
"changelog": [
|
||||
"Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.",
|
||||
"App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.",
|
||||
"Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.",
|
||||
"Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.",
|
||||
"Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory."
|
||||
"Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger.",
|
||||
"Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy.",
|
||||
"Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens.",
|
||||
"Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.57-alpha",
|
||||
"new_version": "1.7.57-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago",
|
||||
"sha256": "e96d657c28f84c72c1358fdbbc014184530763341a2d02242b3a479f36dc2aef",
|
||||
"size_bytes": 42647224
|
||||
"current_version": "1.7.75-alpha",
|
||||
"new_version": "1.7.75-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.75-alpha/archipelago",
|
||||
"sha256": "d83c88e21df52b59fbdcadb15b7e968c6ac17f5cbab94a9dd734ccc6637c9f7f",
|
||||
"size_bytes": 43061552
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.57-alpha.tar.gz",
|
||||
"current_version": "1.7.57-alpha",
|
||||
"new_version": "1.7.57-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago-frontend-1.7.57-alpha.tar.gz",
|
||||
"sha256": "a09c1507afb4b7e9f5279b92215e92ebc0b86155ae53ac2c0baaecd07522c7ee",
|
||||
"size_bytes": 78322316
|
||||
"name": "archipelago-frontend-1.7.75-alpha.tar.gz",
|
||||
"current_version": "1.7.75-alpha",
|
||||
"new_version": "1.7.75-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.75-alpha/archipelago-frontend-1.7.75-alpha.tar.gz",
|
||||
"sha256": "1aa564b99c64219005693cebf9be6cc148e73585b81a27675dd27b9d09bc6ab8",
|
||||
"size_bytes": 166484762
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+17
-18
@@ -1,29 +1,28 @@
|
||||
{
|
||||
"version": "1.7.57-alpha",
|
||||
"release_date": "2026-05-17",
|
||||
"version": "1.7.75-alpha",
|
||||
"release_date": "2026-05-19",
|
||||
"changelog": [
|
||||
"Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.",
|
||||
"App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.",
|
||||
"Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.",
|
||||
"Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.",
|
||||
"Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory."
|
||||
"Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger.",
|
||||
"Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy.",
|
||||
"Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens.",
|
||||
"Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.57-alpha",
|
||||
"new_version": "1.7.57-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago",
|
||||
"sha256": "e96d657c28f84c72c1358fdbbc014184530763341a2d02242b3a479f36dc2aef",
|
||||
"size_bytes": 42647224
|
||||
"current_version": "1.7.75-alpha",
|
||||
"new_version": "1.7.75-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.75-alpha/archipelago",
|
||||
"sha256": "d83c88e21df52b59fbdcadb15b7e968c6ac17f5cbab94a9dd734ccc6637c9f7f",
|
||||
"size_bytes": 43061552
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.57-alpha.tar.gz",
|
||||
"current_version": "1.7.57-alpha",
|
||||
"new_version": "1.7.57-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago-frontend-1.7.57-alpha.tar.gz",
|
||||
"sha256": "a09c1507afb4b7e9f5279b92215e92ebc0b86155ae53ac2c0baaecd07522c7ee",
|
||||
"size_bytes": 78322316
|
||||
"name": "archipelago-frontend-1.7.75-alpha.tar.gz",
|
||||
"current_version": "1.7.75-alpha",
|
||||
"new_version": "1.7.75-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.75-alpha/archipelago-frontend-1.7.75-alpha.tar.gz",
|
||||
"sha256": "1aa564b99c64219005693cebf9be6cc148e73585b81a27675dd27b9d09bc6ab8",
|
||||
"size_bytes": 166484762
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Validate releases/manifest.json:
|
||||
# - version matches core/archipelago/Cargo.toml
|
||||
# - changelog is non-empty (release notes are mandatory per product policy)
|
||||
# - changelog contains curated release notes, not raw git log output
|
||||
# - every component's download_url exists on disk and matches sha256/size
|
||||
#
|
||||
# Run on every push from CI, and also locally before publishing a release:
|
||||
@@ -30,12 +30,42 @@ if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then
|
||||
fi
|
||||
ok "version matches: $MANIFEST_VERSION"
|
||||
|
||||
# Release notes mandatory — ships stuff nobody can read otherwise.
|
||||
CHANGELOG_COUNT=$(python3 -c "import json; print(len(json.load(open('$MANIFEST'))['changelog']))")
|
||||
if [ "$CHANGELOG_COUNT" -eq 0 ]; then
|
||||
fail "changelog is empty — every release MUST have release notes"
|
||||
fi
|
||||
ok "changelog has $CHANGELOG_COUNT lines"
|
||||
# Release notes mandatory — ships stuff nobody can read otherwise. Require
|
||||
# curated user/operator-facing notes and reject raw `git log --oneline` output.
|
||||
NOTES_CHECK=$(python3 - "$MANIFEST" <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
manifest = sys.argv[1]
|
||||
notes = json.load(open(manifest)).get("changelog", [])
|
||||
if len(notes) < 3:
|
||||
print(f"FAIL: changelog has {len(notes)} lines; need at least 3 curated release-note bullets")
|
||||
sys.exit(0)
|
||||
|
||||
bad = []
|
||||
for note in notes:
|
||||
text = str(note).strip()
|
||||
if not text:
|
||||
bad.append("empty release-note entry")
|
||||
if len(text) < 40:
|
||||
bad.append(f"too short: {text!r}")
|
||||
if re.match(r"^[0-9a-f]{7,40}\s+", text):
|
||||
bad.append(f"raw commit hash entry: {text!r}")
|
||||
if re.match(r"^(feat|fix|chore|docs|test|refactor|build|ci|perf)(\([^)]+\))?:\s", text):
|
||||
bad.append(f"raw conventional-commit entry: {text!r}")
|
||||
|
||||
if bad:
|
||||
print("FAIL: release notes must be curated user/operator-facing bullets, not raw git log lines:\n" + "\n".join(bad))
|
||||
else:
|
||||
print(f"OK: changelog has {len(notes)} curated lines")
|
||||
PY
|
||||
)
|
||||
case "$NOTES_CHECK" in
|
||||
OK:*) ok "${NOTES_CHECK#OK: }" ;;
|
||||
FAIL:*) fail "${NOTES_CHECK#FAIL: }" ;;
|
||||
*) fail "unexpected release-note validation output: $NOTES_CHECK" ;;
|
||||
esac
|
||||
|
||||
# Each component: the artifact on disk under releases/v<version>/ must match
|
||||
# the declared sha256 and size_bytes.
|
||||
|
||||
@@ -510,6 +510,22 @@ fix_missing_rootless_ports() {
|
||||
$fixed && return 0 || return 1
|
||||
}
|
||||
|
||||
# ── Fix 11: Nginx Proxy Manager public host bridge ───────────
|
||||
# Host nginx owns public 80/443 on Archipelago. Mirror NPM proxy hosts into
|
||||
# host nginx so issued certs and public traffic reach the intended upstreams.
|
||||
fix_npm_public_hosts() {
|
||||
local script="/opt/archipelago/scripts/sync-npm-public-hosts.sh"
|
||||
[ -x "$script" ] || script="$SCRIPT_DIR/sync-npm-public-hosts.sh"
|
||||
[ -x "$script" ] || return 1
|
||||
[ -f /var/lib/archipelago/nginx-proxy-manager/data/database.sqlite ] || return 1
|
||||
|
||||
if "$script" >/dev/null 2>&1; then
|
||||
log "Synced Nginx Proxy Manager public hosts into host nginx"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────
|
||||
|
||||
# If remote host provided, run via SSH
|
||||
@@ -539,6 +555,7 @@ run_fix "exit-127" fix_exit_127
|
||||
run_fix "netns-egress" fix_rootless_netns_egress
|
||||
run_fix "stopped-core" fix_stopped_core_containers
|
||||
run_fix "rootless-ports" fix_missing_rootless_ports
|
||||
run_fix "npm-public-hosts" fix_npm_public_hosts
|
||||
|
||||
echo ""
|
||||
if [ $FIXES_APPLIED -gt 0 ]; then
|
||||
|
||||
@@ -175,9 +175,11 @@ load_spec_bitcoin-knots() {
|
||||
SPEC_TIER="1"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/bitcoin"
|
||||
SPEC_DATA_UID="100101:100101"
|
||||
local btc_dbcache=4096
|
||||
[ "${LOW_MEM:-false}" = "true" ] && btc_dbcache=2048
|
||||
# Dynamic: prune on small disk
|
||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||
SPEC_CUSTOM_ARGS="-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"
|
||||
SPEC_CUSTOM_ARGS="-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=${btc_dbcache} -par=0 -maxconnections=125"
|
||||
else
|
||||
SPEC_CUSTOM_ARGS="-server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125"
|
||||
fi
|
||||
@@ -271,7 +273,7 @@ load_spec_btcpay-server() {
|
||||
SPEC_PORTS="23000:49392"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/btcpay:/datadir"
|
||||
SPEC_MEMORY="$(mem_limit btcpay-server)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:49392/ || exit 1"
|
||||
SPEC_HEALTH_CMD="bash -ec '</dev/tcp/127.0.0.1/49392'"
|
||||
SPEC_ENV="ASPNETCORE_URLS=http://0.0.0.0:49392 BTCPAY_PROTOCOL=http BTCPAY_HOST=$HOST_IP:23000 BTCPAY_CHAINS=btc BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 BTCPAY_BTCRPCUSER=$BITCOIN_RPC_USER BTCPAY_BTCRPCPASSWORD=$BITCOIN_RPC_PASS BTCPAY_POSTGRES=Username=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=btcpay"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/btcpay"
|
||||
|
||||
+44
-47
@@ -28,11 +28,12 @@ for arg in "$@"; do
|
||||
echo "Steps performed:"
|
||||
echo " 1. Validate version format (SemVer)"
|
||||
echo " 2. Bump version in Cargo.toml and package.json"
|
||||
echo " 3. Build frontend"
|
||||
echo " 4. Generate changelog from git log"
|
||||
echo " 5. Create release manifest"
|
||||
echo " 6. Commit version bump"
|
||||
echo " 7. Create git tag v{VERSION}"
|
||||
echo " 3. Build backend"
|
||||
echo " 4. Build frontend"
|
||||
echo " 5. Generate changelog from git log"
|
||||
echo " 6. Create release manifest"
|
||||
echo " 7. Commit version bump"
|
||||
echo " 8. Create git tag v{VERSION}"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --dry-run Show what would be done without making changes"
|
||||
@@ -95,11 +96,12 @@ if $DRY_RUN; then
|
||||
echo "[DRY RUN] Would perform the following:"
|
||||
echo " 1. Update core/archipelago/Cargo.toml version to $VERSION"
|
||||
echo " 2. Update neode-ui/package.json version to $VERSION"
|
||||
echo " 3. Build frontend (npm run build)"
|
||||
echo " 4. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
|
||||
echo " 5. Create release manifest"
|
||||
echo " 6. Commit: 'chore: release v${VERSION}'"
|
||||
echo " 7. Tag: v${VERSION}"
|
||||
echo " 3. Build backend (cargo build --release -p archipelago)"
|
||||
echo " 4. Build frontend (npm run build)"
|
||||
echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
|
||||
echo " 6. Create release manifest"
|
||||
echo " 7. Commit: 'chore: release v${VERSION}'"
|
||||
echo " 8. Tag: v${VERSION}"
|
||||
echo ""
|
||||
echo "After this script, you would:"
|
||||
echo " - Push: git push && git push --tags"
|
||||
@@ -123,49 +125,46 @@ cd "$PROJECT_ROOT/neode-ui"
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version 2>/dev/null || true
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "[3/7] Building frontend..."
|
||||
echo "[3/8] Building backend..."
|
||||
cd "$PROJECT_ROOT/core"
|
||||
cargo build --release -p archipelago
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "[4/8] Building frontend..."
|
||||
cd "$PROJECT_ROOT/neode-ui"
|
||||
npm run build 2>&1 | tail -3
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "[4/7] Generating changelog..."
|
||||
# Get commits since last tag (or last 50 if no tags)
|
||||
LAST_TAG=$(git -C "$PROJECT_ROOT" describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [ -n "$LAST_TAG" ]; then
|
||||
GIT_LOG=$(git -C "$PROJECT_ROOT" log "$LAST_TAG"..HEAD --oneline --no-merges 2>/dev/null || echo "")
|
||||
else
|
||||
GIT_LOG=$(git -C "$PROJECT_ROOT" log --oneline --no-merges -50 2>/dev/null || echo "")
|
||||
fi
|
||||
echo "[5/8] Validating curated changelog..."
|
||||
|
||||
# Create/update CHANGELOG.md entry
|
||||
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"
|
||||
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||
CHANGELOG_ENTRY="## v${VERSION} (${RELEASE_DATE})
|
||||
|
||||
$GIT_LOG
|
||||
"
|
||||
|
||||
if [ -f "$CHANGELOG_FILE" ]; then
|
||||
# Prepend new entry after the first line (title)
|
||||
EXISTING=$(cat "$CHANGELOG_FILE")
|
||||
FIRST_LINE=$(head -1 "$CHANGELOG_FILE")
|
||||
REST=$(tail -n +2 "$CHANGELOG_FILE")
|
||||
echo "$FIRST_LINE" > "$CHANGELOG_FILE"
|
||||
echo "" >> "$CHANGELOG_FILE"
|
||||
echo "$CHANGELOG_ENTRY" >> "$CHANGELOG_FILE"
|
||||
echo "$REST" >> "$CHANGELOG_FILE"
|
||||
else
|
||||
echo "# Changelog" > "$CHANGELOG_FILE"
|
||||
echo "" >> "$CHANGELOG_FILE"
|
||||
echo "$CHANGELOG_ENTRY" >> "$CHANGELOG_FILE"
|
||||
if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE"; then
|
||||
echo "Error: CHANGELOG.md must already contain curated notes for v${VERSION}."
|
||||
echo "Add a section like:"
|
||||
echo ""
|
||||
echo "## v${VERSION} (${RELEASE_DATE})"
|
||||
echo ""
|
||||
echo "- User/operator-facing change ..."
|
||||
echo "- Another concrete change ..."
|
||||
echo "- Validation or operational note ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[5/7] Creating release manifest..."
|
||||
echo "[6/8] Creating release manifest..."
|
||||
mkdir -p "$PROJECT_ROOT/releases"
|
||||
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
|
||||
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
|
||||
|
||||
echo "[6/7] Committing version bump..."
|
||||
echo "[6b/8] Staging release artifacts for validation..."
|
||||
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
||||
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
|
||||
mkdir -p "$VERSION_DIR"
|
||||
install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago"
|
||||
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
||||
"$SCRIPT_DIR/check-release-manifest.sh"
|
||||
|
||||
echo "[7/8] Committing version bump..."
|
||||
git -C "$PROJECT_ROOT" add \
|
||||
core/archipelago/Cargo.toml \
|
||||
neode-ui/package.json \
|
||||
@@ -177,7 +176,7 @@ git -C "$PROJECT_ROOT" add \
|
||||
|
||||
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
|
||||
|
||||
echo "[7/7] Creating git tag..."
|
||||
echo "[8/8] Creating git tag..."
|
||||
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
|
||||
|
||||
echo ""
|
||||
@@ -188,15 +187,13 @@ echo " - Version bumped in Cargo.toml and package.json"
|
||||
echo " - Changelog updated in CHANGELOG.md"
|
||||
echo " - Release manifest: releases/manifest.json"
|
||||
echo " - Release manifest copy: release-manifest.json"
|
||||
echo " - Staged artifacts: releases/v${VERSION}/"
|
||||
echo " - Git tag: v${VERSION}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Review: git log --oneline -5"
|
||||
echo " 2. Create Gitea release v${VERSION} and upload artifacts:"
|
||||
echo " archipelago"
|
||||
echo " archipelago-frontend-${VERSION}.tar.gz"
|
||||
echo " 3. Push to gitea-local and gitea-vps2:"
|
||||
echo " git push gitea-local main --tags && git push gitea-vps2 main --tags"
|
||||
echo " 4. Verify manifest is live on both mirrors:"
|
||||
echo " 2. Publish commits, tag, artifacts, and verify download URLs:"
|
||||
echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2"
|
||||
echo " 3. Verify manifest is live on both mirrors:"
|
||||
echo " curl -fsS http://localhost:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
echo " curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
|
||||
@@ -625,7 +625,7 @@ deploy_node() {
|
||||
echo ' Creating btcpay-server...'
|
||||
sudo mkdir -p /var/lib/archipelago/btcpay
|
||||
\$DOCKER run -d --name btcpay-server --restart unless-stopped \$NET_OPT \
|
||||
--health-cmd 'curl -sf http://localhost:49392/' --health-interval=30s --health-timeout=10s --health-retries=3 \
|
||||
--health-cmd "bash -ec '</dev/tcp/127.0.0.1/49392'" --health-interval=30s --health-timeout=10s --health-retries=3 \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||
--security-opt no-new-privileges:true \
|
||||
-p 23000:49392 -v /var/lib/archipelago/btcpay:/datadir \
|
||||
@@ -909,7 +909,8 @@ LNDCONF
|
||||
if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx nginx-proxy-manager; then
|
||||
\$DOCKER start nginx-proxy-manager 2>/dev/null || true
|
||||
else
|
||||
sudo mkdir -p /var/lib/archipelago/nginx-proxy-manager/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt
|
||||
sudo mkdir -p /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge /var/lib/archipelago/nginx-proxy-manager/letsencrypt
|
||||
sudo chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null || true
|
||||
\$DOCKER run -d --name nginx-proxy-manager --restart unless-stopped \
|
||||
--health-cmd 'curl -sf http://localhost:81/' --health-interval=30s --health-timeout=5s --health-retries=3 \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
|
||||
|
||||
@@ -481,10 +481,13 @@ log "archy-net network ready"
|
||||
log "Fixing rootless podman UID mapping..."
|
||||
# Containers running as root (UID 0 → host UID 100000)
|
||||
for dir in lnd electrumx btcpay nbxplorer jellyfin vaultwarden \
|
||||
home-assistant fedimint fedimint-gateway photoprism ollama filebrowser \
|
||||
nextcloud uptime-kuma nginx-proxy-manager portainer nostr-rs-relay; do
|
||||
home-assistant fedimint fedimint-gateway photoprism ollama filebrowser \
|
||||
nextcloud uptime-kuma portainer nostr-rs-relay; do
|
||||
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100000:100000 "/var/lib/archipelago/$dir" 2>/dev/null
|
||||
done
|
||||
# Nginx Proxy Manager runs as root in the rootless user namespace, which maps to
|
||||
# the archipelago user on host bind mounts. Keep certbot's webroot writable.
|
||||
[ -d /var/lib/archipelago/nginx-proxy-manager ] && chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null
|
||||
# Bitcoin Knots: container UID 101 → host UID 100101
|
||||
[ -d /var/lib/archipelago/bitcoin ] && chown -R 100101:100101 /var/lib/archipelago/bitcoin 2>/dev/null
|
||||
# Postgres: container UID 70 → host UID 100070
|
||||
@@ -508,13 +511,13 @@ LOW_MEM=false
|
||||
|
||||
mem_limit() {
|
||||
case "$1" in
|
||||
bitcoin-knots) $LOW_MEM && echo "2g" || echo "4g";;
|
||||
bitcoin|bitcoin-core|bitcoin-knots) $LOW_MEM && echo "4g" || echo "8g";;
|
||||
cryptpad) echo "512m";;
|
||||
ollama) $LOW_MEM && echo "1g" || echo "4g";;
|
||||
lnd) echo "512m";;
|
||||
electrumx) echo "1g";;
|
||||
electrumx|mempool-electrs|electrs) echo "4g";;
|
||||
nextcloud) echo "1g";;
|
||||
btcpay-server) echo "1g";;
|
||||
btcpay-server|btcpayserver) echo "1g";;
|
||||
homeassistant) echo "512m";;
|
||||
fedimint) echo "512m";;
|
||||
fedimint-gateway) echo "512m";;
|
||||
@@ -588,7 +591,11 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|arch
|
||||
[ -z "$DISK_GB" ] && DISK_GB=$(df --output=size -BG / 2>/dev/null | tail -1 | tr -dc '0-9')
|
||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||
BTC_EXTRA_ARGS="-prune=550"
|
||||
BTC_DBCACHE=2048
|
||||
if [ "$LOW_MEM" = "true" ]; then
|
||||
BTC_DBCACHE=2048
|
||||
else
|
||||
BTC_DBCACHE=4096
|
||||
fi
|
||||
log " Small disk (${DISK_GB}GB) — enabling pruning"
|
||||
else
|
||||
BTC_EXTRA_ARGS="-txindex=1"
|
||||
@@ -871,7 +878,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then
|
||||
log "Creating BTCPay Server..."
|
||||
mkdir -p /var/lib/archipelago/btcpay/Main
|
||||
$DOCKER run -d --name btcpay-server --restart unless-stopped \
|
||||
--health-cmd="curl -sf http://localhost:49392/ || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
|
||||
--health-cmd="bash -ec '</dev/tcp/127.0.0.1/49392'" --health-interval=120s --health-timeout=5s --health-retries=3 \
|
||||
--memory=$(mem_limit btcpay-server) --network archy-net --network-alias btcpay-server \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||
--security-opt no-new-privileges:true \
|
||||
@@ -1177,6 +1184,8 @@ track_container "filebrowser"
|
||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nginx-proxy-manager; then
|
||||
log "Creating Nginx Proxy Manager..."
|
||||
mkdir -p /var/lib/archipelago/nginx-proxy-manager/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt
|
||||
mkdir -p /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge
|
||||
chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null || true
|
||||
NPM_ADMIN_PORT=$(alloc_port nginx-proxy-manager 8081)
|
||||
NPM_HTTP_PORT=$(alloc_port nginx-proxy-manager-http 8084)
|
||||
NPM_HTTPS_PORT=$(alloc_port nginx-proxy-manager-https 8444)
|
||||
|
||||
@@ -24,7 +24,7 @@ MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.0.0"
|
||||
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
|
||||
|
||||
# BTCPay
|
||||
BTCPAY_IMAGE="$ARCHY_REGISTRY/btcpayserver:1.13.7"
|
||||
BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.3.9"
|
||||
NBXPLORER_IMAGE="$ARCHY_REGISTRY/nbxplorer:2.6.0"
|
||||
POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
|
||||
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
|
||||
@@ -48,9 +48,20 @@ PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:latest"
|
||||
|
||||
# Networking
|
||||
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
|
||||
NETBIRD_DASHBOARD_IMAGE="docker.io/netbirdio/dashboard:v2.38.0"
|
||||
NETBIRD_SERVER_IMAGE="docker.io/netbirdio/netbird-server:0.71.2"
|
||||
NETBIRD_PROXY_IMAGE="docker.io/library/nginx:1.27-alpine"
|
||||
ALPINE_TOR_IMAGE="$ARCHY_REGISTRY/alpine-tor:0.4.8.13"
|
||||
ADGUARDHOME_IMAGE="$ARCHY_REGISTRY/adguardhome:v0.107.55"
|
||||
|
||||
# Saleor stack
|
||||
SALEOR_API_IMAGE="ghcr.io/saleor/saleor:3.23"
|
||||
SALEOR_DASHBOARD_IMAGE="ghcr.io/saleor/saleor-dashboard:3.23"
|
||||
SALEOR_POSTGRES_IMAGE="docker.io/library/postgres:15-alpine"
|
||||
SALEOR_VALKEY_IMAGE="docker.io/valkey/valkey:8.1-alpine"
|
||||
SALEOR_JAEGER_IMAGE="docker.io/jaegertracing/jaeger:latest"
|
||||
SALEOR_MAILPIT_IMAGE="docker.io/axllent/mailpit:latest"
|
||||
|
||||
# Fedimint
|
||||
FEDIMINT_IMAGE="$ARCHY_REGISTRY/fedimintd:v0.10.0"
|
||||
FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0"
|
||||
|
||||
@@ -96,17 +96,17 @@ mem_limit() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Built-in defaults (keep in sync with first-boot-containers.sh)
|
||||
# Built-in defaults (keep in sync with first-boot-containers.sh and Rust package config)
|
||||
local low="${LOW_MEM:-false}"
|
||||
case "$name" in
|
||||
bitcoin-knots) $low && echo "1g" || echo "2g" ;;
|
||||
bitcoin|bitcoin-core|bitcoin-knots) $low && echo "4g" || echo "8g" ;;
|
||||
onlyoffice) $low && echo "1g" || echo "2g" ;;
|
||||
ollama) $low && echo "1g" || echo "4g" ;;
|
||||
lnd) echo "512m" ;;
|
||||
electrumx) echo "1g" ;;
|
||||
electrumx|mempool-electrs|electrs) echo "4g" ;;
|
||||
nextcloud) echo "1g" ;;
|
||||
immich_server) echo "1g" ;;
|
||||
btcpay-server) echo "1g" ;;
|
||||
btcpay-server|btcpayserver) echo "1g" ;;
|
||||
homeassistant) echo "512m" ;;
|
||||
fedimint) echo "512m" ;;
|
||||
fedimint-gateway) echo "512m" ;;
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish an Archipelago OTA release to a Gitea remote and verify downloads.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-}"
|
||||
REMOTE="${2:-gitea-vps2}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Usage: $0 VERSION [remote]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
||||
BACKEND="$VERSION_DIR/archipelago"
|
||||
FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
||||
|
||||
fail() { echo "Error: $*" >&2; exit 1; }
|
||||
|
||||
[ -f "$PROJECT_ROOT/releases/manifest.json" ] || fail "releases/manifest.json missing"
|
||||
[ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND"
|
||||
[ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND"
|
||||
|
||||
"$SCRIPT_DIR/check-release-manifest.sh"
|
||||
|
||||
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
|
||||
case "$remote_url" in
|
||||
http://*@*) ;;
|
||||
*) fail "$REMOTE must be an authenticated http:// Gitea remote URL for API uploads" ;;
|
||||
esac
|
||||
|
||||
auth=${remote_url#http://}
|
||||
auth=${auth%@*}
|
||||
host_path=${remote_url#http://$auth@}
|
||||
host=${host_path%%/*}
|
||||
repo_path=${host_path#*/}
|
||||
repo_path=${repo_path%.git}
|
||||
api="http://$host/api/v1/repos/$repo_path"
|
||||
release_url="$api/releases/tags/v${VERSION}"
|
||||
|
||||
echo "Pushing main and v${VERSION} to $REMOTE..."
|
||||
git -C "$PROJECT_ROOT" push "$REMOTE" main "refs/tags/v${VERSION}"
|
||||
|
||||
release_json=$(curl -fsS -u "$auth" "$release_url" || true)
|
||||
if [ -z "$release_json" ]; then
|
||||
echo "Creating Gitea release v${VERSION}..."
|
||||
release_body=$(python3 - "$VERSION" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
version = sys.argv[1]
|
||||
print(json.dumps({
|
||||
"tag_name": f"v{version}",
|
||||
"target_commitish": "main",
|
||||
"name": f"v{version}",
|
||||
"body": f"Archipelago v{version} release artifacts for OTA updates.",
|
||||
"draft": False,
|
||||
"prerelease": True,
|
||||
}))
|
||||
PY
|
||||
)
|
||||
release_json=$(curl -fsS -u "$auth" -H 'Content-Type: application/json' -d "$release_body" "$api/releases")
|
||||
fi
|
||||
|
||||
release_id=$(printf '%s' "$release_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||
|
||||
asset_names=$(curl -fsS -u "$auth" "$api/releases/$release_id/assets" | python3 -c 'import json,sys; print("\n".join(a["name"] for a in json.load(sys.stdin)))')
|
||||
upload_asset() {
|
||||
local path="$1"
|
||||
local name="$2"
|
||||
if printf '%s\n' "$asset_names" | grep -Fxq "$name"; then
|
||||
echo "Asset $name already exists; leaving it in place."
|
||||
return
|
||||
fi
|
||||
echo "Uploading $name..."
|
||||
curl --fail --show-error --silent --http1.1 --connect-timeout 20 --max-time 900 \
|
||||
-u "$auth" \
|
||||
-F "attachment=@$path" \
|
||||
"$api/releases/$release_id/assets?name=$name" >/dev/null
|
||||
asset_names=$(printf '%s\n%s\n' "$asset_names" "$name")
|
||||
}
|
||||
|
||||
upload_asset "$BACKEND" "archipelago"
|
||||
upload_asset "$FRONTEND" "archipelago-frontend-${VERSION}.tar.gz"
|
||||
|
||||
echo "Verifying public download URLs from manifest..."
|
||||
python3 - "$PROJECT_ROOT/releases/manifest.json" <<'PY' | while read -r url size; do
|
||||
import json
|
||||
import sys
|
||||
|
||||
manifest = json.load(open(sys.argv[1]))
|
||||
for component in manifest["components"]:
|
||||
print(component["download_url"], component["size_bytes"])
|
||||
PY
|
||||
headers=$(curl -fsSI -L --max-time 60 "$url") || fail "download URL failed: $url"
|
||||
actual_size=$(printf '%s\n' "$headers" | awk 'tolower($1)=="content-length:" { size=$2 } END { gsub("\r", "", size); print size }')
|
||||
[ "$actual_size" = "$size" ] || fail "download size mismatch for $url (expected $size, got ${actual_size:-missing})"
|
||||
done
|
||||
|
||||
echo "Release v${VERSION} published and verified on $REMOTE."
|
||||
+10
-1
@@ -75,6 +75,15 @@ fi
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
if ! command -v nano >/dev/null 2>&1; then
|
||||
log "Installing nano for Archipelago terminal..."
|
||||
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq nano 2>>"$LOG_FILE"; then
|
||||
ok "nano installed"
|
||||
else
|
||||
warn "Unable to install nano automatically; continuing update"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fetch latest
|
||||
log "Fetching from origin..."
|
||||
git fetch origin main --quiet 2>>"$LOG_FILE"
|
||||
@@ -186,7 +195,7 @@ fi
|
||||
# for backward compatibility with older binaries that still look there.
|
||||
SCRIPTS_DEST="/opt/archipelago/scripts"
|
||||
sudo mkdir -p "$SCRIPTS_DEST"
|
||||
for script in image-versions.sh reconcile-containers.sh container-specs.sh container-doctor.sh app-surface-smoke-test.sh bitcoin-stack-lifecycle-test.sh; do
|
||||
for script in image-versions.sh reconcile-containers.sh container-specs.sh container-doctor.sh sync-npm-public-hosts.sh app-surface-smoke-test.sh bitcoin-stack-lifecycle-test.sh; do
|
||||
src="$REPO_DIR/scripts/$script"
|
||||
if [ -f "$src" ]; then
|
||||
sudo install -m 755 "$src" "$SCRIPTS_DEST/$script"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
DB="/var/lib/archipelago/nginx-proxy-manager/data/database.sqlite"
|
||||
OUT="/etc/nginx/conf.d/public-npm-proxy-hosts.conf"
|
||||
ACME_ROOT="/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge"
|
||||
LE_ROOT="/var/lib/archipelago/nginx-proxy-manager/letsencrypt/live"
|
||||
|
||||
[ -f "$DB" ] || exit 0
|
||||
mkdir -p "$ACME_ROOT/.well-known/acme-challenge"
|
||||
chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null || true
|
||||
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
|
||||
python3 - "$DB" "$ACME_ROOT" "$LE_ROOT" >"$tmp" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
db, acme_root, le_root = sys.argv[1:]
|
||||
con = sqlite3.connect(db)
|
||||
con.row_factory = sqlite3.Row
|
||||
|
||||
rows = con.execute(
|
||||
"""
|
||||
select p.id, p.domain_names, p.forward_scheme, p.forward_host, p.forward_port,
|
||||
p.certificate_id, p.ssl_forced, c.provider
|
||||
from proxy_host p
|
||||
left join certificate c on c.id = p.certificate_id
|
||||
where p.enabled = 1 and p.certificate_id > 0
|
||||
order by p.id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
print("# Generated by sync-npm-public-hosts.sh; do not edit by hand.")
|
||||
for row in rows:
|
||||
try:
|
||||
domains = [d for d in json.loads(row["domain_names"] or "[]") if d]
|
||||
except Exception:
|
||||
domains = []
|
||||
if not domains:
|
||||
continue
|
||||
cert_id = row["certificate_id"]
|
||||
cert = f"{le_root}/npm-{cert_id}/fullchain.pem"
|
||||
key = f"{le_root}/npm-{cert_id}/privkey.pem"
|
||||
if row["provider"] != "letsencrypt":
|
||||
continue
|
||||
if not os.path.isfile(cert) or not os.path.isfile(key):
|
||||
continue
|
||||
names = " ".join(domains)
|
||||
scheme = row["forward_scheme"] or "http"
|
||||
host = row["forward_host"]
|
||||
port = row["forward_port"]
|
||||
if not host or not port:
|
||||
continue
|
||||
# NPM containers use this name to reach host-published services; host nginx
|
||||
# itself should use loopback for the same services.
|
||||
nginx_host = "127.0.0.1" if host == "host.containers.internal" else host
|
||||
|
||||
print(f"""
|
||||
server {{
|
||||
listen 80;
|
||||
server_name {names};
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {{
|
||||
default_type text/plain;
|
||||
root {acme_root};
|
||||
try_files $uri =404;
|
||||
}}
|
||||
|
||||
location / {{
|
||||
return 301 https://$host$request_uri;
|
||||
}}
|
||||
}}
|
||||
|
||||
server {{
|
||||
listen 443 ssl;
|
||||
server_name {names};
|
||||
ssl_certificate {cert};
|
||||
ssl_certificate_key {key};
|
||||
|
||||
location / {{
|
||||
proxy_pass {scheme}://{nginx_host}:{port};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Scheme https;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}}
|
||||
}}
|
||||
""")
|
||||
PY
|
||||
|
||||
backup=""
|
||||
if [ -f "$OUT" ]; then
|
||||
backup=$(mktemp)
|
||||
cp "$OUT" "$backup"
|
||||
fi
|
||||
|
||||
restore_previous() {
|
||||
if [ -n "$backup" ] && [ -f "$backup" ]; then
|
||||
install -m 0644 "$backup" "$OUT"
|
||||
else
|
||||
rm -f "$OUT"
|
||||
fi
|
||||
}
|
||||
|
||||
if ! install -m 0644 "$tmp" "$OUT" || ! nginx -t >/dev/null; then
|
||||
restore_previous
|
||||
nginx -t >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
systemctl reload nginx
|
||||
@@ -135,7 +135,7 @@ image_for() {
|
||||
case "$1" in
|
||||
bitcoin-knots) echo "146.59.87.168:3000/lfg2025/bitcoin-knots:latest" ;;
|
||||
bitcoin-core) echo "docker.io/bitcoin/bitcoin:28.4" ;;
|
||||
btcpay-server) echo "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7" ;;
|
||||
btcpay-server) echo "docker.io/btcpayserver/btcpayserver:2.3.9" ;;
|
||||
lnd) echo "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta" ;;
|
||||
mempool) echo "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0" ;;
|
||||
homeassistant) echo "146.59.87.168:3000/lfg2025/home-assistant:2024.1" ;;
|
||||
|
||||
Reference in New Issue
Block a user