security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed): - CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed - HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted - HIGH: tar slip prevention, S3 SSRF validation, backup ID validation - MEDIUM: remember-me random secret, TOTP session rotation, password re-auth - LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation Container reliability: - Memory limits on all 37 containers (OOM prevention) - Exited vs stopped state distinction with health-aware status badges - Crash recovery coordination (no more restart cascade) - User-stopped tracking survives reboots - Tiered boot recovery (databases → core → services → apps) UI: - Wallet TransactionsModal, health-aware app status badges - Restart button on containers, exited/crashed red state - Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch - Apps sticky header removed, dev faucet, mutable mock wallet Infrastructure: - LND REST port 8080 exposed over Tor (LND Connect fix) - Nginx cookie_session fix, deploy script Tor config updated - Dev environment: podman auto-start, boot mode simulation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
28763c4f09
commit
84a56c80de
@ -5,6 +5,7 @@
|
|||||||
- [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs (API key, AIUI nginx, swap)
|
- [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs (API key, AIUI nginx, swap)
|
||||||
|
|
||||||
## Servers & Deploy
|
## Servers & Deploy
|
||||||
|
- [project_environments.md](project_environments.md) — Four environments: dev mode, dev server/prod, demo
|
||||||
- [tailscale_servers.md](tailscale_servers.md) — Tailscale server details (archipelago-2, archipelago-3)
|
- [tailscale_servers.md](tailscale_servers.md) — Tailscale server details (archipelago-2, archipelago-3)
|
||||||
- [reference_tailscale_nodes.md](reference_tailscale_nodes.md) — All node IPs and SSH commands
|
- [reference_tailscale_nodes.md](reference_tailscale_nodes.md) — All node IPs and SSH commands
|
||||||
- [second-server.md](second-server.md) — Second dev server (archipelago-2 via Tailscale)
|
- [second-server.md](second-server.md) — Second dev server (archipelago-2 via Tailscale)
|
||||||
|
|||||||
21
.claude/memory/project_environments.md
Normal file
21
.claude/memory/project_environments.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: Four Environments
|
||||||
|
description: Clear distinction between dev mode (local mock), dev server (228), demo (Portainer), and prod (same as dev server)
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
Four distinct environments — use correct terminology:
|
||||||
|
|
||||||
|
| Name | What | Where | Backend | Deploy |
|
||||||
|
|------|------|-------|---------|--------|
|
||||||
|
| **Dev mode** | Local macOS, mock backend | `localhost:8100` | `mock-backend.js` on `:5959` | `npm run dev:mock` |
|
||||||
|
| **Dev server / Prod** | Primary build/test/live server | `192.168.1.228` (+ fleet) | Real Rust backend + Podman | `deploy-to-target.sh --live` |
|
||||||
|
| **Demo** | Public demo instance | Remote server | Mock Node.js via Docker | Portainer Stacks / `docker-compose.demo.yml` |
|
||||||
|
|
||||||
|
- Dev server and prod are the SAME machine (192.168.1.228) — "prod" just means "the live deployment"
|
||||||
|
- Demo is completely separate — user deploys via Portainer UI, Claude has no SSH access
|
||||||
|
- Dev mode is local-only, no containers needed, fastest iteration
|
||||||
|
|
||||||
|
**Why:** User corrected ambiguous usage of "dev servers (prod)" — these are the same thing, not two separate environments.
|
||||||
|
|
||||||
|
**How to apply:** Always say "dev mode" for local mock, "dev server" or "prod" for 228, "demo" for the Portainer instance. Never conflate them.
|
||||||
78
.claude/memory/project_repo_cleanup_and_dev_env.md
Normal file
78
.claude/memory/project_repo_cleanup_and_dev_env.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: Repo Cleanup & Dev Environment Overhaul (2026-03-18)
|
||||||
|
description: Major session — repo cleanup to archy-archive, demo seeding, dev-start.sh rewrite, ThunderHub/Fedimint/ecash, Podman install, wallet mock endpoints
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Was Done
|
||||||
|
|
||||||
|
### 1. Repo Cleanup
|
||||||
|
- Moved ~200 files (docs, scripts, loops, legacy Docker UIs, duplicate videos) to `~/Projects/archy-archive/` (outside repo)
|
||||||
|
- Kept: all active docs (BETA-PROGRESS, MASTER_PLAN, architecture, ADRs, api-reference, developer-guide, troubleshooting, operations-runbook), all source code, active scripts
|
||||||
|
- Three "user's call" docs kept: `multi-node-architecture.md`, `marketplace-protocol.md`, `app-developer-guide.md`
|
||||||
|
|
||||||
|
### 2. docker-compose.yml Switched from Regtest to Signet
|
||||||
|
- All Bitcoin/LND/Fedimint containers now use **signet** (not regtest)
|
||||||
|
- Ports updated: RPC 38332, P2P 38333
|
||||||
|
- Removed archived `bitcoin-ui` and `lnd-ui` nginx services (referenced deleted `docker/` dir)
|
||||||
|
- Added ThunderHub service (port 3010) to main compose
|
||||||
|
|
||||||
|
### 3. New Testnet Compose (`docker-compose.testnet.yml`)
|
||||||
|
- Standalone signet stack: bitcoind + LND + ThunderHub + Fedimint
|
||||||
|
- Config at `testnet/thunderhub-config.yaml`
|
||||||
|
- README at `testnet/README.md` with faucet links and commands
|
||||||
|
|
||||||
|
### 4. Mock Backend Enhancements (`neode-ui/mock-backend.js`)
|
||||||
|
- **Container socket auto-detection**: tries `DOCKER_HOST` → Podman TMPDIR socket → Docker socket → null (simulation). No more `/var/run/docker.sock` spam
|
||||||
|
- **8 static dev apps** (was 6): added ThunderHub (port 3010) and Fedimint (port 8175)
|
||||||
|
- **25+ new RPC endpoints**: lnd.getinfo, lnd.newaddress, lnd.createinvoice, lnd.payinvoice, lnd.sendcoins, lnd.listchannels, lnd.openchannel, lnd.closechannel, wallet.ecash-balance, wallet.ecash-send, wallet.ecash-receive, wallet.ecash-history, wallet.networking-profits, bitcoin.getinfo, system.stats, update.status, network.list-requests, dev.faucet, etc.
|
||||||
|
- **Fedimint version** synced to 0.10.0, port fixed from 8174 → 8175
|
||||||
|
- **5 realistic notifications** (was empty array)
|
||||||
|
- **Mock ThunderHub UI** at `/app/thunderhub/` — full HTML dashboard
|
||||||
|
|
||||||
|
### 5. Dev Scripts Fixed
|
||||||
|
- `neode-ui/start-dev.sh`: removed broken `start-docker-apps.sh` call, fixed EAGAIN via safe `while read` loop
|
||||||
|
- `neode-ui/stop-dev.sh`: removed broken `stop-docker-apps.sh` call
|
||||||
|
- `neode-ui/package.json`: removed stale `prebuild`, added `--raw` to concurrently (fixes EAGAIN pgrep spawn)
|
||||||
|
- `scripts/dev-start.sh`: complete rewrite with 8 options including boot mode and testnet stack
|
||||||
|
|
||||||
|
### 6. ThunderHub Added Everywhere
|
||||||
|
- Icon: `neode-ui/public/assets/img/app-icons/thunderhub.svg`
|
||||||
|
- Mock backend: portMappings, marketplaceMetadata, staticDevApps, marketplace.get()
|
||||||
|
- Marketplace.vue: getCuratedAppList(), recommended tier
|
||||||
|
- appLauncher.ts: PORT_TO_APP_ID `'3010': 'thunderhub'`
|
||||||
|
|
||||||
|
### 7. Podman Installed on Mac
|
||||||
|
- `podman 5.8.1` + `podman-compose 1.5.0` via Homebrew
|
||||||
|
- Machine initialized and running
|
||||||
|
|
||||||
|
### 8. Home Wallet Card
|
||||||
|
- Fixed `lnd.getinfo` response to include `balance_sats` and `channel_balance_sats`
|
||||||
|
- Fixed `lnd.gettransactions` to use `amount_sats` and include `incoming_pending_count`
|
||||||
|
- Added **Faucet button** (green) — calls `dev.faucet` RPC
|
||||||
|
- Grid changed from 3-col to 4-col (Send, Receive, Faucet, Web5)
|
||||||
|
|
||||||
|
### 9. Developer Onboarding Docs
|
||||||
|
- `neode-ui/README.md`: full rewrite
|
||||||
|
- `neode-ui/DEV-SCRIPTS.md`: updated with actual 8 static apps
|
||||||
|
|
||||||
|
## Current State / Resume Here
|
||||||
|
- **`npm start` works** — no Docker needed, all wallet actions mocked, 8 apps visible
|
||||||
|
- **Send/Receive modals** open from Home wallet card — if still issues, check browser console
|
||||||
|
- **Faucet button** calls dev.faucet and refreshes balances
|
||||||
|
- **Not yet tested**: `podman-compose -f docker-compose.testnet.yml up` (signet sync ~10 min)
|
||||||
|
- **Not yet committed** — all changes are local, uncommitted
|
||||||
|
- **Demo prod server** not redeployed — push changes then redeploy via Portainer
|
||||||
|
|
||||||
|
## Key Files Modified This Session
|
||||||
|
- `neode-ui/mock-backend.js` (major — container socket, 25+ RPC endpoints, ThunderHub mock UI)
|
||||||
|
- `neode-ui/src/views/Home.vue` (faucet button, 4-col grid)
|
||||||
|
- `neode-ui/src/views/Marketplace.vue` (ThunderHub entry)
|
||||||
|
- `neode-ui/src/stores/appLauncher.ts` (ThunderHub port)
|
||||||
|
- `neode-ui/start-dev.sh`, `neode-ui/stop-dev.sh`, `neode-ui/package.json`
|
||||||
|
- `scripts/dev-start.sh` (complete rewrite)
|
||||||
|
- `docker-compose.yml` (regtest→signet, ThunderHub, removed archived UIs)
|
||||||
|
- `docker-compose.testnet.yml` (new)
|
||||||
|
- `testnet/thunderhub-config.yaml`, `testnet/README.md` (new)
|
||||||
|
- `neode-ui/public/assets/img/app-icons/thunderhub.svg` (new)
|
||||||
|
- `neode-ui/README.md`, `neode-ui/DEV-SCRIPTS.md` (rewrites)
|
||||||
81
CHANGELOG.md
81
CHANGELOG.md
@ -7,6 +7,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-03-19
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
#### Pentest Remediation (33 findings, all addressed)
|
||||||
|
- **Critical**: Backend now binds to 127.0.0.1 only — no more direct LAN access to port 5678
|
||||||
|
- **Critical**: Fixed path traversal in Tor service management that could allow `sudo rm -rf` on arbitrary directories
|
||||||
|
- **Critical**: Fixed unauthenticated file read/delete via DWN recordId path traversal
|
||||||
|
- **High**: Federation peers now require cryptographic signature — unsigned peers rejected
|
||||||
|
- **High**: Login redirect XSS vulnerability fixed with proper URL validation
|
||||||
|
- **High**: Viewer role restricted to read-only node methods (was granting sign/export access)
|
||||||
|
- **High**: Backup restore/verify now validates IDs against path traversal
|
||||||
|
- **High**: Tar archive extraction validates every entry path (prevents tar slip attacks)
|
||||||
|
- **High**: S3 backup endpoints require HTTPS and reject private IP ranges
|
||||||
|
- **Medium**: Remember-me token secret now uses cryptographic random (not machine-id)
|
||||||
|
- **Medium**: Destructive operations (factory reset, onboarding reset) now require password re-verification
|
||||||
|
- **Medium**: Session token rotated after TOTP verification (prevents interception reuse)
|
||||||
|
- **Medium**: Webhook URL validation hardened against IPv6 bypass, DNS rebinding, redirect chains
|
||||||
|
- **Low**: CORS localhost:8100 only included in dev mode
|
||||||
|
- **Low**: CSP `unsafe-inline` removed from `script-src`
|
||||||
|
- **Low**: Content filenames validated against path separators and hidden file prefixes
|
||||||
|
- **Low**: Nostr relay URLs restricted to `wss://` with private IP rejection
|
||||||
|
- **Low**: Onion address validation enforces v3 format (56 base32 chars)
|
||||||
|
- **Low**: Router detection restricted to private IP ranges only
|
||||||
|
|
||||||
|
#### Nginx Authentication
|
||||||
|
- Fixed session cookie name mismatch (`session_id` → `session`) across all nginx auth checks
|
||||||
|
- LND Connect info endpoint now properly authenticated
|
||||||
|
|
||||||
|
### Container Reliability
|
||||||
|
|
||||||
|
#### Memory Limits (prevents OOM crashes)
|
||||||
|
- All 37 containers in `first-boot-containers.sh` now have `--memory=` limits
|
||||||
|
- Automatic RAM tier detection — reduced limits on 8GB machines
|
||||||
|
- Prevents a single runaway container from crashing the entire system
|
||||||
|
|
||||||
|
#### Smart Container States
|
||||||
|
- New `exited` state distinguishes crashed containers from intentionally stopped ones
|
||||||
|
- Crashed containers show red "crashed" badge with restart button
|
||||||
|
- Health-aware status: "healthy" (green), "starting up" (yellow spinner), "unhealthy" (orange pulse)
|
||||||
|
- Restart button added next to Stop on running containers
|
||||||
|
|
||||||
|
#### Crash Recovery Improvements
|
||||||
|
- Boot recovery and health monitor now coordinate via shared flag (no more restart cascade)
|
||||||
|
- User-stopped containers tracked in `user-stopped.json` — survive reboots without auto-restart
|
||||||
|
- Boot recovery uses tiered ordering: databases → core → services → apps → UIs
|
||||||
|
- Health monitor waits for boot recovery to complete before starting checks
|
||||||
|
|
||||||
|
### UI Improvements
|
||||||
|
|
||||||
|
#### Home Dashboard
|
||||||
|
- Wallet card now matches Web5 wallet display
|
||||||
|
- New Transactions modal with full history (incoming/outgoing, amounts, confirmations)
|
||||||
|
- Transactions button in header — switches to "Incoming" badge when pending transactions exist
|
||||||
|
- Dev faucet button (dev mode only) with mutable wallet state
|
||||||
|
- Fixed system stats crash (`cpu_usage_percent` field name mismatch)
|
||||||
|
|
||||||
|
#### Apps & App Details
|
||||||
|
- Container restart button (icon) next to Stop on all running apps
|
||||||
|
- Exited/crashed containers show "Restart" instead of "Start" with red styling
|
||||||
|
- Removed broken sticky header from Apps page
|
||||||
|
- Health-aware status badges throughout
|
||||||
|
|
||||||
|
#### Mesh, Cloud, Settings & More
|
||||||
|
- Mesh view overhaul with improved layout
|
||||||
|
- Glass button styling updates across components
|
||||||
|
- New BaseModal and ToggleSwitch components
|
||||||
|
- Updated translations (English + Spanish)
|
||||||
|
- Spotlight search improvements
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
#### LND Connect
|
||||||
|
- Tor hidden service now exposes LND REST port (8080) for remote wallet connections
|
||||||
|
- Fixed in ISO build script, deploy script, and live servers
|
||||||
|
|
||||||
|
#### Dev Environment
|
||||||
|
- Mock backend has mutable wallet state (faucet/send/receive actually change balances)
|
||||||
|
- Testnet stack option auto-starts Podman machine on macOS
|
||||||
|
- Boot mode simulation for testing startup screens
|
||||||
|
|
||||||
## [1.2.0] - 2026-03-14
|
## [1.2.0] - 2026-03-14
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@ -77,11 +77,14 @@ impl ApiHandler {
|
|||||||
|
|
||||||
/// Allowed CORS origins derived from the config host IP.
|
/// Allowed CORS origins derived from the config host IP.
|
||||||
fn allowed_origins(&self) -> Vec<String> {
|
fn allowed_origins(&self) -> Vec<String> {
|
||||||
vec![
|
let mut origins = vec![
|
||||||
format!("http://{}", self.config.host_ip),
|
format!("http://{}", self.config.host_ip),
|
||||||
format!("https://{}", self.config.host_ip),
|
format!("https://{}", self.config.host_ip),
|
||||||
"http://localhost:8100".to_string(), // Vite dev server
|
];
|
||||||
]
|
if self.config.dev_mode {
|
||||||
|
origins.push("http://localhost:8100".to_string()); // Vite dev server
|
||||||
|
}
|
||||||
|
origins
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the Origin header against allowed origins.
|
/// Validate the Origin header against allowed origins.
|
||||||
|
|||||||
@ -76,7 +76,21 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!(complete))
|
Ok(serde_json::json!(complete))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_auth_reset_onboarding(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_auth_reset_onboarding(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let password = params
|
||||||
|
.get("password")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||||
|
|
||||||
|
let valid = self.auth_manager.verify_password(password).await?;
|
||||||
|
if !valid {
|
||||||
|
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||||
|
}
|
||||||
|
|
||||||
self.auth_manager.reset_onboarding().await?;
|
self.auth_manager.reset_onboarding().await?;
|
||||||
Ok(serde_json::json!(true))
|
Ok(serde_json::json!(true))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,61 @@
|
|||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use crate::backup::full;
|
use crate::backup::full;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use std::net::IpAddr;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
/// Validate an S3 endpoint URL: require https, reject private/loopback IPs and localhost.
|
||||||
|
fn validate_s3_endpoint(endpoint: &str) -> Result<()> {
|
||||||
|
// Require HTTPS scheme
|
||||||
|
if !endpoint.starts_with("https://") {
|
||||||
|
anyhow::bail!("S3 endpoint must use https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract host from URL (strip scheme, path, port)
|
||||||
|
let after_scheme = &endpoint["https://".len()..];
|
||||||
|
let host_port = after_scheme.split('/').next().unwrap_or("");
|
||||||
|
// Strip port if present (handle IPv6 bracket notation)
|
||||||
|
let host = if host_port.starts_with('[') {
|
||||||
|
// IPv6: [::1]:443
|
||||||
|
host_port.split(']').next().unwrap_or("").trim_start_matches('[')
|
||||||
|
} else {
|
||||||
|
host_port.split(':').next().unwrap_or("")
|
||||||
|
};
|
||||||
|
|
||||||
|
if host.is_empty() {
|
||||||
|
anyhow::bail!("S3 endpoint missing host");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject localhost
|
||||||
|
if host == "localhost" || host.ends_with(".localhost") {
|
||||||
|
anyhow::bail!("S3 endpoint must not point to localhost");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse as IP and reject private/reserved ranges
|
||||||
|
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||||
|
let is_private = match ip {
|
||||||
|
IpAddr::V4(v4) => {
|
||||||
|
v4.is_loopback() // 127.0.0.0/8
|
||||||
|
|| v4.octets()[0] == 10 // 10.0.0.0/8
|
||||||
|
|| (v4.octets()[0] == 172 && (v4.octets()[1] & 0xf0) == 16) // 172.16.0.0/12
|
||||||
|
|| (v4.octets()[0] == 192 && v4.octets()[1] == 168) // 192.168.0.0/16
|
||||||
|
|| (v4.octets()[0] == 169 && v4.octets()[1] == 254) // 169.254.0.0/16
|
||||||
|
|| v4.is_unspecified() // 0.0.0.0
|
||||||
|
}
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
v6.is_loopback() // ::1
|
||||||
|
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7
|
||||||
|
|| v6.is_unspecified() // ::
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if is_private {
|
||||||
|
anyhow::bail!("S3 endpoint must not point to a private or reserved IP address");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// Create a full encrypted backup. Params: { passphrase, description? }
|
/// Create a full encrypted backup. Params: { passphrase, description? }
|
||||||
pub(super) async fn handle_backup_create(
|
pub(super) async fn handle_backup_create(
|
||||||
@ -55,6 +108,11 @@ impl RpcHandler {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||||
|
|
||||||
|
// Validate backup ID to prevent path traversal
|
||||||
|
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
||||||
|
anyhow::bail!("Invalid backup ID");
|
||||||
|
}
|
||||||
|
|
||||||
let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?;
|
let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?;
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
@ -78,6 +136,11 @@ impl RpcHandler {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||||
|
|
||||||
|
// Validate backup ID to prevent path traversal
|
||||||
|
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
||||||
|
anyhow::bail!("Invalid backup ID");
|
||||||
|
}
|
||||||
|
|
||||||
full::restore_full_backup(&self.config.data_dir, id, passphrase).await?;
|
full::restore_full_backup(&self.config.data_dir, id, passphrase).await?;
|
||||||
|
|
||||||
Ok(serde_json::json!({ "restored": true, "id": id }))
|
Ok(serde_json::json!({ "restored": true, "id": id }))
|
||||||
@ -183,6 +246,9 @@ impl RpcHandler {
|
|||||||
anyhow::bail!("Invalid backup ID");
|
anyhow::bail!("Invalid backup ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate endpoint to prevent SSRF against internal services
|
||||||
|
validate_s3_endpoint(endpoint)?;
|
||||||
|
|
||||||
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
||||||
if !bak_path.exists() {
|
if !bak_path.exists() {
|
||||||
anyhow::bail!("Backup not found: {}", id);
|
anyhow::bail!("Backup not found: {}", id);
|
||||||
@ -255,6 +321,9 @@ impl RpcHandler {
|
|||||||
anyhow::bail!("Invalid backup ID");
|
anyhow::bail!("Invalid backup ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate endpoint to prevent SSRF against internal services
|
||||||
|
validate_s3_endpoint(endpoint)?;
|
||||||
|
|
||||||
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
|
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
|
||||||
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);
|
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,16 @@ use crate::network::dwn_store::DwnStore;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Validate a v3 Tor onion address.
|
||||||
|
/// Must be exactly 62 chars: 56 base32 characters (a-z, 2-7) followed by ".onion".
|
||||||
|
fn is_valid_v3_onion(addr: &str) -> bool {
|
||||||
|
if addr.len() != 62 || !addr.ends_with(".onion") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let prefix = &addr[..56];
|
||||||
|
prefix.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
|
||||||
|
}
|
||||||
|
|
||||||
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
@ -25,10 +35,16 @@ impl RpcHandler {
|
|||||||
.get("filename")
|
.get("filename")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing filename"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing filename"))?;
|
||||||
// Prevent path traversal
|
// Validate filename: prevent path traversal, hidden files, and excessive length
|
||||||
if filename.contains("..") || filename.contains('\0') {
|
if filename.contains("..") || filename.contains('\0') || filename.contains('/') || filename.contains('\\') {
|
||||||
anyhow::bail!("Invalid filename: path traversal not allowed");
|
anyhow::bail!("Invalid filename: path traversal not allowed");
|
||||||
}
|
}
|
||||||
|
if filename.starts_with('.') {
|
||||||
|
anyhow::bail!("Invalid filename: hidden files not allowed");
|
||||||
|
}
|
||||||
|
if filename.is_empty() || filename.len() > 255 {
|
||||||
|
anyhow::bail!("Invalid filename: must be 1-255 characters");
|
||||||
|
}
|
||||||
let mime_type = params
|
let mime_type = params
|
||||||
.get("mime_type")
|
.get("mime_type")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@ -191,8 +207,9 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||||
|
|
||||||
if !onion.ends_with(".onion") || onion.len() < 10 {
|
// Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total
|
||||||
return Err(anyhow::anyhow!("Invalid onion address"));
|
if !is_valid_v3_onion(onion) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
||||||
@ -252,9 +269,9 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||||
|
|
||||||
// Validate onion address format
|
// Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total
|
||||||
if !onion.ends_with(".onion") || onion.len() < 10 {
|
if !is_valid_v3_onion(onion) {
|
||||||
return Err(anyhow::anyhow!("Invalid onion address"));
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect via Tor SOCKS proxy to the peer's content catalog endpoint
|
// Connect via Tor SOCKS proxy to the peer's content catalog endpoint
|
||||||
|
|||||||
@ -371,7 +371,8 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(peer_did = %did, "Peer-joined without signature — accepting but unverified");
|
tracing::warn!(peer_did = %did, "Rejected peer-joined: missing signature");
|
||||||
|
anyhow::bail!("Missing signature — all federation peers must be cryptographically verified");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -442,7 +442,7 @@ impl RpcHandler {
|
|||||||
"auth.changePassword" => self.handle_auth_change_password(params, &session_token).await,
|
"auth.changePassword" => self.handle_auth_change_password(params, &session_token).await,
|
||||||
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
||||||
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||||
"auth.resetOnboarding" => self.handle_auth_reset_onboarding().await,
|
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
|
||||||
|
|
||||||
// Container orchestration (for Archipelago-managed containers)
|
// Container orchestration (for Archipelago-managed containers)
|
||||||
"container-install" => self.handle_container_install(params).await,
|
"container-install" => self.handle_container_install(params).await,
|
||||||
@ -789,7 +789,7 @@ impl RpcHandler {
|
|||||||
self.metrics_store.record_rpc_latency(elapsed_ms).await;
|
self.metrics_store.record_rpc_latency(elapsed_ms).await;
|
||||||
|
|
||||||
// Build response (cache successful results for cacheable methods)
|
// Build response (cache successful results for cacheable methods)
|
||||||
let rpc_resp = match result {
|
let mut rpc_resp = match result {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
if is_cacheable {
|
if is_cacheable {
|
||||||
self.response_cache.set(rpc_req.method.clone(), data.clone()).await;
|
self.response_cache.set(rpc_req.method.clone(), data.clone()).await;
|
||||||
@ -893,8 +893,62 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// On successful TOTP verification, the session is already upgraded to full
|
// On successful TOTP verification, set the rotated session cookie
|
||||||
// (handled inside handle_login_totp/handle_login_backup)
|
if (rpc_req.method == "auth.login.totp" || rpc_req.method == "auth.login.backup")
|
||||||
|
&& rpc_resp.error.is_none()
|
||||||
|
{
|
||||||
|
// Extract token (clone to release immutable borrow before mutable borrow below)
|
||||||
|
let new_token_opt = rpc_resp
|
||||||
|
.result
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.get("new_session_token"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
if let Some(new_token) = new_token_opt {
|
||||||
|
let csrf_token = derive_csrf_token(&new_token);
|
||||||
|
let remember_token = self.session_store.create_remember_token();
|
||||||
|
response.headers_mut().append(
|
||||||
|
"Set-Cookie",
|
||||||
|
format!(
|
||||||
|
"session={}; HttpOnly; SameSite=Strict; Path=/{}",
|
||||||
|
new_token,
|
||||||
|
self.cookie_suffix()
|
||||||
|
)
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
response.headers_mut().append(
|
||||||
|
"Set-Cookie",
|
||||||
|
format!(
|
||||||
|
"csrf_token={}; SameSite=Strict; Path=/{}",
|
||||||
|
csrf_token,
|
||||||
|
self.cookie_suffix()
|
||||||
|
)
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
response.headers_mut().append(
|
||||||
|
"Set-Cookie",
|
||||||
|
format!(
|
||||||
|
"remember={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}{}",
|
||||||
|
remember_token,
|
||||||
|
REMEMBER_TTL,
|
||||||
|
self.cookie_suffix()
|
||||||
|
)
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
// Strip the token from the response body — don't leak it to JS
|
||||||
|
if let Some(result) = rpc_resp.result.as_mut() {
|
||||||
|
if let Some(obj) = result.as_object_mut() {
|
||||||
|
obj.remove("new_session_token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let body_bytes = serde_json::to_vec(&rpc_resp).unwrap_or_default();
|
||||||
|
*response.body_mut() = hyper::Body::from(body_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// On password change, rotate the session token for the caller
|
// On password change, rotate the session token for the caller
|
||||||
if rpc_req.method == "auth.changePassword" && rpc_resp.error.is_none() {
|
if rpc_req.method == "auth.changePassword" && rpc_resp.error.is_none() {
|
||||||
|
|||||||
@ -686,6 +686,12 @@ printtoconsole=1\n", rpc_pass);
|
|||||||
sorted
|
sorted
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Clear user-stopped flag — user explicitly started this app
|
||||||
|
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
|
||||||
|
for name in &to_start {
|
||||||
|
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
|
||||||
|
}
|
||||||
|
|
||||||
for name in to_start {
|
for name in to_start {
|
||||||
let _ = tokio::process::Command::new("podman")
|
let _ = tokio::process::Command::new("podman")
|
||||||
.args(["start", &name])
|
.args(["start", &name])
|
||||||
@ -707,9 +713,13 @@ printtoconsole=1\n", rpc_pass);
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
validate_app_id(package_id)?;
|
validate_app_id(package_id)?;
|
||||||
|
|
||||||
|
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
|
||||||
|
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
|
||||||
|
|
||||||
let containers = get_containers_for_app(package_id).await?;
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
if containers.is_empty() {
|
if containers.is_empty() {
|
||||||
let container_name = format!("archy-{}", package_id);
|
let container_name = format!("archy-{}", package_id);
|
||||||
|
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name).await;
|
||||||
let _ = tokio::process::Command::new("podman")
|
let _ = tokio::process::Command::new("podman")
|
||||||
.args(["stop", &container_name])
|
.args(["stop", &container_name])
|
||||||
.output()
|
.output()
|
||||||
@ -717,6 +727,9 @@ printtoconsole=1\n", rpc_pass);
|
|||||||
return Ok(serde_json::Value::Null);
|
return Ok(serde_json::Value::Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for name in &containers {
|
||||||
|
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
|
||||||
|
}
|
||||||
for name in containers {
|
for name in containers {
|
||||||
let _ = tokio::process::Command::new("podman")
|
let _ = tokio::process::Command::new("podman")
|
||||||
.args(["stop", &name])
|
.args(["stop", &name])
|
||||||
@ -1025,6 +1038,7 @@ printtoconsole=1\n", rpc_pass);
|
|||||||
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||||
PackageDataEntry {
|
PackageDataEntry {
|
||||||
state: PackageState::Installing,
|
state: PackageState::Installing,
|
||||||
|
health: None,
|
||||||
static_files: StaticFiles {
|
static_files: StaticFiles {
|
||||||
license: String::new(),
|
license: String::new(),
|
||||||
instructions: String::new(),
|
instructions: String::new(),
|
||||||
|
|||||||
@ -609,6 +609,18 @@ impl RpcHandler {
|
|||||||
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
|
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require password re-authentication for destructive operations
|
||||||
|
let password = params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("password"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||||
|
|
||||||
|
let valid = self.auth_manager.verify_password(password).await?;
|
||||||
|
if !valid {
|
||||||
|
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||||
|
}
|
||||||
|
|
||||||
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
||||||
|
|
||||||
let data_dir = &self.config.data_dir;
|
let data_dir = &self.config.data_dir;
|
||||||
|
|||||||
@ -118,6 +118,11 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||||
|
|
||||||
|
// Validate name to prevent path traversal
|
||||||
|
if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||||
|
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
|
||||||
|
}
|
||||||
|
|
||||||
let onion = read_onion_address(name);
|
let onion = read_onion_address(name);
|
||||||
Ok(serde_json::json!({ "name": name, "onion_address": onion }))
|
Ok(serde_json::json!({ "name": name, "onion_address": onion }))
|
||||||
}
|
}
|
||||||
@ -134,6 +139,11 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||||
|
|
||||||
|
// Validate name to prevent path traversal
|
||||||
|
if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||||
|
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
|
||||||
|
}
|
||||||
|
|
||||||
let base = tor_data_dir();
|
let base = tor_data_dir();
|
||||||
let service_dir = format!("{}/hidden_service_{}", base, name);
|
let service_dir = format!("{}/hidden_service_{}", base, name);
|
||||||
|
|
||||||
@ -277,6 +287,12 @@ impl RpcHandler {
|
|||||||
.get("app_id")
|
.get("app_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||||
|
|
||||||
|
// Validate app_id to prevent path traversal
|
||||||
|
if app_id.is_empty() || app_id.len() > 64 || !app_id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||||
|
return Err(anyhow::anyhow!("Invalid app_id (alphanumeric, hyphens, underscores only)"));
|
||||||
|
}
|
||||||
|
|
||||||
let enabled = params
|
let enabled = params
|
||||||
.get("enabled")
|
.get("enabled")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
|
|||||||
@ -192,10 +192,11 @@ impl RpcHandler {
|
|||||||
let _ = self.auth_manager.update_totp(data).await;
|
let _ = self.auth_manager.update_totp(data).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade pending session to full
|
// Upgrade pending session to full (rotates token)
|
||||||
self.session_store.upgrade_to_full(token).await;
|
let new_token = self.session_store.upgrade_to_full(token).await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
|
||||||
|
|
||||||
Ok(serde_json::json!({ "success": true }))
|
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
anyhow::bail!("Invalid code. Please try again.");
|
anyhow::bail!("Invalid code. Please try again.");
|
||||||
@ -241,13 +242,14 @@ impl RpcHandler {
|
|||||||
totp_data.backup_codes.remove(idx);
|
totp_data.backup_codes.remove(idx);
|
||||||
self.auth_manager.update_totp(totp_data).await?;
|
self.auth_manager.update_totp(totp_data).await?;
|
||||||
|
|
||||||
// Upgrade pending session to full
|
// Upgrade pending session to full (rotates token)
|
||||||
self.session_store.upgrade_to_full(token).await;
|
let new_token = self.session_store.upgrade_to_full(token).await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
|
||||||
|
|
||||||
tracing::info!("Login via backup code (codes remaining: {})",
|
tracing::info!("Login via backup code (codes remaining: {})",
|
||||||
self.auth_manager.get_totp_data().await?.map(|d| d.backup_codes.len()).unwrap_or(0));
|
self.auth_manager.get_totp_data().await?.map(|d| d.backup_codes.len()).unwrap_or(0));
|
||||||
|
|
||||||
Ok(serde_json::json!({ "success": true }))
|
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
anyhow::bail!("Invalid backup code");
|
anyhow::bail!("Invalid backup code");
|
||||||
|
|||||||
@ -3,6 +3,90 @@ use crate::webhooks;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
/// Check if a hostname/IP points to a private or internal address.
|
||||||
|
/// Handles: IPv4, IPv6 (including mapped IPv4 like ::ffff:127.0.0.1),
|
||||||
|
/// decimal/octal IP representations, and well-known internal hostnames.
|
||||||
|
fn is_webhook_host_private(host: &str) -> bool {
|
||||||
|
// Strip IPv6 brackets if present
|
||||||
|
let h = host.trim_start_matches('[').trim_end_matches(']');
|
||||||
|
|
||||||
|
// Check well-known internal hostnames
|
||||||
|
let lower = h.to_lowercase();
|
||||||
|
if lower == "localhost"
|
||||||
|
|| lower == "localhost.localdomain"
|
||||||
|
|| lower.ends_with(".local")
|
||||||
|
|| lower.ends_with(".internal")
|
||||||
|
|| lower == "metadata.google.internal"
|
||||||
|
|| lower == "169.254.169.254"
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as IP address
|
||||||
|
if let Ok(ip) = h.parse::<std::net::IpAddr>() {
|
||||||
|
return match ip {
|
||||||
|
std::net::IpAddr::V4(v4) => {
|
||||||
|
v4.is_loopback()
|
||||||
|
|| v4.is_private()
|
||||||
|
|| v4.is_link_local()
|
||||||
|
|| v4.is_unspecified()
|
||||||
|
|| v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1]) // CGNAT
|
||||||
|
}
|
||||||
|
std::net::IpAddr::V6(v6) => {
|
||||||
|
if v6.is_loopback() || v6.is_unspecified() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
|
||||||
|
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||||
|
return v4.is_loopback()
|
||||||
|
|| v4.is_private()
|
||||||
|
|| v4.is_link_local()
|
||||||
|
|| v4.is_unspecified();
|
||||||
|
}
|
||||||
|
// Unique local (fd00::/8, fc00::/7)
|
||||||
|
let segments = v6.segments();
|
||||||
|
(segments[0] & 0xfe00) == 0xfc00
|
||||||
|
|| (segments[0] & 0xffc0) == 0xfe80 // link-local
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect decimal IP notation (e.g., "2130706433" = 127.0.0.1)
|
||||||
|
if let Ok(decimal) = h.parse::<u32>() {
|
||||||
|
let octets = decimal.to_be_bytes();
|
||||||
|
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
|
||||||
|
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect octal IP notation (e.g., "0177.0.0.1" = 127.0.0.1)
|
||||||
|
if h.contains('.') {
|
||||||
|
let parts: Vec<&str> = h.split('.').collect();
|
||||||
|
if parts.len() == 4 {
|
||||||
|
let mut octets = [0u8; 4];
|
||||||
|
let mut all_ok = true;
|
||||||
|
for (i, part) in parts.iter().enumerate() {
|
||||||
|
let val = if part.starts_with("0x") || part.starts_with("0X") {
|
||||||
|
u64::from_str_radix(part.trim_start_matches("0x").trim_start_matches("0X"), 16).ok()
|
||||||
|
} else if part.starts_with('0') && part.len() > 1 {
|
||||||
|
u64::from_str_radix(part, 8).ok()
|
||||||
|
} else {
|
||||||
|
part.parse::<u64>().ok()
|
||||||
|
};
|
||||||
|
match val {
|
||||||
|
Some(v) if v <= 255 => octets[i] = v as u8,
|
||||||
|
_ => { all_ok = false; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if all_ok {
|
||||||
|
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
|
||||||
|
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// webhook.get-config — Get current webhook configuration.
|
/// webhook.get-config — Get current webhook configuration.
|
||||||
pub(super) async fn handle_webhook_get_config(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_webhook_get_config(&self) -> Result<serde_json::Value> {
|
||||||
@ -28,37 +112,35 @@ impl RpcHandler {
|
|||||||
config.enabled = enabled;
|
config.enabled = enabled;
|
||||||
}
|
}
|
||||||
if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
|
if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
|
||||||
// Validate webhook URL scheme and reject obviously dangerous targets
|
// Validate webhook URL scheme and reject dangerous targets
|
||||||
if !url.is_empty() {
|
if !url.is_empty() {
|
||||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
|
||||||
anyhow::bail!("Webhook URL must use HTTP(S)");
|
|
||||||
}
|
|
||||||
if !self.config.dev_mode && !url.starts_with("https://") {
|
|
||||||
anyhow::bail!("Webhook URL must use HTTPS in production");
|
|
||||||
}
|
|
||||||
// Extract host portion and reject private/internal addresses
|
|
||||||
let host_part = url
|
|
||||||
.trim_start_matches("https://")
|
|
||||||
.trim_start_matches("http://")
|
|
||||||
.split('/')
|
|
||||||
.next()
|
|
||||||
.unwrap_or("")
|
|
||||||
.split(':')
|
|
||||||
.next()
|
|
||||||
.unwrap_or("");
|
|
||||||
let is_private = host_part == "localhost"
|
|
||||||
|| host_part == "127.0.0.1"
|
|
||||||
|| host_part == "::1"
|
|
||||||
|| host_part.starts_with("10.")
|
|
||||||
|| host_part.starts_with("172.")
|
|
||||||
|| host_part.starts_with("192.168.")
|
|
||||||
|| host_part.starts_with("169.254.");
|
|
||||||
if is_private && !self.config.dev_mode {
|
|
||||||
anyhow::bail!("Webhook URL must not point to private/local addresses");
|
|
||||||
}
|
|
||||||
if url.len() > 2048 {
|
if url.len() > 2048 {
|
||||||
anyhow::bail!("Webhook URL too long");
|
anyhow::bail!("Webhook URL too long");
|
||||||
}
|
}
|
||||||
|
// Parse URL properly to handle edge cases (IPv6, userinfo, etc.)
|
||||||
|
let parsed = reqwest::Url::parse(url)
|
||||||
|
.map_err(|_| anyhow::anyhow!("Invalid webhook URL"))?;
|
||||||
|
// Require https:// in production
|
||||||
|
if !self.config.dev_mode && parsed.scheme() != "https" {
|
||||||
|
anyhow::bail!("Webhook URL must use HTTPS in production");
|
||||||
|
}
|
||||||
|
if parsed.scheme() != "https" && parsed.scheme() != "http" {
|
||||||
|
anyhow::bail!("Webhook URL must use HTTP(S)");
|
||||||
|
}
|
||||||
|
// Reject URLs with userinfo (user:pass@host) — can be used for credential smuggling
|
||||||
|
if parsed.username() != "" || parsed.password().is_some() {
|
||||||
|
anyhow::bail!("Webhook URL must not contain credentials");
|
||||||
|
}
|
||||||
|
// Extract and validate the host
|
||||||
|
let host = parsed.host_str().unwrap_or("");
|
||||||
|
if host.is_empty() {
|
||||||
|
anyhow::bail!("Webhook URL must have a valid host");
|
||||||
|
}
|
||||||
|
// Reject private/internal addresses (handle IPv4, IPv6, decimal/octal IPs, hostnames)
|
||||||
|
let is_private = is_webhook_host_private(host);
|
||||||
|
if is_private && !self.config.dev_mode {
|
||||||
|
anyhow::bail!("Webhook URL must not point to private/local addresses");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
config.url = url.to_string();
|
config.url = url.to_string();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,9 @@ impl UserRole {
|
|||||||
|| method == "system.temperature"
|
|| method == "system.temperature"
|
||||||
|| method == "system.disk-status"
|
|| method == "system.disk-status"
|
||||||
|| method == "system.detect-usb-devices"
|
|| method == "system.detect-usb-devices"
|
||||||
|| method.starts_with("node.")
|
|| method == "node.did"
|
||||||
|
|| method == "node.tor-address"
|
||||||
|
|| method == "node.nostr-pubkey"
|
||||||
|| method.starts_with("federation.list")
|
|| method.starts_with("federation.list")
|
||||||
|| method.starts_with("dwn.status")
|
|| method.starts_with("dwn.status")
|
||||||
|| method.starts_with("dwn.list")
|
|| method.starts_with("dwn.list")
|
||||||
|
|||||||
@ -435,10 +435,46 @@ fn create_tar_gz(data_dir: &Path) -> Result<Vec<u8>> {
|
|||||||
fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> {
|
fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> {
|
||||||
let gz = GzDecoder::new(tar_gz_data);
|
let gz = GzDecoder::new(tar_gz_data);
|
||||||
let mut archive = Archive::new(gz);
|
let mut archive = Archive::new(gz);
|
||||||
|
let canonical_base = data_dir
|
||||||
|
.canonicalize()
|
||||||
|
.context("Failed to canonicalize data_dir")?;
|
||||||
|
|
||||||
archive
|
for entry_result in archive.entries().context("Failed to read tar entries")? {
|
||||||
.unpack(data_dir)
|
let mut entry = entry_result.context("Failed to read tar entry")?;
|
||||||
.context("Failed to extract backup archive")?;
|
let entry_path = entry.path().context("Failed to get entry path")?.to_path_buf();
|
||||||
|
|
||||||
|
// Reject entries with path traversal components
|
||||||
|
for component in entry_path.components() {
|
||||||
|
if matches!(component, std::path::Component::ParentDir) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Tar entry contains path traversal: {}",
|
||||||
|
entry_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = data_dir.join(&entry_path);
|
||||||
|
// Verify the resolved path stays within data_dir
|
||||||
|
// For new files that don't exist yet, check the parent directory
|
||||||
|
let check_path = if target.exists() {
|
||||||
|
target.canonicalize()?
|
||||||
|
} else if let Some(parent) = target.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
parent.canonicalize()?.join(target.file_name().unwrap_or_default())
|
||||||
|
} else {
|
||||||
|
target.clone()
|
||||||
|
};
|
||||||
|
if !check_path.starts_with(&canonical_base) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Tar entry escapes target directory: {}",
|
||||||
|
entry_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry
|
||||||
|
.unpack(&target)
|
||||||
|
.with_context(|| format!("Failed to extract: {}", entry_path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
debug!("Backup extracted to {:?}", data_dir);
|
debug!("Backup extracted to {:?}", data_dir);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -217,7 +217,7 @@ mod tests {
|
|||||||
fn test_default_config_values() {
|
fn test_default_config_values() {
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
assert_eq!(config.data_dir, PathBuf::from("/var/lib/archipelago"));
|
assert_eq!(config.data_dir, PathBuf::from("/var/lib/archipelago"));
|
||||||
assert_eq!(config.bind_host, "0.0.0.0");
|
assert_eq!(config.bind_host, "127.0.0.1");
|
||||||
assert_eq!(config.bind_port, 5678);
|
assert_eq!(config.bind_port, 5678);
|
||||||
assert_eq!(config.log_level, "info");
|
assert_eq!(config.log_level, "info");
|
||||||
assert_eq!(config.host_ip, "127.0.0.1");
|
assert_eq!(config.host_ip, "127.0.0.1");
|
||||||
|
|||||||
@ -145,6 +145,7 @@ impl DockerPackageScanner {
|
|||||||
|
|
||||||
let package = PackageDataEntry {
|
let package = PackageDataEntry {
|
||||||
state: package_state.clone(),
|
state: package_state.clone(),
|
||||||
|
health: container.health.clone(),
|
||||||
static_files: StaticFiles {
|
static_files: StaticFiles {
|
||||||
license: "MIT".to_string(),
|
license: "MIT".to_string(),
|
||||||
instructions: metadata.description.clone(),
|
instructions: metadata.description.clone(),
|
||||||
@ -592,9 +593,8 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
|
|||||||
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
|
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
|
||||||
match container_state {
|
match container_state {
|
||||||
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
|
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
|
||||||
ContainerState::Stopped | ContainerState::Exited => {
|
ContainerState::Stopped => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||||
(PackageState::Stopped, ServiceStatus::Stopped)
|
ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped),
|
||||||
}
|
|
||||||
ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped),
|
ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||||
ContainerState::Paused => (PackageState::Stopped, ServiceStatus::Stopped),
|
ContainerState::Paused => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||||
ContainerState::Unknown(_) => (PackageState::Stopped, ServiceStatus::Stopped),
|
ContainerState::Unknown(_) => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||||
@ -607,6 +607,7 @@ fn package_state_str(state: &PackageState) -> &str {
|
|||||||
PackageState::Installed => "installed",
|
PackageState::Installed => "installed",
|
||||||
PackageState::Stopping => "stopping",
|
PackageState::Stopping => "stopping",
|
||||||
PackageState::Stopped => "stopped",
|
PackageState::Stopped => "stopped",
|
||||||
|
PackageState::Exited => "exited",
|
||||||
PackageState::Starting => "starting",
|
PackageState::Starting => "starting",
|
||||||
PackageState::Running => "running",
|
PackageState::Running => "running",
|
||||||
PackageState::Restarting => "restarting",
|
PackageState::Restarting => "restarting",
|
||||||
|
|||||||
@ -11,11 +11,64 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
const PID_FILE: &str = "archipelago.pid";
|
const PID_FILE: &str = "archipelago.pid";
|
||||||
const CONTAINER_STATE_FILE: &str = "running-containers.json";
|
const CONTAINER_STATE_FILE: &str = "running-containers.json";
|
||||||
|
const USER_STOPPED_FILE: &str = "user-stopped.json";
|
||||||
|
|
||||||
|
/// Shared flag: true once boot recovery is complete. Health monitor should wait for this.
|
||||||
|
pub static RECOVERY_COMPLETE: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Mark boot recovery as complete. Call after crash recovery + start_stopped_containers finish.
|
||||||
|
pub fn mark_recovery_complete() {
|
||||||
|
RECOVERY_COMPLETE.store(true, Ordering::SeqCst);
|
||||||
|
info!("Boot recovery complete — health monitor may proceed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if boot recovery is done.
|
||||||
|
pub fn is_recovery_complete() -> bool {
|
||||||
|
RECOVERY_COMPLETE.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User-stopped tracking ───────────────────────────────────────────────
|
||||||
|
// When a user explicitly stops a container via the UI, we record it here
|
||||||
|
// so crash recovery and health monitor don't auto-restart it.
|
||||||
|
|
||||||
|
/// Load the set of user-stopped containers from disk.
|
||||||
|
pub async fn load_user_stopped(data_dir: &Path) -> std::collections::HashSet<String> {
|
||||||
|
let path = data_dir.join(USER_STOPPED_FILE);
|
||||||
|
match fs::read_to_string(&path).await {
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||||
|
Err(_) => std::collections::HashSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the set of user-stopped containers to disk.
|
||||||
|
pub async fn save_user_stopped(data_dir: &Path, stopped: &std::collections::HashSet<String>) {
|
||||||
|
let path = data_dir.join(USER_STOPPED_FILE);
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(stopped) {
|
||||||
|
let _ = fs::write(&path, json).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a container as user-stopped (won't be auto-restarted).
|
||||||
|
pub async fn mark_user_stopped(data_dir: &Path, name: &str) {
|
||||||
|
let mut stopped = load_user_stopped(data_dir).await;
|
||||||
|
stopped.insert(name.to_string());
|
||||||
|
save_user_stopped(data_dir, &stopped).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear user-stopped flag (container was manually started by user).
|
||||||
|
pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
|
||||||
|
let mut stopped = load_user_stopped(data_dir).await;
|
||||||
|
if stopped.remove(name) {
|
||||||
|
save_user_stopped(data_dir, &stopped).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RunningContainerRecord {
|
pub struct RunningContainerRecord {
|
||||||
@ -241,7 +294,8 @@ fn is_process_running(pid: u32) -> bool {
|
|||||||
/// Start all stopped containers that were previously installed.
|
/// Start all stopped containers that were previously installed.
|
||||||
/// Runs on every startup to ensure containers come back after clean reboots.
|
/// Runs on every startup to ensure containers come back after clean reboots.
|
||||||
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
|
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
|
||||||
pub async fn start_stopped_containers() -> RecoveryReport {
|
/// Skips containers that the user intentionally stopped via the UI.
|
||||||
|
pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
|
||||||
let output = match tokio::time::timeout(
|
let output = match tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(30),
|
std::time::Duration::from_secs(30),
|
||||||
tokio::process::Command::new("podman")
|
tokio::process::Command::new("podman")
|
||||||
@ -257,7 +311,7 @@ pub async fn start_stopped_containers() -> RecoveryReport {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let names: Vec<String> = match output {
|
let all_names: Vec<String> = match output {
|
||||||
Ok(o) if o.status.success() => {
|
Ok(o) if o.status.success() => {
|
||||||
String::from_utf8_lossy(&o.stdout)
|
String::from_utf8_lossy(&o.stdout)
|
||||||
.lines()
|
.lines()
|
||||||
@ -268,17 +322,52 @@ pub async fn start_stopped_containers() -> RecoveryReport {
|
|||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if all_names.is_empty() {
|
||||||
|
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out user-stopped containers
|
||||||
|
let user_stopped = load_user_stopped(data_dir).await;
|
||||||
|
let names: Vec<String> = all_names.into_iter()
|
||||||
|
.filter(|n| {
|
||||||
|
if user_stopped.contains(n) {
|
||||||
|
info!("Skipping user-stopped container: {}", n);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
if names.is_empty() {
|
if names.is_empty() {
|
||||||
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
|
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Starting {} stopped containers after boot...", names.len());
|
// Sort by startup tier: databases first, then core, then dependent services, then apps
|
||||||
let records: Vec<RunningContainerRecord> = names.iter()
|
let mut records: Vec<RunningContainerRecord> = names.iter()
|
||||||
.map(|n| RunningContainerRecord { name: n.clone(), image: String::new() })
|
.map(|n| RunningContainerRecord { name: n.clone(), image: String::new() })
|
||||||
.collect();
|
.collect();
|
||||||
|
records.sort_by_key(|r| container_boot_tier(&r.name));
|
||||||
|
|
||||||
|
info!("Starting {} stopped containers after boot (skipped {} user-stopped)...",
|
||||||
|
records.len(), user_stopped.len());
|
||||||
recover_containers(&records).await
|
recover_containers(&records).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simple tier ordering for boot recovery (mirrors health_monitor tiers).
|
||||||
|
fn container_boot_tier(name: &str) -> u8 {
|
||||||
|
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||||
|
match id {
|
||||||
|
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
|
||||||
|
| "immich_redis" | "penpot-valkey" => 0,
|
||||||
|
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 1,
|
||||||
|
"lnd" | "electrumx" | "mempool-electrs" | "electrs" | "nbxplorer" => 2,
|
||||||
|
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui"
|
||||||
|
| "penpot-frontend" | "penpot-exporter" => 4,
|
||||||
|
_ => 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a background task that periodically saves the container snapshot.
|
/// Spawn a background task that periodically saves the container snapshot.
|
||||||
pub fn spawn_snapshot_task(data_dir: PathBuf) {
|
pub fn spawn_snapshot_task(data_dir: PathBuf) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|||||||
@ -102,6 +102,7 @@ pub enum PackageState {
|
|||||||
Installed,
|
Installed,
|
||||||
Stopping,
|
Stopping,
|
||||||
Stopped,
|
Stopped,
|
||||||
|
Exited,
|
||||||
Starting,
|
Starting,
|
||||||
Running,
|
Running,
|
||||||
Restarting,
|
Restarting,
|
||||||
@ -117,6 +118,9 @@ pub enum PackageState {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct PackageDataEntry {
|
pub struct PackageDataEntry {
|
||||||
pub state: PackageState,
|
pub state: PackageState,
|
||||||
|
/// Container health: "healthy", "unhealthy", "starting", or null
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub health: Option<String>,
|
||||||
#[serde(rename = "static-files")]
|
#[serde(rename = "static-files")]
|
||||||
pub static_files: StaticFiles,
|
pub static_files: StaticFiles,
|
||||||
pub manifest: Manifest,
|
pub manifest: Manifest,
|
||||||
|
|||||||
@ -350,8 +350,26 @@ async fn restart_container(name: &str) -> bool {
|
|||||||
/// Spawn the health monitor background task.
|
/// Spawn the health monitor background task.
|
||||||
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Wait 2 minutes for containers to start up
|
// Wait for boot recovery to complete before starting health checks.
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(120)).await;
|
// This prevents the health monitor from fighting with crash_recovery
|
||||||
|
// which is starting containers in tier order.
|
||||||
|
info!("Health monitor: waiting for boot recovery to complete...");
|
||||||
|
let wait_start = std::time::Instant::now();
|
||||||
|
loop {
|
||||||
|
if crate::crash_recovery::is_recovery_complete() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Safety timeout: start anyway after 5 minutes even if recovery hangs
|
||||||
|
if wait_start.elapsed().as_secs() > 300 {
|
||||||
|
warn!("Health monitor: boot recovery did not complete within 5 minutes, starting anyway");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
// Additional cooldown after recovery to let containers stabilize
|
||||||
|
info!("Health monitor: recovery done, waiting 60s for containers to stabilize...");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||||
|
info!("Health monitor: starting health checks");
|
||||||
|
|
||||||
let mut tracker = RestartTracker::new();
|
let mut tracker = RestartTracker::new();
|
||||||
let mut mem_tracker = MemoryTracker::new();
|
let mut mem_tracker = MemoryTracker::new();
|
||||||
@ -378,6 +396,9 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load user-stopped list to skip intentionally stopped containers
|
||||||
|
let user_stopped = crate::crash_recovery::load_user_stopped(&data_dir).await;
|
||||||
|
|
||||||
// Sort containers by startup tier so databases restart before dependent services
|
// Sort containers by startup tier so databases restart before dependent services
|
||||||
let mut unhealthy: Vec<&ContainerHealth> = Vec::new();
|
let mut unhealthy: Vec<&ContainerHealth> = Vec::new();
|
||||||
let mut state_changed = false;
|
let mut state_changed = false;
|
||||||
@ -392,6 +413,11 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if container.state == "exited" || container.state == "stopped" {
|
if container.state == "exited" || container.state == "stopped" {
|
||||||
|
// Skip user-stopped containers
|
||||||
|
if user_stopped.contains(&container.name) {
|
||||||
|
debug!("Skipping user-stopped container: {}", container.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
unhealthy.push(container);
|
unhealthy.push(container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,13 +92,17 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start any stopped containers (handles clean reboot)
|
// Start any stopped containers (handles clean reboot)
|
||||||
let boot_report = crash_recovery::start_stopped_containers().await;
|
// Skips user-stopped containers, uses tier ordering
|
||||||
|
let boot_report = crash_recovery::start_stopped_containers(&data_dir).await;
|
||||||
if boot_report.total > 0 {
|
if boot_report.total > 0 {
|
||||||
info!(
|
info!(
|
||||||
"🔄 Boot startup: {}/{} containers started (failed: {:?})",
|
"🔄 Boot startup: {}/{} containers started (failed: {:?})",
|
||||||
boot_report.recovered, boot_report.total, boot_report.failed
|
boot_report.recovered, boot_report.total, boot_report.failed
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signal to health monitor that boot recovery is done
|
||||||
|
crash_recovery::mark_recovery_complete();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -121,8 +121,24 @@ impl DwnStore {
|
|||||||
Ok(message)
|
Ok(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate a record ID to prevent path traversal.
|
||||||
|
fn validate_record_id(record_id: &str) -> Result<()> {
|
||||||
|
if record_id.is_empty()
|
||||||
|
|| record_id.len() > 128
|
||||||
|
|| !record_id
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid record ID (alphanumeric, hyphens, underscores only)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Read a message by record ID.
|
/// Read a message by record ID.
|
||||||
pub async fn read_message(&self, record_id: &str) -> Result<Option<DwnMessage>> {
|
pub async fn read_message(&self, record_id: &str) -> Result<Option<DwnMessage>> {
|
||||||
|
Self::validate_record_id(record_id)?;
|
||||||
let path = self.messages_dir.join(format!("{}.json", record_id));
|
let path = self.messages_dir.join(format!("{}.json", record_id));
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@ -137,6 +153,7 @@ impl DwnStore {
|
|||||||
|
|
||||||
/// Delete a message by record ID.
|
/// Delete a message by record ID.
|
||||||
pub async fn delete_message(&self, record_id: &str) -> Result<bool> {
|
pub async fn delete_message(&self, record_id: &str) -> Result<bool> {
|
||||||
|
Self::validate_record_id(record_id)?;
|
||||||
let path = self.messages_dir.join(format!("{}.json", record_id));
|
let path = self.messages_dir.join(format!("{}.json", record_id));
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
|
|||||||
@ -322,11 +322,37 @@ pub async fn save_router_config(data_dir: &Path, config: &RouterConfig) -> Resul
|
|||||||
fs::write(&path, data).await.context("Writing router config")
|
fs::write(&path, data).await.context("Writing router config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate that an IP string is a private/LAN address (not public, not localhost).
|
||||||
|
fn is_valid_private_ip(ip_str: &str) -> bool {
|
||||||
|
let ip: std::net::IpAddr = match ip_str.parse() {
|
||||||
|
Ok(ip) => ip,
|
||||||
|
Err(_) => return false, // Reject hostnames
|
||||||
|
};
|
||||||
|
match ip {
|
||||||
|
std::net::IpAddr::V4(v4) => {
|
||||||
|
// Allow only RFC1918 private ranges, reject localhost and public
|
||||||
|
let octets = v4.octets();
|
||||||
|
let is_10 = octets[0] == 10;
|
||||||
|
let is_172_private = octets[0] == 172 && (16..=31).contains(&octets[1]);
|
||||||
|
let is_192_168 = octets[0] == 192 && octets[1] == 168;
|
||||||
|
is_10 || is_172_private || is_192_168
|
||||||
|
}
|
||||||
|
std::net::IpAddr::V6(_) => false, // Reject IPv6 for gateway detection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Detect router type by probing common endpoints on the gateway.
|
/// Detect router type by probing common endpoints on the gateway.
|
||||||
pub async fn detect_router_type(gateway_ip: &str) -> RouterType {
|
pub async fn detect_router_type(gateway_ip: &str) -> RouterType {
|
||||||
|
// Validate that gateway is a private IP — prevent SSRF to arbitrary hosts
|
||||||
|
if !is_valid_private_ip(gateway_ip) {
|
||||||
|
tracing::warn!(gateway = gateway_ip, "Rejected non-private gateway IP");
|
||||||
|
return RouterType::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(5))
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
.danger_accept_invalid_certs(true)
|
.danger_accept_invalid_certs(true)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
|||||||
@ -158,17 +158,77 @@ pub async fn get_stats(data_dir: &Path) -> Result<RelayStats> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize a relay URL (ensure wss:// prefix).
|
/// Normalize and validate a relay URL.
|
||||||
|
/// Only allows wss:// scheme (not ws://) for security.
|
||||||
|
/// Rejects URLs pointing to private/internal IPs.
|
||||||
fn normalize_relay_url(url: &str) -> Result<String> {
|
fn normalize_relay_url(url: &str) -> Result<String> {
|
||||||
let trimmed = url.trim();
|
let trimmed = url.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Err(anyhow::anyhow!("Relay URL cannot be empty"));
|
return Err(anyhow::anyhow!("Relay URL cannot be empty"));
|
||||||
}
|
}
|
||||||
if trimmed.starts_with("wss://") || trimmed.starts_with("ws://") {
|
if trimmed.len() > 2048 {
|
||||||
Ok(trimmed.to_string())
|
return Err(anyhow::anyhow!("Relay URL too long"));
|
||||||
} else {
|
|
||||||
Ok(format!("wss://{}", trimmed))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply wss:// prefix if no scheme
|
||||||
|
let with_scheme = if trimmed.starts_with("wss://") || trimmed.starts_with("ws://") {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("wss://{}", trimmed)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only allow wss:// scheme for security
|
||||||
|
if !with_scheme.starts_with("wss://") {
|
||||||
|
return Err(anyhow::anyhow!("Relay URL must use wss:// scheme"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract host portion for SSRF validation
|
||||||
|
let host = with_scheme
|
||||||
|
.strip_prefix("wss://")
|
||||||
|
.unwrap_or("")
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.split(':')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if host.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("Relay URL must have a valid host"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject private/internal addresses
|
||||||
|
if is_relay_host_private(host) {
|
||||||
|
return Err(anyhow::anyhow!("Relay URL must not point to private/local addresses"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(with_scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a relay host points to a private or internal address.
|
||||||
|
fn is_relay_host_private(host: &str) -> bool {
|
||||||
|
let lower = host.to_lowercase();
|
||||||
|
if lower == "localhost" || lower == "localhost.localdomain" || lower.ends_with(".local") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||||
|
return match ip {
|
||||||
|
std::net::IpAddr::V4(v4) => {
|
||||||
|
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
|
||||||
|
}
|
||||||
|
std::net::IpAddr::V6(v6) => {
|
||||||
|
if v6.is_loopback() || v6.is_unspecified() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||||
|
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
|
||||||
|
}
|
||||||
|
let segments = v6.segments();
|
||||||
|
(segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -183,9 +243,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_relay_url_with_ws() {
|
fn test_normalize_relay_url_rejects_ws() {
|
||||||
let result = normalize_relay_url("ws://relay.example.com").unwrap();
|
let result = normalize_relay_url("ws://relay.example.com");
|
||||||
assert_eq!(result, "ws://relay.example.com");
|
assert!(result.is_err(), "ws:// scheme should be rejected — only wss:// is allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -209,6 +269,14 @@ mod tests {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_relay_url_rejects_private_ips() {
|
||||||
|
assert!(normalize_relay_url("wss://127.0.0.1").is_err());
|
||||||
|
assert!(normalize_relay_url("wss://localhost").is_err());
|
||||||
|
assert!(normalize_relay_url("wss://192.168.1.1").is_err());
|
||||||
|
assert!(normalize_relay_url("wss://10.0.0.1").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_seed_defaults_has_expected_count() {
|
fn test_seed_defaults_has_expected_count() {
|
||||||
let store = seed_defaults();
|
let store = seed_defaults();
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
|
use rand::RngCore;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
@ -234,16 +235,31 @@ impl SessionStore {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upgrade a pending session to a full session.
|
/// Upgrade a pending session to a full session with token rotation.
|
||||||
pub async fn upgrade_to_full(&self, token: &str) {
|
/// Deletes the old pending session and creates a new full session with a fresh token.
|
||||||
let hash = hash_token(token);
|
/// Returns the new plaintext token so the caller can set it as the new cookie.
|
||||||
|
pub async fn upgrade_to_full(&self, token: &str) -> Option<String> {
|
||||||
|
let old_hash = hash_token(token);
|
||||||
let mut sessions = self.sessions.write().await;
|
let mut sessions = self.sessions.write().await;
|
||||||
if let Some(session) = sessions.get_mut(&hash) {
|
// Only upgrade if the old session exists and is pending
|
||||||
session.session_type = SessionType::Full;
|
if sessions.remove(&old_hash).is_some() {
|
||||||
|
let new_token_bytes: [u8; 32] = rand::random();
|
||||||
|
let new_token = hex::encode(new_token_bytes);
|
||||||
|
let new_hash = hash_token(&new_token);
|
||||||
let now = SystemTime::now();
|
let now = SystemTime::now();
|
||||||
session.created_at = now;
|
self.evict_if_over_limit(&mut sessions);
|
||||||
session.last_activity = now;
|
sessions.insert(
|
||||||
|
new_hash,
|
||||||
|
Session {
|
||||||
|
created_at: now,
|
||||||
|
last_activity: now,
|
||||||
|
session_type: SessionType::Full,
|
||||||
|
},
|
||||||
|
);
|
||||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||||
|
Some(new_token)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -393,25 +409,21 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_or_create_remember_secret() -> Vec<u8> {
|
pub fn load_or_create_remember_secret() -> Vec<u8> {
|
||||||
// Try existing secret file first (backwards compatibility)
|
// Try existing secret file first
|
||||||
if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) {
|
if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) {
|
||||||
if secret.len() == 32 {
|
if secret.len() == 32 {
|
||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Derive a deterministic secret from machine-id so it survives restarts
|
// Generate a cryptographically random 32-byte secret on first boot
|
||||||
// without storing plaintext key material
|
let mut secret = [0u8; 32];
|
||||||
let machine_id = std::fs::read_to_string("/etc/machine-id")
|
rand::rngs::OsRng.fill_bytes(&mut secret);
|
||||||
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string());
|
// Ensure parent directory exists
|
||||||
let salt = b"archipelago-remember-me-v1";
|
if let Some(parent) = std::path::Path::new(REMEMBER_SECRET_FILE).parent() {
|
||||||
let mut hasher = sha2::Sha256::new();
|
let _ = std::fs::create_dir_all(parent);
|
||||||
use sha2::Digest;
|
}
|
||||||
hasher.update(machine_id.trim().as_bytes());
|
let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret);
|
||||||
hasher.update(salt);
|
secret.to_vec()
|
||||||
let secret = hasher.finalize();
|
|
||||||
let secret_vec = secret.to_vec();
|
|
||||||
let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret_vec);
|
|
||||||
secret_vec
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -605,9 +617,15 @@ mod tests {
|
|||||||
let got = store.get_pending_secret(&token).await;
|
let got = store.get_pending_secret(&token).await;
|
||||||
assert_eq!(got, Some(secret));
|
assert_eq!(got, Some(secret));
|
||||||
|
|
||||||
// Upgrade to full
|
// Upgrade to full — returns a new rotated token
|
||||||
store.upgrade_to_full(&token).await;
|
let new_token = store.upgrade_to_full(&token).await;
|
||||||
assert!(store.validate(&token).await);
|
assert!(new_token.is_some());
|
||||||
|
let new_token = new_token.unwrap();
|
||||||
|
|
||||||
|
// Old token should be invalid (rotated)
|
||||||
|
assert!(!store.validate(&token).await);
|
||||||
|
// New token should be valid
|
||||||
|
assert!(store.validate(&new_token).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@ -124,6 +124,7 @@ async fn send_http_webhook(
|
|||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
.build()
|
.build()
|
||||||
.context("Failed to create HTTP client")?;
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
|
|||||||
@ -20,12 +20,26 @@ pub struct ContainerStatus {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub state: ContainerState,
|
pub state: ContainerState,
|
||||||
|
pub health: Option<String>, // "healthy", "unhealthy", "starting", or None if no healthcheck
|
||||||
|
pub started_at: Option<String>,
|
||||||
pub image: String,
|
pub image: String,
|
||||||
pub created: String,
|
pub created: String,
|
||||||
pub ports: Vec<String>,
|
pub ports: Vec<String>,
|
||||||
pub lan_address: Option<String>, // Launch URL for UI access
|
pub lan_address: Option<String>, // Launch URL for UI access
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse health status from podman's Status string (e.g., "Up 5 minutes (healthy)")
|
||||||
|
fn parse_health_from_status(status: &str) -> Option<String> {
|
||||||
|
if let Some(start) = status.rfind('(') {
|
||||||
|
if let Some(end) = status.rfind(')') {
|
||||||
|
if start < end {
|
||||||
|
return Some(status[start + 1..end].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub enum ContainerState {
|
pub enum ContainerState {
|
||||||
Created,
|
Created,
|
||||||
@ -301,6 +315,8 @@ impl PodmanClient {
|
|||||||
id: parts[0].to_string(),
|
id: parts[0].to_string(),
|
||||||
name: parts[1].to_string(),
|
name: parts[1].to_string(),
|
||||||
state: ContainerState::from(parts[2]),
|
state: ContainerState::from(parts[2]),
|
||||||
|
health: None,
|
||||||
|
started_at: None,
|
||||||
image: parts[3].to_string(),
|
image: parts[3].to_string(),
|
||||||
created: parts[4].to_string(),
|
created: parts[4].to_string(),
|
||||||
ports: vec![], // TODO: Parse ports from parts[5]
|
ports: vec![], // TODO: Parse ports from parts[5]
|
||||||
@ -387,10 +403,15 @@ impl PodmanClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let lan_address = Self::lan_address_for(&name);
|
let lan_address = Self::lan_address_for(&name);
|
||||||
|
let status_str = container["Status"].as_str().unwrap_or("");
|
||||||
|
let health = parse_health_from_status(status_str);
|
||||||
|
let started_at = container["StartedAt"].as_str().or_else(|| container["Started"].as_str()).map(|s| s.to_string());
|
||||||
result.push(ContainerStatus {
|
result.push(ContainerStatus {
|
||||||
id: container["Id"].as_str().unwrap_or("").to_string(),
|
id: container["Id"].as_str().unwrap_or("").to_string(),
|
||||||
name,
|
name,
|
||||||
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
|
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
|
||||||
|
health,
|
||||||
|
started_at,
|
||||||
image: container["Image"].as_str().unwrap_or("").to_string(),
|
image: container["Image"].as_str().unwrap_or("").to_string(),
|
||||||
created: container["Created"].as_str().unwrap_or("").to_string(),
|
created: container["Created"].as_str().unwrap_or("").to_string(),
|
||||||
ports,
|
ports,
|
||||||
@ -431,10 +452,15 @@ impl PodmanClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let lan_address = Self::lan_address_for(&name);
|
let lan_address = Self::lan_address_for(&name);
|
||||||
|
let status_str = container["Status"].as_str().unwrap_or("");
|
||||||
|
let health = parse_health_from_status(status_str);
|
||||||
|
let started_at = container["StartedAt"].as_str().or_else(|| container["Started"].as_str()).map(|s| s.to_string());
|
||||||
result.push(ContainerStatus {
|
result.push(ContainerStatus {
|
||||||
id: container["Id"].as_str().unwrap_or("").to_string(),
|
id: container["Id"].as_str().unwrap_or("").to_string(),
|
||||||
name,
|
name,
|
||||||
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
|
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
|
||||||
|
health,
|
||||||
|
started_at,
|
||||||
image: container["Image"].as_str().unwrap_or("").to_string(),
|
image: container["Image"].as_str().unwrap_or("").to_string(),
|
||||||
created: container["Created"].as_str().unwrap_or("").to_string(),
|
created: container["Created"].as_str().unwrap_or("").to_string(),
|
||||||
ports,
|
ports,
|
||||||
|
|||||||
@ -284,6 +284,8 @@ impl ContainerRuntime for DockerRuntime {
|
|||||||
id: parts[0].to_string(),
|
id: parts[0].to_string(),
|
||||||
name: parts[1].to_string(),
|
name: parts[1].to_string(),
|
||||||
state: crate::podman_client::ContainerState::from(parts[2]),
|
state: crate::podman_client::ContainerState::from(parts[2]),
|
||||||
|
health: None,
|
||||||
|
started_at: None,
|
||||||
image: parts[3].to_string(),
|
image: parts[3].to_string(),
|
||||||
created: parts[4].to_string(),
|
created: parts[4].to_string(),
|
||||||
ports: vec![],
|
ports: vec![],
|
||||||
@ -356,6 +358,8 @@ impl ContainerRuntime for DockerRuntime {
|
|||||||
state: ContainerState::from(
|
state: ContainerState::from(
|
||||||
container["State"].as_str().unwrap_or("unknown")
|
container["State"].as_str().unwrap_or("unknown")
|
||||||
),
|
),
|
||||||
|
health: None,
|
||||||
|
started_at: None,
|
||||||
image: container["Image"].as_str().unwrap_or("").to_string(),
|
image: container["Image"].as_str().unwrap_or("").to_string(),
|
||||||
created: container["CreatedAt"].as_str().unwrap_or("").to_string(),
|
created: container["CreatedAt"].as_str().unwrap_or("").to_string(),
|
||||||
ports,
|
ports,
|
||||||
|
|||||||
@ -773,6 +773,7 @@ HiddenServicePort 50001 127.0.0.1:50001
|
|||||||
|
|
||||||
HiddenServiceDir $TOR_DIR/hidden_service_lnd
|
HiddenServiceDir $TOR_DIR/hidden_service_lnd
|
||||||
HiddenServicePort 9735 127.0.0.1:9735
|
HiddenServicePort 9735 127.0.0.1:9735
|
||||||
|
HiddenServicePort 8080 127.0.0.1:8080
|
||||||
|
|
||||||
HiddenServiceDir $TOR_DIR/hidden_service_btcpay
|
HiddenServiceDir $TOR_DIR/hidden_service_btcpay
|
||||||
HiddenServicePort 23000 127.0.0.1:23000
|
HiddenServicePort 23000 127.0.0.1:23000
|
||||||
|
|||||||
@ -17,7 +17,7 @@ server {
|
|||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
add_header X-DNS-Prefetch-Control "off" always;
|
add_header X-DNS-Prefetch-Control "off" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; 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;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; 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;
|
||||||
|
|
||||||
# AIUI SPA (Chat mode iframe)
|
# AIUI SPA (Chat mode iframe)
|
||||||
# Use =404 fallback instead of index.html to prevent serving HTML with wrong
|
# Use =404 fallback instead of index.html to prevent serving HTML with wrong
|
||||||
@ -37,7 +37,7 @@ server {
|
|||||||
|
|
||||||
# AIUI Claude API proxy — requires valid session cookie
|
# AIUI Claude API proxy — requires valid session cookie
|
||||||
location /aiui/api/claude/ {
|
location /aiui/api/claude/ {
|
||||||
if ($cookie_session_id = "") {
|
if ($cookie_session = "") {
|
||||||
return 401 '{"error":"Unauthorized"}';
|
return 401 '{"error":"Unauthorized"}';
|
||||||
}
|
}
|
||||||
proxy_pass http://127.0.0.1:3142/;
|
proxy_pass http://127.0.0.1:3142/;
|
||||||
@ -54,7 +54,7 @@ server {
|
|||||||
|
|
||||||
# AIUI OpenRouter API proxy — requires valid session cookie
|
# AIUI OpenRouter API proxy — requires valid session cookie
|
||||||
location /aiui/api/openrouter/ {
|
location /aiui/api/openrouter/ {
|
||||||
if ($cookie_session_id = "") {
|
if ($cookie_session = "") {
|
||||||
return 401 '{"error":"Unauthorized"}';
|
return 401 '{"error":"Unauthorized"}';
|
||||||
}
|
}
|
||||||
proxy_pass https://openrouter.ai/api/;
|
proxy_pass https://openrouter.ai/api/;
|
||||||
@ -69,7 +69,7 @@ server {
|
|||||||
|
|
||||||
# AIUI Ollama (local AI) proxy — localhost:11434
|
# AIUI Ollama (local AI) proxy — localhost:11434
|
||||||
location /aiui/api/ollama/ {
|
location /aiui/api/ollama/ {
|
||||||
if ($cookie_session_id = "") {
|
if ($cookie_session = "") {
|
||||||
return 401 '{"error":"Unauthorized"}';
|
return 401 '{"error":"Unauthorized"}';
|
||||||
}
|
}
|
||||||
proxy_pass http://127.0.0.1:11434/;
|
proxy_pass http://127.0.0.1:11434/;
|
||||||
@ -85,7 +85,7 @@ server {
|
|||||||
|
|
||||||
# AIUI web search proxy — SearXNG on port 8888
|
# AIUI web search proxy — SearXNG on port 8888
|
||||||
location /aiui/api/web-search {
|
location /aiui/api/web-search {
|
||||||
if ($cookie_session_id = "") {
|
if ($cookie_session = "") {
|
||||||
return 401 '{"error":"Unauthorized"}';
|
return 401 '{"error":"Unauthorized"}';
|
||||||
}
|
}
|
||||||
proxy_pass http://127.0.0.1:8888/search;
|
proxy_pass http://127.0.0.1:8888/search;
|
||||||
@ -154,7 +154,7 @@ server {
|
|||||||
|
|
||||||
location /lnd-connect-info {
|
location /lnd-connect-info {
|
||||||
# Requires authenticated session — exposes LND admin macaroon
|
# Requires authenticated session — exposes LND admin macaroon
|
||||||
if ($cookie_session_id = "") { return 401; }
|
if ($cookie_session = "") { return 401; }
|
||||||
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
|
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@ -725,7 +725,7 @@ server {
|
|||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
add_header X-DNS-Prefetch-Control "off" always;
|
add_header X-DNS-Prefetch-Control "off" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; 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;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; 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;
|
||||||
|
|
||||||
# AIUI SPA (Chat mode iframe)
|
# AIUI SPA (Chat mode iframe)
|
||||||
location /aiui/ {
|
location /aiui/ {
|
||||||
@ -735,7 +735,7 @@ server {
|
|||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
}
|
}
|
||||||
location /aiui/api/claude/ {
|
location /aiui/api/claude/ {
|
||||||
if ($cookie_session_id = "") {
|
if ($cookie_session = "") {
|
||||||
return 401 '{"error":"Unauthorized"}';
|
return 401 '{"error":"Unauthorized"}';
|
||||||
}
|
}
|
||||||
proxy_pass http://127.0.0.1:3142/;
|
proxy_pass http://127.0.0.1:3142/;
|
||||||
@ -750,7 +750,7 @@ server {
|
|||||||
proxy_send_timeout 120s;
|
proxy_send_timeout 120s;
|
||||||
}
|
}
|
||||||
location /aiui/api/ollama/ {
|
location /aiui/api/ollama/ {
|
||||||
if ($cookie_session_id = "") {
|
if ($cookie_session = "") {
|
||||||
return 401 '{"error":"Unauthorized"}';
|
return 401 '{"error":"Unauthorized"}';
|
||||||
}
|
}
|
||||||
proxy_pass http://127.0.0.1:11434/;
|
proxy_pass http://127.0.0.1:11434/;
|
||||||
@ -764,7 +764,7 @@ server {
|
|||||||
# Connection header managed by nginx default
|
# Connection header managed by nginx default
|
||||||
}
|
}
|
||||||
location /aiui/api/openrouter/ {
|
location /aiui/api/openrouter/ {
|
||||||
if ($cookie_session_id = "") {
|
if ($cookie_session = "") {
|
||||||
return 401 '{"error":"Unauthorized"}';
|
return 401 '{"error":"Unauthorized"}';
|
||||||
}
|
}
|
||||||
proxy_pass https://openrouter.ai/api/;
|
proxy_pass https://openrouter.ai/api/;
|
||||||
@ -808,7 +808,7 @@ server {
|
|||||||
|
|
||||||
location /lnd-connect-info {
|
location /lnd-connect-info {
|
||||||
# Requires authenticated session — exposes LND admin macaroon
|
# Requires authenticated session — exposes LND admin macaroon
|
||||||
if ($cookie_session_id = "") { return 401; }
|
if ($cookie_session = "") { return 401; }
|
||||||
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
|
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.g6vfn35hb3c"
|
"revision": "0.3ur9h1c6gak"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
@ -343,6 +343,7 @@ async function getDockerContainers() {
|
|||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
status: isRunning ? 'running' : 'stopped',
|
status: isRunning ? 'running' : 'stopped',
|
||||||
state: isRunning ? 'running' : 'stopped',
|
state: isRunning ? 'running' : 'stopped',
|
||||||
|
health: isRunning ? 'healthy' : null,
|
||||||
'static-files': {
|
'static-files': {
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
instructions: metadata.description,
|
instructions: metadata.description,
|
||||||
|
|||||||
3
neode-ui/public/assets/icon/web5.svg
Normal file
3
neode-ui/public/assets/icon/web5.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="1631" height="1624" viewBox="0 0 1631 1624" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 534 B |
@ -141,15 +141,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="paymentError" class="mb-3 p-2 bg-red-500/15 border border-red-500/20 rounded-lg">
|
<div v-if="paymentError" class="mb-3 alert-error">
|
||||||
<p class="text-red-400 text-xs">{{ paymentError }}</p>
|
<p class="text-xs">{{ paymentError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button @click="rejectPayment" class="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors">
|
<button @click="rejectPayment" class="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors">
|
||||||
Deny
|
Deny
|
||||||
</button>
|
</button>
|
||||||
<button @click="approvePayment" :disabled="paymentProcessing" class="flex-1 px-4 py-2.5 bg-orange-500/20 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-300 hover:bg-orange-500/30 transition-colors disabled:opacity-50">
|
<button @click="approvePayment" :disabled="paymentProcessing" class="flex-1 px-4 py-2.5 glass-button glass-button-warning rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
{{ paymentProcessing ? 'Paying...' : 'Approve' }}
|
{{ paymentProcessing ? 'Paying...' : 'Approve' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
89
neode-ui/src/components/BaseModal.vue
Normal file
89
neode-ui/src/components/BaseModal.vue
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal">
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 flex items-center justify-center p-4"
|
||||||
|
:class="zClass"
|
||||||
|
@click.self="close"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||||
|
<div
|
||||||
|
ref="modalRef"
|
||||||
|
class="glass-card p-6 w-full relative z-10"
|
||||||
|
:class="[maxWidth, contentClass]"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-4 shrink-0">
|
||||||
|
<h3 class="text-xl font-semibold text-white">{{ title }}</h3>
|
||||||
|
<button
|
||||||
|
@click="close"
|
||||||
|
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
show: boolean
|
||||||
|
title: string
|
||||||
|
maxWidth?: string
|
||||||
|
zIndex?: string
|
||||||
|
contentClass?: string
|
||||||
|
}>(), {
|
||||||
|
maxWidth: 'max-w-md',
|
||||||
|
zIndex: 'z-[3000]',
|
||||||
|
contentClass: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modalRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const zClass = computed(() => props.zIndex)
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
useModalKeyboard(modalRef, computed(() => props.show), close)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.modal-enter-active .glass-card,
|
||||||
|
.modal-leave-active .glass-card {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.modal-enter-from .glass-card {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
.modal-leave-to .glass-card {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -12,7 +12,18 @@
|
|||||||
:style="{ '--card-stagger': idx }"
|
:style="{ '--card-stagger': idx }"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4">
|
||||||
<div class="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
|
<!-- App icons for goals with required apps, emoji fallback otherwise -->
|
||||||
|
<div v-if="goalAppIcons(goal).length > 0" class="flex items-center gap-1.5 shrink-0">
|
||||||
|
<img
|
||||||
|
v-for="icon in goalAppIcons(goal)"
|
||||||
|
:key="icon.appId"
|
||||||
|
:src="icon.url"
|
||||||
|
:alt="icon.appId"
|
||||||
|
class="w-8 h-8 rounded-lg object-contain bg-white/5 border border-white/10 p-0.5"
|
||||||
|
@error="($event.target as HTMLImageElement).style.display = 'none'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
|
||||||
<span class="text-xl">{{ goalIcon(goal.icon) }}</span>
|
<span class="text-xl">{{ goalIcon(goal.icon) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="goal-status-badge" :class="statusBadgeClass(goal.id)">
|
<span class="goal-status-badge" :class="statusBadgeClass(goal.id)">
|
||||||
@ -28,7 +39,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-xs text-white/40">{{ goal.estimatedTime }}</span>
|
<span class="text-xs text-white/40">{{ goal.estimatedTime }}</span>
|
||||||
<span class="text-xs text-white/50 flex items-center gap-1">
|
<span class="text-xs text-white/50 flex items-center gap-1">
|
||||||
{{ goal.difficulty === 'beginner' ? 'Beginner' : 'Intermediate' }}
|
{{ goal.difficulty === 'beginner' ? t('easyHome.beginner') : t('easyHome.intermediate') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@ -37,18 +48,40 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { GOALS } from '@/data/goals'
|
import { GOALS } from '@/data/goals'
|
||||||
import { useGoalStore } from '@/stores/goals'
|
import { useGoalStore } from '@/stores/goals'
|
||||||
|
import type { GoalDefinition } from '@/types/goals'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
animate: boolean
|
animate: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const goalStore = useGoalStore()
|
const goalStore = useGoalStore()
|
||||||
const goals = GOALS
|
const goals = GOALS
|
||||||
const goalStatuses = goalStore.goalStatuses
|
const goalStatuses = goalStore.goalStatuses
|
||||||
|
|
||||||
|
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
||||||
|
const APP_ICON_MAP: Record<string, string> = {
|
||||||
|
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
|
||||||
|
lnd: '/assets/img/app-icons/lnd.svg',
|
||||||
|
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
|
||||||
|
immich: '/assets/img/app-icons/immich.png',
|
||||||
|
nextcloud: '/assets/img/app-icons/nextcloud.webp',
|
||||||
|
fedimint: '/assets/img/app-icons/fedimint.png',
|
||||||
|
mempool: '/assets/img/app-icons/mempool.webp',
|
||||||
|
electrs: '/assets/img/app-icons/electrs.svg',
|
||||||
|
'nostr-rs-relay': '/assets/img/app-icons/nostr-rs-relay.svg',
|
||||||
|
}
|
||||||
|
|
||||||
|
function goalAppIcons(goal: GoalDefinition): { appId: string; url: string }[] {
|
||||||
|
return goal.requiredApps
|
||||||
|
.filter((appId) => APP_ICON_MAP[appId] !== undefined)
|
||||||
|
.map((appId) => ({ appId, url: APP_ICON_MAP[appId] as string }))
|
||||||
|
}
|
||||||
|
|
||||||
function goalIcon(icon: string): string {
|
function goalIcon(icon: string): string {
|
||||||
const icons: Record<string, string> = {
|
const icons: Record<string, string> = {
|
||||||
shop: '🏪',
|
shop: '🏪',
|
||||||
@ -64,9 +97,9 @@ function goalIcon(icon: string): string {
|
|||||||
|
|
||||||
function statusLabel(goalId: string): string {
|
function statusLabel(goalId: string): string {
|
||||||
const status = goalStatuses[goalId]
|
const status = goalStatuses[goalId]
|
||||||
if (status === 'completed') return 'Done'
|
if (status === 'completed') return t('easyHome.done')
|
||||||
if (status === 'in-progress') return 'In Progress'
|
if (status === 'in-progress') return t('easyHome.inProgress')
|
||||||
return 'Start'
|
return t('easyHome.start')
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeClass(goalId: string): string {
|
function statusBadgeClass(goalId: string): string {
|
||||||
|
|||||||
@ -1,29 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<BaseModal :show="show" :title="title" max-width="max-w-lg" content-class="max-h-[80vh] overflow-y-auto" @close="$emit('close')">
|
||||||
<Transition name="modal">
|
|
||||||
<div
|
|
||||||
v-if="show"
|
|
||||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
|
||||||
@click="$emit('close')"
|
|
||||||
>
|
|
||||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
|
||||||
<div
|
|
||||||
ref="modalRef"
|
|
||||||
@click.stop
|
|
||||||
class="glass-card p-6 max-w-lg w-full relative z-10 max-h-[80vh] overflow-y-auto"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-4 mb-4">
|
|
||||||
<h3 class="text-xl font-semibold text-white">{{ title }}</h3>
|
|
||||||
<button
|
|
||||||
@click="$emit('close')"
|
|
||||||
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="text-white/80 prose prose-invert max-w-none">
|
<div class="text-white/80 prose prose-invert max-w-none">
|
||||||
<p class="whitespace-pre-wrap">{{ content }}</p>
|
<p class="whitespace-pre-wrap">{{ content }}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -39,27 +15,20 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</BaseModal>
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
relatedPath?: string
|
relatedPath?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const modalRef = ref<HTMLElement | null>(null)
|
|
||||||
useModalKeyboard(modalRef, computed(() => props.show), () => emit('close'))
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,59 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<BaseModal :show="showUpdatePrompt" title="Update Available" z-index="z-[9999]" @close="dismissUpdate">
|
||||||
<Transition name="modal">
|
|
||||||
<div
|
|
||||||
v-if="showUpdatePrompt"
|
|
||||||
class="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
|
||||||
@click.self="dismissUpdate"
|
|
||||||
>
|
|
||||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
|
||||||
<div
|
|
||||||
ref="modalRef"
|
|
||||||
class="glass-card p-6 max-w-md w-full relative z-10"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-4 mb-4">
|
|
||||||
<h3 class="text-xl font-semibold text-white">Update Available</h3>
|
|
||||||
<button
|
|
||||||
@click="dismissUpdate"
|
|
||||||
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
|
||||||
aria-label="Dismiss"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-white/80 mb-6">
|
<p class="text-white/80 mb-6">
|
||||||
A new version of Archipelago is available. Update now to get the latest features and fixes.
|
A new version of Archipelago is available. Update now to get the latest features and fixes.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-3 justify-end">
|
<template #footer>
|
||||||
<button
|
<div class="flex gap-3 justify-end">
|
||||||
@click="dismissUpdate"
|
<button
|
||||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
@click="dismissUpdate"
|
||||||
>
|
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||||
Later
|
>
|
||||||
</button>
|
Later
|
||||||
<button
|
</button>
|
||||||
@click="handleUpdate"
|
<button
|
||||||
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
@click="handleUpdate"
|
||||||
>
|
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||||
Update Now
|
>
|
||||||
</button>
|
Update Now
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</Transition>
|
</BaseModal>
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
|
|
||||||
const showUpdatePrompt = ref(false)
|
const showUpdatePrompt = ref(false)
|
||||||
let updateCallback: (() => Promise<void>) | null = null
|
let updateCallback: (() => Promise<void>) | null = null
|
||||||
const modalRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Listen for service worker updates
|
// Listen for service worker updates
|
||||||
@ -106,8 +80,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
useModalKeyboard(modalRef, showUpdatePrompt, dismissUpdate)
|
|
||||||
|
|
||||||
function dismissUpdate() {
|
function dismissUpdate() {
|
||||||
showUpdatePrompt.value = false
|
showUpdatePrompt.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<BaseModal :show="show" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
||||||
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="close" @keydown.escape="close">
|
|
||||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true">
|
|
||||||
<h2 class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
|
|
||||||
|
|
||||||
<!-- Method tabs -->
|
<!-- Method tabs -->
|
||||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||||
<button
|
<button
|
||||||
@ -12,24 +8,24 @@
|
|||||||
@click="receiveMethod = m"
|
@click="receiveMethod = m"
|
||||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
||||||
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||||
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
|
>{{ m === 'onchain' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : t('receiveBitcoin.ecash') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lightning -->
|
<!-- Lightning -->
|
||||||
<div v-if="receiveMethod === 'lightning'">
|
<div v-if="receiveMethod === 'lightning'">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
|
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.amountSats') }}</label>
|
||||||
<input v-model.number="invoiceAmount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model.number="invoiceAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">Memo (optional)</label>
|
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.memoOptional') }}</label>
|
||||||
<input v-model="invoiceMemo" type="text" placeholder="Payment for..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="invoiceMemo" type="text" :placeholder="t('receiveBitcoin.memoPlaceholder')" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
<div v-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
||||||
<canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
<canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
||||||
<p class="text-white/50 text-xs mb-1">Invoice (share with sender):</p>
|
<p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.invoiceShareLabel') }}</p>
|
||||||
<p class="text-xs font-mono text-white/80 break-all">{{ invoiceResult }}</p>
|
<p class="text-xs font-mono text-white/80 break-all">{{ invoiceResult }}</p>
|
||||||
<button @click="copyText(invoiceResult)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
|
<button @click="copyText(invoiceResult)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -37,9 +33,9 @@
|
|||||||
<div v-if="receiveMethod === 'onchain'">
|
<div v-if="receiveMethod === 'onchain'">
|
||||||
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
||||||
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
||||||
<p class="text-white/50 text-xs mb-2">Your Bitcoin address:</p>
|
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
|
||||||
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
|
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
|
||||||
<button @click="copyText(onchainAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
|
<button @click="copyText(onchainAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="mb-3 text-center">
|
<div v-else class="mb-3 text-center">
|
||||||
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
|
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
|
||||||
@ -49,29 +45,28 @@
|
|||||||
<!-- Ecash -->
|
<!-- Ecash -->
|
||||||
<div v-if="receiveMethod === 'ecash'">
|
<div v-if="receiveMethod === 'ecash'">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">Paste ecash token</label>
|
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
|
||||||
<textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"></textarea>
|
<textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass font-mono"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
|
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="mb-3 text-xs text-red-400">{{ error }}</div>
|
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||||
<button @click="receive" :disabled="processing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-green-500/20 border-green-500/30 disabled:opacity-50">
|
<button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
{{ processing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }}
|
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : t('receiveBitcoin.receive') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</BaseModal>
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@ -120,7 +115,7 @@ async function receive() {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
if (receiveMethod.value === 'lightning') {
|
if (receiveMethod.value === 'lightning') {
|
||||||
if (!invoiceAmount.value) { error.value = 'Enter an amount'; return }
|
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
|
||||||
const res = await rpcClient.call<{ payment_request: string }>({
|
const res = await rpcClient.call<{ payment_request: string }>({
|
||||||
method: 'lnd.addinvoice',
|
method: 'lnd.addinvoice',
|
||||||
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
|
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
|
||||||
@ -132,12 +127,12 @@ async function receive() {
|
|||||||
onchainAddress.value = res.address
|
onchainAddress.value = res.address
|
||||||
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
||||||
} else {
|
} else {
|
||||||
if (!ecashToken.value.trim()) { error.value = 'Paste an ecash token'; return }
|
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
|
||||||
await rpcClient.call<{ amount_sats: number }>({
|
await rpcClient.call<{ amount_sats: number }>({
|
||||||
method: 'wallet.ecash-receive',
|
method: 'wallet.ecash-receive',
|
||||||
params: { token: ecashToken.value.trim() },
|
params: { token: ecashToken.value.trim() },
|
||||||
})
|
})
|
||||||
ecashResult.value = 'Token received successfully!'
|
ecashResult.value = t('receiveBitcoin.tokenReceivedSuccess')
|
||||||
emit('received')
|
emit('received')
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
||||||
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="close" @keydown.escape="close">
|
|
||||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true">
|
|
||||||
<h2 class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
|
|
||||||
|
|
||||||
<!-- Method tabs -->
|
<!-- Method tabs -->
|
||||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||||
<button
|
<button
|
||||||
@ -12,55 +8,54 @@
|
|||||||
@click="sendMethod = m"
|
@click="sendMethod = m"
|
||||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
||||||
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||||
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
|
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : t('sendBitcoin.auto') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||||
<p class="text-xs text-white/50">Auto-selects method based on amount: ecash < 1k sats, Lightning 1k–500k, on-chain > 500k</p>
|
<p class="text-xs text-white/50">{{ t('sendBitcoin.autoMethodDesc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
|
<label class="text-white/60 text-sm block mb-1">{{ t('sendBitcoin.amountSats') }}</label>
|
||||||
<input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">
|
<label class="text-white/60 text-sm block mb-1">
|
||||||
{{ effectiveMethod === 'lightning' ? 'Lightning Invoice (BOLT11)' : 'Bitcoin Address' }}
|
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : t('sendBitcoin.bitcoinAddress') }}
|
||||||
</label>
|
</label>
|
||||||
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-white/30"></textarea>
|
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||||
<p class="text-white/50 text-xs mb-1">Token (share with recipient):</p>
|
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
|
||||||
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
|
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
|
||||||
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
|
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="resultTxid" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
|
<div v-if="resultTxid" class="mb-3 alert-success">
|
||||||
<p class="text-green-400 text-xs">Sent! TX: {{ resultTxid }}</p>
|
<p class="text-xs">{{ t('sendBitcoin.sentTx', { txid: resultTxid }) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="resultHash" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
|
<div v-if="resultHash" class="mb-3 alert-success">
|
||||||
<p class="text-green-400 text-xs">Paid! Hash: {{ resultHash }}</p>
|
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="mb-3 text-xs text-red-400">{{ error }}</div>
|
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||||
<button @click="send" :disabled="processing || !amount" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
|
<button @click="send" :disabled="processing || !amount" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
{{ processing ? 'Sending...' : 'Send' }}
|
{{ processing ? t('common.sending') : t('common.send') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</BaseModal>
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|||||||
25
neode-ui/src/components/ToggleSwitch.vue
Normal file
25
neode-ui/src/components/ToggleSwitch.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="modelValue"
|
||||||
|
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
|
||||||
|
:class="modelValue ? 'bg-orange-500' : 'bg-white/15'"
|
||||||
|
@click="$emit('update:modelValue', !modelValue)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
||||||
|
:class="modelValue ? 'translate-x-5' : 'translate-x-1'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
116
neode-ui/src/components/TransactionsModal.vue
Normal file
116
neode-ui/src/components/TransactionsModal.vue
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[90vh] flex flex-col" @close="close">
|
||||||
|
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
|
||||||
|
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5">
|
||||||
|
<div
|
||||||
|
v-for="tx in transactions"
|
||||||
|
:key="tx.tx_hash"
|
||||||
|
class="flex items-center justify-between gap-3 py-3 hover:bg-white/5 rounded-lg px-2 cursor-pointer transition-colors"
|
||||||
|
@click="openInMempool(tx.tx_hash)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
|
||||||
|
:class="tx.direction === 'incoming'
|
||||||
|
? (tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15')
|
||||||
|
: 'bg-red-500/10'"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
:class="tx.direction === 'incoming'
|
||||||
|
? (tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400')
|
||||||
|
: 'text-red-400'"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path v-if="tx.direction === 'incoming'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||||
|
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
|
||||||
|
>
|
||||||
|
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ Math.abs(tx.amount_sats).toLocaleString() }} sats
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||||
|
:class="tx.num_confirmations === 0
|
||||||
|
? 'bg-yellow-500/15 text-yellow-400'
|
||||||
|
: tx.num_confirmations < 3
|
||||||
|
? 'bg-green-500/15 text-green-400'
|
||||||
|
: 'bg-white/10 text-white/50'"
|
||||||
|
>
|
||||||
|
{{ tx.num_confirmations === 0 ? t('transactions.unconfirmed') : t('transactions.confirmations', { count: tx.num_confirmations }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-0.5">
|
||||||
|
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
|
||||||
|
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
|
||||||
|
<svg class="w-3.5 h-3.5 text-white/30" 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
|
|
||||||
|
interface WalletTransaction {
|
||||||
|
tx_hash: string
|
||||||
|
amount_sats: number
|
||||||
|
direction: 'incoming' | 'outgoing'
|
||||||
|
num_confirmations: number
|
||||||
|
time_stamp: number
|
||||||
|
total_fees: number
|
||||||
|
dest_addresses: string[]
|
||||||
|
label: string
|
||||||
|
block_height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
show: boolean
|
||||||
|
transactions: WalletTransaction[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInMempool(txHash: string) {
|
||||||
|
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTxTime(timestamp: number): string {
|
||||||
|
if (!timestamp) return ''
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
if (diffMins < 1) return t('transactions.justNow')
|
||||||
|
if (diffMins < 60) return t('transactions.minutesAgo', { count: diffMins })
|
||||||
|
const diffHours = Math.floor(diffMins / 60)
|
||||||
|
if (diffHours < 24) return t('transactions.hoursAgo', { count: diffHours })
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
if (diffDays < 7) return t('transactions.daysAgo', { count: diffDays })
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -28,10 +28,7 @@
|
|||||||
<p class="text-sm font-medium text-white/90">Share this {{ isDir ? 'folder' : 'file' }}</p>
|
<p class="text-sm font-medium text-white/90">Share this {{ isDir ? 'folder' : 'file' }}</p>
|
||||||
<p class="text-xs text-white/50 mt-0.5">Make visible to connected peers</p>
|
<p class="text-xs text-white/50 mt-0.5">Make visible to connected peers</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="share-toggle">
|
<ToggleSwitch v-model="shared" />
|
||||||
<input type="checkbox" v-model="shared" />
|
|
||||||
<span class="share-toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Access Type (only when shared) -->
|
<!-- Access Type (only when shared) -->
|
||||||
@ -126,6 +123,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
filename: string
|
filename: string
|
||||||
|
|||||||
@ -703,5 +703,53 @@
|
|||||||
"containers": "Containers",
|
"containers": "Containers",
|
||||||
"goToLogin": "Go to Login",
|
"goToLogin": "Go to Login",
|
||||||
"lastChecked": "Last checked: {time}"
|
"lastChecked": "Last checked: {time}"
|
||||||
|
},
|
||||||
|
"receiveBitcoin": {
|
||||||
|
"onChain": "On-Chain",
|
||||||
|
"lightning": "Lightning",
|
||||||
|
"ecash": "Ecash",
|
||||||
|
"amountSats": "Amount (sats)",
|
||||||
|
"memoOptional": "Memo (optional)",
|
||||||
|
"memoPlaceholder": "Payment for...",
|
||||||
|
"invoiceShareLabel": "Invoice (share with sender):",
|
||||||
|
"yourBitcoinAddress": "Your Bitcoin address:",
|
||||||
|
"pasteEcashToken": "Paste ecash token",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"generateAddress": "Generate Address",
|
||||||
|
"createInvoice": "Create Invoice",
|
||||||
|
"receive": "Receive",
|
||||||
|
"enterAnAmount": "Enter an amount",
|
||||||
|
"pasteAnEcashToken": "Paste an ecash token",
|
||||||
|
"tokenReceivedSuccess": "Token received successfully!"
|
||||||
|
},
|
||||||
|
"sendBitcoin": {
|
||||||
|
"onChain": "On-chain",
|
||||||
|
"auto": "Auto",
|
||||||
|
"lightning": "Lightning",
|
||||||
|
"ecash": "Ecash",
|
||||||
|
"autoMethodDesc": "Auto-selects method based on amount: ecash < 1k sats, Lightning 1k\u2013500k, on-chain > 500k",
|
||||||
|
"amountSats": "Amount (sats)",
|
||||||
|
"lightningInvoice": "Lightning Invoice (BOLT11)",
|
||||||
|
"bitcoinAddress": "Bitcoin Address",
|
||||||
|
"tokenShareLabel": "Token (share with recipient):",
|
||||||
|
"sentTx": "Sent! TX: {txid}",
|
||||||
|
"paidHash": "Paid! Hash: {hash}"
|
||||||
|
},
|
||||||
|
"transactions": {
|
||||||
|
"title": "Transactions",
|
||||||
|
"noTransactionsYet": "No transactions yet",
|
||||||
|
"unconfirmed": "Unconfirmed",
|
||||||
|
"confirmations": "{count} conf",
|
||||||
|
"justNow": "just now",
|
||||||
|
"minutesAgo": "{count}m ago",
|
||||||
|
"hoursAgo": "{count}h ago",
|
||||||
|
"daysAgo": "{count}d ago"
|
||||||
|
},
|
||||||
|
"easyHome": {
|
||||||
|
"beginner": "Beginner",
|
||||||
|
"intermediate": "Intermediate",
|
||||||
|
"done": "Done",
|
||||||
|
"inProgress": "In Progress",
|
||||||
|
"start": "Start"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -702,5 +702,53 @@
|
|||||||
"containers": "Contenedores",
|
"containers": "Contenedores",
|
||||||
"goToLogin": "Ir a inicio de sesi\u00f3n",
|
"goToLogin": "Ir a inicio de sesi\u00f3n",
|
||||||
"lastChecked": "\u00daltima verificaci\u00f3n: {time}"
|
"lastChecked": "\u00daltima verificaci\u00f3n: {time}"
|
||||||
|
},
|
||||||
|
"receiveBitcoin": {
|
||||||
|
"onChain": "On-Chain",
|
||||||
|
"lightning": "Lightning",
|
||||||
|
"ecash": "Ecash",
|
||||||
|
"amountSats": "Monto (sats)",
|
||||||
|
"memoOptional": "Nota (opcional)",
|
||||||
|
"memoPlaceholder": "Pago por...",
|
||||||
|
"invoiceShareLabel": "Factura (compartir con el remitente):",
|
||||||
|
"yourBitcoinAddress": "Su direcci\u00f3n Bitcoin:",
|
||||||
|
"pasteEcashToken": "Pegar token Ecash",
|
||||||
|
"processing": "Procesando...",
|
||||||
|
"generateAddress": "Generar direcci\u00f3n",
|
||||||
|
"createInvoice": "Crear factura",
|
||||||
|
"receive": "Recibir",
|
||||||
|
"enterAnAmount": "Ingrese un monto",
|
||||||
|
"pasteAnEcashToken": "Pegue un token Ecash",
|
||||||
|
"tokenReceivedSuccess": "\u00a1Token recibido exitosamente!"
|
||||||
|
},
|
||||||
|
"sendBitcoin": {
|
||||||
|
"onChain": "On-chain",
|
||||||
|
"auto": "Auto",
|
||||||
|
"lightning": "Lightning",
|
||||||
|
"ecash": "Ecash",
|
||||||
|
"autoMethodDesc": "Selecci\u00f3n autom\u00e1tica seg\u00fan monto: ecash < 1k sats, Lightning 1k\u2013500k, on-chain > 500k",
|
||||||
|
"amountSats": "Monto (sats)",
|
||||||
|
"lightningInvoice": "Factura Lightning (BOLT11)",
|
||||||
|
"bitcoinAddress": "Direcci\u00f3n Bitcoin",
|
||||||
|
"tokenShareLabel": "Token (compartir con el destinatario):",
|
||||||
|
"sentTx": "\u00a1Enviado! TX: {txid}",
|
||||||
|
"paidHash": "\u00a1Pagado! Hash: {hash}"
|
||||||
|
},
|
||||||
|
"transactions": {
|
||||||
|
"title": "Transacciones",
|
||||||
|
"noTransactionsYet": "A\u00fan no hay transacciones",
|
||||||
|
"unconfirmed": "Sin confirmar",
|
||||||
|
"confirmations": "{count} conf",
|
||||||
|
"justNow": "justo ahora",
|
||||||
|
"minutesAgo": "hace {count}m",
|
||||||
|
"hoursAgo": "hace {count}h",
|
||||||
|
"daysAgo": "hace {count}d"
|
||||||
|
},
|
||||||
|
"easyHome": {
|
||||||
|
"beginner": "Principiante",
|
||||||
|
"intermediate": "Intermedio",
|
||||||
|
"done": "Listo",
|
||||||
|
"inProgress": "En progreso",
|
||||||
|
"start": "Iniciar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -218,7 +218,7 @@ async function checkSessionWithTimeout(store: ReturnType<typeof useAppStore>): P
|
|||||||
* Navigation Guard
|
* Navigation Guard
|
||||||
* Handles authentication and onboarding flow routing
|
* Handles authentication and onboarding flow routing
|
||||||
*/
|
*/
|
||||||
function isLocalRedirect(path: unknown): path is string {
|
export function isLocalRedirect(path: unknown): path is string {
|
||||||
if (typeof path !== 'string') return false
|
if (typeof path !== 'string') return false
|
||||||
try {
|
try {
|
||||||
if (path.startsWith('//') || path.includes('://')) return false
|
if (path.startsWith('//') || path.includes('://')) return false
|
||||||
|
|||||||
@ -92,8 +92,9 @@ export const useAIPermissionsStore = defineStore('aiPermissions', () => {
|
|||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY)
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored) as AIContextCategory[]
|
const parsed: unknown = JSON.parse(stored)
|
||||||
return new Set(parsed.filter(c => AI_PERMISSION_CATEGORIES.some(cat => cat.id === c)))
|
if (!Array.isArray(parsed)) return new Set()
|
||||||
|
return new Set(parsed.filter((c: unknown) => typeof c === 'string' && AI_PERMISSION_CATEGORIES.some(cat => cat.id === c)) as AIContextCategory[])
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (import.meta.env.DEV) console.warn('Failed to load AI permissions from storage', e)
|
if (import.meta.env.DEV) console.warn('Failed to load AI permissions from storage', e)
|
||||||
|
|||||||
@ -63,7 +63,10 @@ const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
|
|||||||
function getApprovedOrigins(): Set<string> {
|
function getApprovedOrigins(): Set<string> {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
|
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
|
||||||
return stored ? new Set(JSON.parse(stored) as string[]) : new Set()
|
if (!stored) return new Set()
|
||||||
|
const parsed: unknown = JSON.parse(stored)
|
||||||
|
if (!Array.isArray(parsed)) return new Set()
|
||||||
|
return new Set(parsed.filter((s: unknown) => typeof s === 'string'))
|
||||||
} catch {
|
} catch {
|
||||||
return new Set()
|
return new Set()
|
||||||
}
|
}
|
||||||
@ -205,8 +208,11 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(appKey)
|
const stored = localStorage.getItem(appKey)
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored) as { id?: string }
|
const parsed: unknown = JSON.parse(stored)
|
||||||
appIdentityId = parsed.id || null
|
if (typeof parsed === 'object' && parsed !== null && 'id' in parsed) {
|
||||||
|
const idVal = (parsed as Record<string, unknown>).id
|
||||||
|
appIdentityId = typeof idVal === 'string' ? idVal : null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
|||||||
@ -23,8 +23,17 @@ export const useSpotlightStore = defineStore('spotlight', () => {
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(RECENT_ITEMS_KEY)
|
const raw = localStorage.getItem(RECENT_ITEMS_KEY)
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw) as RecentItem[]
|
const parsed: unknown = JSON.parse(raw)
|
||||||
recentItems.value = parsed.slice(0, MAX_RECENT_ITEMS)
|
if (!Array.isArray(parsed)) {
|
||||||
|
recentItems.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recentItems.value = parsed
|
||||||
|
.filter((item: unknown) =>
|
||||||
|
typeof item === 'object' && item !== null &&
|
||||||
|
'id' in item && 'label' in item && 'type' in item && 'timestamp' in item
|
||||||
|
)
|
||||||
|
.slice(0, MAX_RECENT_ITEMS) as RecentItem[]
|
||||||
} else {
|
} else {
|
||||||
recentItems.value = []
|
recentItems.value = []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
/* Mobile touch targets — ensure tappable elements meet 44px minimum */
|
/* Mobile touch targets — ensure tappable elements meet 44px minimum */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]) {
|
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]):not([class*="w-10"]):not([class*="w-11"]):not([class*="w-12"]) {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -81,7 +81,6 @@ button:active:not(:disabled),
|
|||||||
[role="button"]:active,
|
[role="button"]:active,
|
||||||
a.glass-card:active,
|
a.glass-card:active,
|
||||||
a.goal-card:active,
|
a.goal-card:active,
|
||||||
.info-card-button:active,
|
|
||||||
.path-action-button:active {
|
.path-action-button:active {
|
||||||
transform: scale(0.97) !important;
|
transform: scale(0.97) !important;
|
||||||
transition: transform 0.1s ease !important;
|
transition: transform 0.1s ease !important;
|
||||||
@ -244,18 +243,21 @@ input[type="radio"]:active + * {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On mobile browsers, cap chat height to the dynamic viewport to prevent
|
||||||
|
content extending behind browser chrome (address bar / toolbar). */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.chat-fullscreen {
|
||||||
|
max-height: 100vh;
|
||||||
|
max-height: 100dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat-mode-pill {
|
.chat-mode-pill {
|
||||||
display: none;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2.25rem;
|
top: 2.25rem;
|
||||||
right: 1.25rem;
|
right: 1.25rem;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
@media (min-width: 768px) {
|
|
||||||
.chat-mode-pill {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-iframe {
|
.chat-iframe {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -265,10 +267,15 @@ input[type="radio"]:active + * {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On mobile, shrink iframe height so AIUI ends above the Archipelago tab bar */
|
/* On mobile, shrink iframe height so AIUI ends above the Archipelago tab bar.
|
||||||
|
Use dvh (dynamic viewport height) instead of 100% — on a normal mobile browser,
|
||||||
|
100% resolves through the parent chain to the large viewport (100vh) which
|
||||||
|
is taller than the visible area when the browser chrome is showing. dvh
|
||||||
|
tracks the actual visible viewport. */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.chat-iframe-mobile {
|
.chat-iframe-mobile {
|
||||||
height: calc(100% - var(--mobile-tab-bar-height, 72px)) !important;
|
height: calc(100vh - var(--mobile-tab-bar-height, 72px)) !important;
|
||||||
|
height: calc(100dvh - var(--mobile-tab-bar-height, 72px)) !important;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -441,6 +448,112 @@ input[type="radio"]:active + * {
|
|||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Glass button color variants */
|
||||||
|
.glass-button-warning {
|
||||||
|
background: rgba(251, 146, 60, 0.2);
|
||||||
|
border: 1px solid rgba(251, 146, 60, 0.3);
|
||||||
|
color: #fdba74;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button-warning:hover {
|
||||||
|
background: rgba(251, 146, 60, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button-success {
|
||||||
|
background: rgba(74, 222, 128, 0.2);
|
||||||
|
border: 1px solid rgba(74, 222, 128, 0.4);
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button-success:hover {
|
||||||
|
background: rgba(74, 222, 128, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges — inline colored pills */
|
||||||
|
.status-success {
|
||||||
|
background: rgba(74, 222, 128, 0.2);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
background: rgba(251, 146, 60, 0.2);
|
||||||
|
color: #fb923c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert banners — padded containers with border */
|
||||||
|
.alert-success {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgba(74, 222, 128, 0.1);
|
||||||
|
border: 1px solid rgba(74, 222, 128, 0.2);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #bbf7d0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgba(251, 146, 60, 0.1);
|
||||||
|
border: 1px solid rgba(251, 146, 60, 0.2);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #fdba74;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #93c5fd;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form input focus ring */
|
||||||
|
.input-glass {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-glass:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #fb923c;
|
||||||
|
box-shadow: 0 0 0 1px #fb923c;
|
||||||
|
}
|
||||||
|
|
||||||
/* Toast - glassmorphic, top-right */
|
/* Toast - glassmorphic, top-right */
|
||||||
.toast-glass {
|
.toast-glass {
|
||||||
background-color: rgba(0, 0, 0, 0.65);
|
background-color: rgba(0, 0, 0, 0.65);
|
||||||
@ -593,22 +706,6 @@ input[type="radio"]:active + * {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gradient border container for large content areas */
|
|
||||||
.gradient-border-container {
|
|
||||||
position: relative;
|
|
||||||
border-radius: 1.5rem;
|
|
||||||
padding: 4px;
|
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.1) 50%, rgba(0, 0, 0, 0.6) 100%);
|
|
||||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gradient-border-container-inner {
|
|
||||||
background: rgba(0, 0, 0, 0.35);
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
-webkit-backdrop-filter: blur(18px);
|
|
||||||
border-radius: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Choose Your Path - Main Container */
|
/* Choose Your Path - Main Container */
|
||||||
.path-glass-container {
|
.path-glass-container {
|
||||||
width: calc(100% - 48px);
|
width: calc(100% - 48px);
|
||||||
@ -876,6 +973,28 @@ input[type="radio"]:active + * {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal transition (Vue <Transition name="modal">) — shared across all modal components */
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-active .glass-card,
|
||||||
|
.modal-leave-active .glass-card {
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from .glass-card,
|
||||||
|
.modal-leave-to .glass-card {
|
||||||
|
transform: scale(0.95);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Background image */
|
/* Background image */
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -958,10 +1077,10 @@ iframe.iframe-scrollbar-hide {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes caretBlink {
|
@keyframes caretBlink {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
border-right-color: #00ffff;
|
border-right-color: #fbbf24;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
border-right-color: transparent;
|
border-right-color: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -999,15 +1118,6 @@ iframe.iframe-scrollbar-hide {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes caretBlink {
|
|
||||||
0%, 100% {
|
|
||||||
border-right-color: #fbbf24;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
border-right-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Splash screen styles */
|
/* Splash screen styles */
|
||||||
.splash-complete .login-card {
|
.splash-complete .login-card {
|
||||||
animation: fadeUpIn 900ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both;
|
animation: fadeUpIn 900ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both;
|
||||||
@ -1065,9 +1175,9 @@ body::after {
|
|||||||
animation-fill-mode: backwards;
|
animation-fill-mode: backwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dashboard: full viewport width, no letterboxing */
|
/* Dashboard: full viewport width, no letterboxing, no body scroll */
|
||||||
body.dashboard-active {
|
body.dashboard-active {
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1590,46 +1700,6 @@ html:has(body.video-background-active)::before {
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toggle switch */
|
|
||||||
.share-toggle {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
width: 2.75rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.share-toggle input {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
.share-toggle-slider {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
.share-toggle-slider::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 1.125rem;
|
|
||||||
height: 1.125rem;
|
|
||||||
left: 0.1875rem;
|
|
||||||
bottom: 0.1875rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: white;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
.share-toggle input:checked + .share-toggle-slider {
|
|
||||||
background: #fb923c;
|
|
||||||
}
|
|
||||||
.share-toggle input:checked + .share-toggle-slider::before {
|
|
||||||
transform: translateX(1.25rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Access type options */
|
/* Access type options */
|
||||||
.share-access-options {
|
.share-access-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -1823,44 +1893,6 @@ html:has(body.video-background-active)::before {
|
|||||||
.monitoring-bar-danger {
|
.monitoring-bar-danger {
|
||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
}
|
}
|
||||||
.monitoring-alert-toggle {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 36px;
|
|
||||||
height: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.monitoring-alert-toggle input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
.monitoring-alert-toggle-slider {
|
|
||||||
position: absolute;
|
|
||||||
cursor: pointer;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 10px;
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
}
|
|
||||||
.monitoring-alert-toggle-slider::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
height: 14px;
|
|
||||||
width: 14px;
|
|
||||||
left: 3px;
|
|
||||||
bottom: 3px;
|
|
||||||
background: rgba(255, 255, 255, 0.6);
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
.monitoring-alert-toggle input:checked + .monitoring-alert-toggle-slider {
|
|
||||||
background: rgba(74, 222, 128, 0.4);
|
|
||||||
}
|
|
||||||
.monitoring-alert-toggle input:checked + .monitoring-alert-toggle-slider::before {
|
|
||||||
transform: translateX(16px);
|
|
||||||
background: #4ade80;
|
|
||||||
}
|
|
||||||
.monitoring-threshold-input {
|
.monitoring-threshold-input {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@ -1872,42 +1904,3 @@ html:has(body.video-background-active)::before {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toggle switch for Tor services and similar on/off controls */
|
|
||||||
.tor-toggle-label {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 36px;
|
|
||||||
height: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.tor-toggle-input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
.tor-toggle-slider {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 10px;
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
}
|
|
||||||
.tor-toggle-slider::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
left: 2px;
|
|
||||||
top: 2px;
|
|
||||||
background: rgba(255, 255, 255, 0.6);
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: transform 0.3s ease, background 0.3s ease;
|
|
||||||
}
|
|
||||||
.tor-toggle-input:checked + .tor-toggle-slider {
|
|
||||||
background: rgba(251, 146, 60, 0.4);
|
|
||||||
}
|
|
||||||
.tor-toggle-input:checked + .tor-toggle-slider::before {
|
|
||||||
transform: translateX(16px);
|
|
||||||
background: #fb923c;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -79,6 +79,7 @@ export type PackageState = typeof PackageState[keyof typeof PackageState]
|
|||||||
|
|
||||||
export interface PackageDataEntry {
|
export interface PackageDataEntry {
|
||||||
state: PackageState
|
state: PackageState
|
||||||
|
health?: string | null // "healthy", "unhealthy", "starting", or null
|
||||||
'static-files'?: {
|
'static-files'?: {
|
||||||
license: string
|
license: string
|
||||||
instructions: string
|
instructions: string
|
||||||
|
|||||||
@ -41,10 +41,10 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium"
|
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium"
|
||||||
:class="getStatusClass(pkg.state)"
|
:class="getStatusClass(pkg.state, pkg.health)"
|
||||||
>
|
>
|
||||||
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state)"></span>
|
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health)"></span>
|
||||||
{{ pkg.state }}
|
{{ getStatusLabel(pkg.state, pkg.health) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -74,14 +74,15 @@
|
|||||||
</button>
|
</button>
|
||||||
<template v-if="!isWebOnly">
|
<template v-if="!isWebOnly">
|
||||||
<button
|
<button
|
||||||
v-if="pkg.state === 'stopped'"
|
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
|
||||||
@click="startApp"
|
@click="startApp"
|
||||||
class="px-4 py-2.5 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center gap-2"
|
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||||||
|
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ t('common.start') }}
|
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="restartApp"
|
@click="restartApp"
|
||||||
@ -105,7 +106,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="uninstallApp"
|
@click="uninstallApp"
|
||||||
class="px-4 py-2.5 bg-red-600/20 border border-red-600/40 rounded-lg text-red-300 text-sm font-medium hover:bg-red-600/30 transition-colors flex items-center gap-2"
|
class="px-4 py-2.5 glass-button glass-button-danger rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
@ -135,10 +136,10 @@
|
|||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||||
:class="getStatusClass(pkg.state)"
|
:class="getStatusClass(pkg.state, pkg.health)"
|
||||||
>
|
>
|
||||||
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state)"></span>
|
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state, pkg.health)"></span>
|
||||||
{{ pkg.state }}
|
{{ getStatusLabel(pkg.state, pkg.health) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -148,7 +149,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="!isWebOnly"
|
v-if="!isWebOnly"
|
||||||
@click="uninstallApp"
|
@click="uninstallApp"
|
||||||
class="flex-shrink-0 w-10 h-10 rounded-lg bg-red-600/20 border border-red-600/40 text-red-300 hover:bg-red-600/30 transition-colors flex items-center justify-center"
|
class="flex-shrink-0 w-10 h-10 rounded-lg glass-button glass-button-danger transition-colors flex items-center justify-center"
|
||||||
:title="t('common.uninstall')"
|
:title="t('common.uninstall')"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -172,14 +173,15 @@
|
|||||||
</button>
|
</button>
|
||||||
<template v-if="!isWebOnly">
|
<template v-if="!isWebOnly">
|
||||||
<button
|
<button
|
||||||
v-if="pkg.state === 'stopped'"
|
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
|
||||||
@click="startApp"
|
@click="startApp"
|
||||||
class="px-4 py-2.5 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center justify-center gap-2"
|
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2"
|
||||||
|
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ t('common.start') }}
|
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="pkg.state === 'running'"
|
v-if="pkg.state === 'running'"
|
||||||
@ -194,7 +196,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="restartApp"
|
@click="restartApp"
|
||||||
:class="[canLaunch && (pkg.state === 'stopped' || pkg.state === 'running') ? 'col-span-2' : '']"
|
:class="[canLaunch && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'running') ? 'col-span-2' : '']"
|
||||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center justify-center gap-2"
|
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -447,7 +449,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="confirmUninstall"
|
@click="confirmUninstall"
|
||||||
class="w-full md:w-auto px-6 py-3 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
|
class="w-full md:w-auto px-6 py-3 glass-button glass-button-danger rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
{{ t('common.uninstall') }}
|
{{ t('common.uninstall') }}
|
||||||
</button>
|
</button>
|
||||||
@ -460,7 +462,7 @@
|
|||||||
<!-- Action error toast -->
|
<!-- Action error toast -->
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div v-if="actionError" 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">
|
<div v-if="actionError" 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">
|
||||||
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
|
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
|
||||||
<span>{{ actionError }}</span>
|
<span>{{ actionError }}</span>
|
||||||
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">×</button>
|
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||||
</div>
|
</div>
|
||||||
@ -898,12 +900,16 @@ async function uninstallApp() {
|
|||||||
showUninstallModal()
|
showUninstallModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusClass(state: PackageState): string {
|
function getStatusClass(state: PackageState, health?: string | null): string {
|
||||||
|
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
|
||||||
|
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200 border border-orange-500/30'
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case PackageState.Running:
|
case PackageState.Running:
|
||||||
return 'bg-green-500/20 text-green-200 border border-green-500/30'
|
return 'bg-green-500/20 text-green-200 border border-green-500/30'
|
||||||
case PackageState.Stopped:
|
case PackageState.Stopped:
|
||||||
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
|
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
|
||||||
|
case PackageState.Exited:
|
||||||
|
return 'bg-red-500/20 text-red-200 border border-red-500/30'
|
||||||
case PackageState.Starting:
|
case PackageState.Starting:
|
||||||
case PackageState.Stopping:
|
case PackageState.Stopping:
|
||||||
case PackageState.Restarting:
|
case PackageState.Restarting:
|
||||||
@ -915,12 +921,16 @@ function getStatusClass(state: PackageState): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusDotClass(state: PackageState): string {
|
function getStatusDotClass(state: PackageState, health?: string | null): string {
|
||||||
|
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-400 animate-pulse'
|
||||||
|
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-400 animate-pulse'
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case PackageState.Running:
|
case PackageState.Running:
|
||||||
return 'bg-green-400'
|
return 'bg-green-400'
|
||||||
case PackageState.Stopped:
|
case PackageState.Stopped:
|
||||||
return 'bg-gray-400'
|
return 'bg-gray-400'
|
||||||
|
case PackageState.Exited:
|
||||||
|
return 'bg-red-400 animate-pulse'
|
||||||
case PackageState.Starting:
|
case PackageState.Starting:
|
||||||
case PackageState.Stopping:
|
case PackageState.Stopping:
|
||||||
case PackageState.Restarting:
|
case PackageState.Restarting:
|
||||||
@ -931,6 +941,14 @@ function getStatusDotClass(state: PackageState): string {
|
|||||||
return 'bg-gray-400'
|
return 'bg-gray-400'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(state: PackageState, health?: string | null): string {
|
||||||
|
if (state === PackageState.Running && health === 'starting') return 'starting up'
|
||||||
|
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
|
||||||
|
if (state === PackageState.Running && health === 'healthy') return 'healthy'
|
||||||
|
if (state === PackageState.Exited) return 'crashed'
|
||||||
|
return state
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -555,7 +555,7 @@ function handleBackdropClick() {
|
|||||||
function closeSession() {
|
function closeSession() {
|
||||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||||
if (isInlinePanel.value) emit('close')
|
if (isInlinePanel.value) emit('close')
|
||||||
else router.push({ name: 'apps' })
|
else router.back()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
|||||||
@ -40,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading Skeleton -->
|
<!-- Loading Skeleton -->
|
||||||
<div v-if="!store.isConnected && sortedPackageEntries.length === 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
|
<div v-if="!store.isConnected && sortedPackageEntries.length === 0 && !connectionError" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
|
||||||
<div v-for="i in 3" :key="i" class="glass-card p-6 animate-pulse">
|
<div v-for="i in 3" :key="i" class="glass-card p-6 animate-pulse">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div class="w-16 h-16 rounded-lg bg-white/10"></div>
|
<div class="w-16 h-16 rounded-lg bg-white/10"></div>
|
||||||
@ -56,6 +56,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Error -->
|
||||||
|
<div v-else-if="connectionError && sortedPackageEntries.length === 0" class="text-center py-12 pb-6">
|
||||||
|
<div class="glass-card p-8 max-w-md mx-auto">
|
||||||
|
<div class="alert-error mb-4">{{ connectionError }}</div>
|
||||||
|
<button
|
||||||
|
@click="connectionError = ''; store.connectWebSocket()"
|
||||||
|
class="glass-button px-6 py-3 rounded-lg font-medium"
|
||||||
|
>
|
||||||
|
Retry Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
|
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
|
||||||
<div class="glass-card p-12 max-w-md mx-auto">
|
<div class="glass-card p-12 max-w-md mx-auto">
|
||||||
@ -136,10 +149,10 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
|
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
|
||||||
:class="getStatusClass(pkg.state)"
|
:class="getStatusClass(pkg.state, pkg.health)"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
v-if="pkg.state === 'starting' || pkg.state === 'installing' || pkg.state === 'stopping' || pkg.state === 'restarting'"
|
v-if="pkg.state === 'starting' || pkg.state === 'installing' || pkg.state === 'stopping' || pkg.state === 'restarting' || (pkg.state === 'running' && pkg.health === 'starting')"
|
||||||
class="animate-spin h-3 w-3"
|
class="animate-spin h-3 w-3"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -148,7 +161,8 @@
|
|||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{{ pkg.state }}
|
<span v-if="pkg.state === 'running' && pkg.health === 'unhealthy'" class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span>
|
||||||
|
{{ getStatusLabel(pkg.state, pkg.health) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-white/50">
|
<span class="text-xs text-white/50">
|
||||||
v{{ pkg.manifest.version }}
|
v{{ pkg.manifest.version }}
|
||||||
@ -171,14 +185,14 @@
|
|||||||
<button
|
<button
|
||||||
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited')"
|
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited')"
|
||||||
@click.stop="startApp(id as string)"
|
@click.stop="startApp(id as string)"
|
||||||
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center justify-center gap-2"
|
class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<span>{{ t('common.start') }}</span>
|
<span>{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
|
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
|
||||||
disabled
|
disabled
|
||||||
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
|
class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<svg class="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
@ -193,6 +207,16 @@
|
|||||||
>
|
>
|
||||||
<span>{{ t('common.stop') }}</span>
|
<span>{{ t('common.stop') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting')"
|
||||||
|
@click.stop="restartApp(id as string)"
|
||||||
|
class="px-2.5 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
|
||||||
|
:title="t('common.restart')"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
|
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
|
||||||
disabled
|
disabled
|
||||||
@ -249,7 +273,7 @@
|
|||||||
<button
|
<button
|
||||||
@click="confirmUninstall"
|
@click="confirmUninstall"
|
||||||
:disabled="uninstalling"
|
:disabled="uninstalling"
|
||||||
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
class="px-4 py-2 glass-button glass-button-danger rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
v-if="uninstalling"
|
v-if="uninstalling"
|
||||||
@ -274,7 +298,7 @@
|
|||||||
<!-- Action error toast -->
|
<!-- Action error toast -->
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div v-if="actionError" 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">
|
<div v-if="actionError" 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">
|
||||||
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
|
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
|
||||||
<span>{{ actionError }}</span>
|
<span>{{ actionError }}</span>
|
||||||
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">×</button>
|
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||||
</div>
|
</div>
|
||||||
@ -284,7 +308,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onBeforeUnmount } from 'vue'
|
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
@ -377,6 +401,20 @@ const categoriesWithApps = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// Connection error state — show after timeout if backend never connects
|
||||||
|
const connectionError = ref('')
|
||||||
|
let connectionTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!store.isConnected) {
|
||||||
|
connectionTimer = setTimeout(() => {
|
||||||
|
if (!store.isConnected && sortedPackageEntries.value.length === 0) {
|
||||||
|
connectionError.value = 'Unable to connect to server. Check that the backend is running.'
|
||||||
|
}
|
||||||
|
}, 15000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Track loading states for each app action
|
// Track loading states for each app action
|
||||||
const loadingActions = ref<Record<string, boolean>>({})
|
const loadingActions = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
@ -523,12 +561,16 @@ function launchApp(id: string) {
|
|||||||
useAppLauncherStore().openSession(id)
|
useAppLauncherStore().openSession(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusClass(state: PackageState): string {
|
function getStatusClass(state: PackageState, health?: string | null): string {
|
||||||
|
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200'
|
||||||
|
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200'
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case PackageState.Running:
|
case PackageState.Running:
|
||||||
return 'bg-green-500/20 text-green-200'
|
return 'bg-green-500/20 text-green-200'
|
||||||
case PackageState.Stopped:
|
case PackageState.Stopped:
|
||||||
return 'bg-gray-500/20 text-gray-200'
|
return 'bg-gray-500/20 text-gray-200'
|
||||||
|
case PackageState.Exited:
|
||||||
|
return 'bg-red-500/20 text-red-200'
|
||||||
case PackageState.Starting:
|
case PackageState.Starting:
|
||||||
case PackageState.Stopping:
|
case PackageState.Stopping:
|
||||||
case PackageState.Restarting:
|
case PackageState.Restarting:
|
||||||
@ -540,6 +582,14 @@ function getStatusClass(state: PackageState): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(state: PackageState, health?: string | null): string {
|
||||||
|
if (state === PackageState.Running && health === 'starting') return 'starting up'
|
||||||
|
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
|
||||||
|
if (state === PackageState.Running && health === 'healthy') return 'healthy'
|
||||||
|
if (state === PackageState.Exited) return 'crashed'
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
function goToApp(id: string) {
|
function goToApp(id: string) {
|
||||||
router.push(`/dashboard/apps/${id}`).catch(() => {})
|
router.push(`/dashboard/apps/${id}`).catch(() => {})
|
||||||
}
|
}
|
||||||
@ -578,9 +628,26 @@ async function stopApp(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restartApp(id: string) {
|
||||||
|
loadingActions.value[id] = true
|
||||||
|
try {
|
||||||
|
await store.restartPackage(id)
|
||||||
|
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
|
||||||
|
actionTimers.set(id, setTimeout(() => {
|
||||||
|
loadingActions.value[id] = false
|
||||||
|
actionTimers.delete(id)
|
||||||
|
}, 8000))
|
||||||
|
} catch (err) {
|
||||||
|
if (import.meta.env.DEV) console.error('Failed to restart app:', err)
|
||||||
|
showActionError(`Failed to restart app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||||
|
loadingActions.value[id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
for (const t of actionTimers.values()) clearTimeout(t)
|
for (const t of actionTimers.values()) clearTimeout(t)
|
||||||
actionTimers.clear()
|
actionTimers.clear()
|
||||||
|
if (connectionTimer) clearTimeout(connectionTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@ -638,25 +705,3 @@ function handleImageError(e: Event) {
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-enter-active,
|
|
||||||
.modal-leave-active {
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-active .glass-card,
|
|
||||||
.modal-leave-active .glass-card {
|
|
||||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-from,
|
|
||||||
.modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-from .glass-card,
|
|
||||||
.modal-leave-to .glass-card {
|
|
||||||
transform: scale(0.95);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-fullscreen">
|
<div class="chat-fullscreen">
|
||||||
<!-- Close button (desktop: top-right pill) -->
|
<!-- Close button (desktop: top-right pill) -->
|
||||||
<div class="chat-mode-pill flex">
|
<div class="chat-mode-pill hidden md:flex">
|
||||||
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
|
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
|
||||||
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
|||||||
@ -114,6 +114,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-if="loadError" class="alert-error mb-4">
|
||||||
|
{{ loadError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Not Installed Hint -->
|
<!-- Not Installed Hint -->
|
||||||
<div v-if="!fileBrowserRunning" class="glass-card p-8 mt-6 text-center">
|
<div v-if="!fileBrowserRunning" class="glass-card p-8 mt-6 text-center">
|
||||||
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
|
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
|
||||||
@ -146,6 +151,7 @@ interface PeerNode {
|
|||||||
|
|
||||||
const peerNodes = ref<PeerNode[]>([])
|
const peerNodes = ref<PeerNode[]>([])
|
||||||
const peersLoading = ref(true)
|
const peersLoading = ref(true)
|
||||||
|
const loadError = ref('')
|
||||||
|
|
||||||
const APP_ALIASES: Record<string, string[]> = {
|
const APP_ALIASES: Record<string, string[]> = {
|
||||||
immich: ['immich_server', 'immich-server'],
|
immich: ['immich_server', 'immich-server'],
|
||||||
@ -244,7 +250,8 @@ async function loadCounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed silently', e)
|
loadError.value = e instanceof Error ? e.message : 'Failed to load file counts'
|
||||||
|
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed', e)
|
||||||
} finally {
|
} finally {
|
||||||
countsLoading.value = false
|
countsLoading.value = false
|
||||||
}
|
}
|
||||||
@ -260,8 +267,9 @@ async function loadPeers() {
|
|||||||
try {
|
try {
|
||||||
const result = await rpcClient.federationListNodes()
|
const result = await rpcClient.federationListNodes()
|
||||||
peerNodes.value = result?.nodes ?? []
|
peerNodes.value = result?.nodes ?? []
|
||||||
} catch {
|
} catch (e) {
|
||||||
peerNodes.value = []
|
peerNodes.value = []
|
||||||
|
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
|
||||||
} finally {
|
} finally {
|
||||||
peersLoading.value = false
|
peersLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,6 +69,12 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cloud Store Error -->
|
||||||
|
<div v-else-if="cloudStore.error" class="glass-card p-6 flex-1 flex flex-col items-center justify-center text-center">
|
||||||
|
<div class="alert-error mb-4">{{ cloudStore.error }}</div>
|
||||||
|
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="cloudStore.refresh()">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Native File Browser (for FileBrowser-backed sections) -->
|
<!-- Native File Browser (for FileBrowser-backed sections) -->
|
||||||
<div
|
<div
|
||||||
v-else-if="useNativeUI"
|
v-else-if="useNativeUI"
|
||||||
|
|||||||
@ -86,7 +86,10 @@
|
|||||||
@click="appLauncher.closePanel()"
|
@click="appLauncher.closePanel()"
|
||||||
:style="{ '--nav-stagger': idx }"
|
:style="{ '--nav-stagger': idx }"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg v-if="item.icon === 'web5'" class="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 1631 1624">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
v-for="(path, index) in getIconPath(item.icon)"
|
v-for="(path, index) in getIconPath(item.icon)"
|
||||||
:key="index"
|
:key="index"
|
||||||
@ -247,7 +250,8 @@
|
|||||||
<div :key="route.path" class="view-wrapper">
|
<div :key="route.path" class="view-wrapper">
|
||||||
<div
|
<div
|
||||||
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
|
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
|
||||||
class="h-full"
|
:class="['h-full', mobileTabPaddingTop ? 'overflow-y-auto' : '']"
|
||||||
|
:style="mobileTabPaddingTop ? { paddingTop: (mobileTabPaddingTop + 16) + 'px' } : undefined"
|
||||||
>
|
>
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</div>
|
</div>
|
||||||
@ -304,7 +308,10 @@
|
|||||||
}"
|
}"
|
||||||
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
|
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg v-if="item.icon === 'web5'" class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="currentColor" viewBox="0 0 1631 1624">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
v-for="(path, index) in getIconPath(item.icon)"
|
v-for="(path, index) in getIconPath(item.icon)"
|
||||||
:key="index"
|
:key="index"
|
||||||
|
|||||||
@ -304,7 +304,7 @@
|
|||||||
<div v-if="!confirmRemove">
|
<div v-if="!confirmRemove">
|
||||||
<button
|
<button
|
||||||
@click="confirmRemove = true"
|
@click="confirmRemove = true"
|
||||||
class="w-full mt-4 px-4 py-2 rounded text-sm text-red-400 border border-red-400/30 hover:bg-red-400/10 transition-colors"
|
class="w-full mt-4 px-4 py-2 rounded text-sm glass-button glass-button-danger transition-colors"
|
||||||
>
|
>
|
||||||
Remove from Federation
|
Remove from Federation
|
||||||
</button>
|
</button>
|
||||||
@ -318,7 +318,7 @@
|
|||||||
>Cancel</button>
|
>Cancel</button>
|
||||||
<button
|
<button
|
||||||
@click="removeNode(selectedNode!.did)"
|
@click="removeNode(selectedNode!.did)"
|
||||||
class="flex-1 px-3 py-1.5 rounded text-sm text-red-400 border border-red-400/30 hover:bg-red-400/10 transition-colors font-medium"
|
class="flex-1 px-3 py-1.5 rounded text-sm glass-button glass-button-danger transition-colors font-medium"
|
||||||
>Confirm Remove</button>
|
>Confirm Remove</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -80,6 +80,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- App icon -->
|
||||||
|
<img
|
||||||
|
v-if="stepIconUrl(step)"
|
||||||
|
:src="stepIconUrl(step)"
|
||||||
|
:alt="step.title"
|
||||||
|
class="w-7 h-7 rounded-md object-contain shrink-0 mt-0.5"
|
||||||
|
@error="($event.target as HTMLImageElement).style.display = 'none'"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Step content -->
|
<!-- Step content -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="text-base font-semibold text-white/90 mb-1">{{ step.title }}</h3>
|
<h3 class="text-base font-semibold text-white/90 mb-1">{{ step.title }}</h3>
|
||||||
@ -141,7 +150,7 @@
|
|||||||
<!-- Action error toast -->
|
<!-- Action error toast -->
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div v-if="actionError" 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">
|
<div v-if="actionError" 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">
|
||||||
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
|
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
|
||||||
<span>{{ actionError }}</span>
|
<span>{{ actionError }}</span>
|
||||||
<button @click="actionError = ''" class="text-red-300 hover:text-white shrink-0">×</button>
|
<button @click="actionError = ''" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||||
</div>
|
</div>
|
||||||
@ -159,6 +168,24 @@ import { useGoalStore } from '@/stores/goals'
|
|||||||
import { getGoalById } from '@/data/goals'
|
import { getGoalById } from '@/data/goals'
|
||||||
import type { GoalStep } from '@/types/goals'
|
import type { GoalStep } from '@/types/goals'
|
||||||
|
|
||||||
|
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
||||||
|
const APP_ICON_MAP: Record<string, string> = {
|
||||||
|
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
|
||||||
|
lnd: '/assets/img/app-icons/lnd.svg',
|
||||||
|
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
|
||||||
|
immich: '/assets/img/app-icons/immich.png',
|
||||||
|
nextcloud: '/assets/img/app-icons/nextcloud.webp',
|
||||||
|
fedimint: '/assets/img/app-icons/fedimint.png',
|
||||||
|
mempool: '/assets/img/app-icons/mempool.webp',
|
||||||
|
electrs: '/assets/img/app-icons/electrs.svg',
|
||||||
|
'nostr-rs-relay': '/assets/img/app-icons/nostr-rs-relay.svg',
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepIconUrl(step: GoalStep): string | undefined {
|
||||||
|
if (!step.appId) return undefined
|
||||||
|
return APP_ICON_MAP[step.appId]
|
||||||
|
}
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="kiosk-status-pill" :class="isConnected ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'">
|
<div class="kiosk-status-pill" :class="isConnected ? 'status-success' : 'status-error'">
|
||||||
<div class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'"></div>
|
<div class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||||
{{ isConnected ? t('kiosk.online') : t('kiosk.offline') }}
|
{{ isConnected ? t('kiosk.online') : t('kiosk.offline') }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -47,7 +47,7 @@
|
|||||||
<button @click="refreshDiagnostics" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">
|
<button @click="refreshDiagnostics" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">
|
||||||
{{ t('common.refresh') }}
|
{{ t('common.refresh') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="goToLogin" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30">
|
<button @click="goToLogin" class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex-1">
|
||||||
{{ t('kioskRecovery.goToLogin') }}
|
{{ t('kioskRecovery.goToLogin') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -204,6 +204,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { isLocalRedirect } from '../router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
@ -217,7 +218,11 @@ const router = useRouter()
|
|||||||
const currentRoute = useRoute()
|
const currentRoute = useRoute()
|
||||||
|
|
||||||
/** After login, redirect to the intended page or default to home */
|
/** After login, redirect to the intended page or default to home */
|
||||||
const loginRedirectTo = computed(() => (currentRoute.query.redirect as string) || '/dashboard')
|
const loginRedirectTo = computed(() => {
|
||||||
|
const redirect = currentRoute.query.redirect as string
|
||||||
|
if (redirect && isLocalRedirect(redirect)) return redirect
|
||||||
|
return '/dashboard'
|
||||||
|
})
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const loginTransition = useLoginTransitionStore()
|
const loginTransition = useLoginTransitionStore()
|
||||||
|
|
||||||
|
|||||||
@ -116,6 +116,12 @@
|
|||||||
|
|
||||||
<!-- Scrollable Apps Section -->
|
<!-- Scrollable Apps Section -->
|
||||||
<div class="pb-8">
|
<div class="pb-8">
|
||||||
|
<!-- Community Load Error -->
|
||||||
|
<div v-if="communityError" class="alert-error mb-4">
|
||||||
|
{{ communityError }}
|
||||||
|
<button @click="loadCommunityMarketplace()" class="ml-2 underline hover:no-underline">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Apps Grid -->
|
<!-- Apps Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<div
|
<div
|
||||||
@ -1288,30 +1294,6 @@ function handleImageError(event: Event) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modal transition animations */
|
|
||||||
.modal-enter-active,
|
|
||||||
.modal-leave-active {
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-from,
|
|
||||||
.modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-active .glass-card,
|
|
||||||
.modal-leave-active .glass-card {
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-from .glass-card {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-leave-to .glass-card {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar styling for apps section */
|
/* Custom scrollbar styling for apps section */
|
||||||
.marketplace-container ::-webkit-scrollbar {
|
.marketplace-container ::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@ -4,11 +4,21 @@ import { useMeshStore } from '@/stores/mesh'
|
|||||||
import { useTransportStore } from '@/stores/transport'
|
import { useTransportStore } from '@/stores/transport'
|
||||||
import type { MeshPeer, SessionStatus } from '@/stores/mesh'
|
import type { MeshPeer, SessionStatus } from '@/stores/mesh'
|
||||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||||
|
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
const mesh = useMeshStore()
|
const mesh = useMeshStore()
|
||||||
const transport = useTransportStore()
|
const transport = useTransportStore()
|
||||||
|
|
||||||
|
// Responsive layout breakpoints
|
||||||
|
const isWideDesktop = ref(window.innerWidth >= 1536)
|
||||||
|
const isMobile = ref(window.innerWidth < 1280)
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
isWideDesktop.value = window.innerWidth >= 1536
|
||||||
|
isMobile.value = window.innerWidth < 1280
|
||||||
|
}
|
||||||
|
|
||||||
// Active chat: either a peer or a channel
|
// Active chat: either a peer or a channel
|
||||||
const activeChatPeer = ref<MeshPeer | null>(null)
|
const activeChatPeer = ref<MeshPeer | null>(null)
|
||||||
const activeChatChannel = ref<{ index: number; name: string } | null>(null)
|
const activeChatChannel = ref<{ index: number; name: string } | null>(null)
|
||||||
@ -18,6 +28,7 @@ const broadcasting = ref(false)
|
|||||||
const configuring = ref(false)
|
const configuring = ref(false)
|
||||||
const connectingDevice = ref<string | null>(null)
|
const connectingDevice = ref<string | null>(null)
|
||||||
const chatScrollEl = ref<HTMLElement | null>(null)
|
const chatScrollEl = ref<HTMLElement | null>(null)
|
||||||
|
const mobileShowChat = ref(false)
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
// The Public channel (always available on Meshcore)
|
// The Public channel (always available on Meshcore)
|
||||||
@ -43,6 +54,27 @@ const deadmanInterval = ref('21600')
|
|||||||
const deadmanEnabled = ref(false)
|
const deadmanEnabled = ref(false)
|
||||||
const deadmanCustomMsg = ref('')
|
const deadmanCustomMsg = ref('')
|
||||||
|
|
||||||
|
// Tools tab for 3rd column on wide desktop and mobile below-chat
|
||||||
|
const toolsTab = ref<'bitcoin' | 'deadman'>('bitcoin')
|
||||||
|
|
||||||
|
// Panel visibility computeds
|
||||||
|
const showChatPanel = computed(() =>
|
||||||
|
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
|
||||||
|
)
|
||||||
|
// On wide desktop + mobile first view: tools use their own tab bar
|
||||||
|
const showBitcoinPanel = computed(() => {
|
||||||
|
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'bitcoin'
|
||||||
|
return activeTab.value === 'bitcoin'
|
||||||
|
})
|
||||||
|
const showDeadmanPanel = computed(() => {
|
||||||
|
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'deadman'
|
||||||
|
return activeTab.value === 'deadman'
|
||||||
|
})
|
||||||
|
// Mobile tools: show on first view (peers), hide when in chat
|
||||||
|
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
|
||||||
|
// Medium desktop: show 3-tab bar. Wide + mobile: hidden (tools has own tab bar)
|
||||||
|
const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value)
|
||||||
|
|
||||||
// Fetch session status when active peer changes
|
// Fetch session status when active peer changes
|
||||||
watch(() => activeChatPeer.value, async (peer) => {
|
watch(() => activeChatPeer.value, async (peer) => {
|
||||||
if (peer) {
|
if (peer) {
|
||||||
@ -184,6 +216,7 @@ function formatTimeRemaining(secs: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
|
await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
|
||||||
// Sync deadman UI state from server
|
// Sync deadman UI state from server
|
||||||
if (mesh.deadmanStatus) {
|
if (mesh.deadmanStatus) {
|
||||||
@ -200,6 +233,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
if (pollInterval) clearInterval(pollInterval)
|
if (pollInterval) clearInterval(pollInterval)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -253,6 +287,7 @@ function openChat(peer: MeshPeer) {
|
|||||||
sendError.value = ''
|
sendError.value = ''
|
||||||
messageText.value = ''
|
messageText.value = ''
|
||||||
activeTab.value = 'chat'
|
activeTab.value = 'chat'
|
||||||
|
mobileShowChat.value = true
|
||||||
mesh.markChatRead(peer.contact_id)
|
mesh.markChatRead(peer.contact_id)
|
||||||
nextTick(() => scrollChatToBottom())
|
nextTick(() => scrollChatToBottom())
|
||||||
}
|
}
|
||||||
@ -263,12 +298,14 @@ function openChannelChat(channel: { index: number; name: string }) {
|
|||||||
sendError.value = ''
|
sendError.value = ''
|
||||||
messageText.value = ''
|
messageText.value = ''
|
||||||
activeTab.value = 'chat'
|
activeTab.value = 'chat'
|
||||||
|
mobileShowChat.value = true
|
||||||
nextTick(() => scrollChatToBottom())
|
nextTick(() => scrollChatToBottom())
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeChat() {
|
function closeChat() {
|
||||||
activeChatPeer.value = null
|
activeChatPeer.value = null
|
||||||
activeChatChannel.value = null
|
activeChatChannel.value = null
|
||||||
|
mobileShowChat.value = false
|
||||||
mesh.clearViewingChat()
|
mesh.clearViewingChat()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -360,10 +397,10 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
<!-- Error banner -->
|
<!-- Error banner -->
|
||||||
<div v-if="mesh.error" class="mesh-error">{{ mesh.error }}</div>
|
<div v-if="mesh.error" class="mesh-error">{{ mesh.error }}</div>
|
||||||
|
|
||||||
<!-- Two-column layout (desktop) / single-column (mobile) -->
|
<!-- Responsive column layout: 3-col (wide), 2-col (medium), 1-col (mobile) -->
|
||||||
<div class="mesh-columns">
|
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop }">
|
||||||
<!-- LEFT COLUMN: Status + Peers -->
|
<!-- LEFT COLUMN: Status + Peers -->
|
||||||
<div class="mesh-left">
|
<div class="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
|
||||||
<!-- Device Status -->
|
<!-- Device Status -->
|
||||||
<div class="glass-card mesh-status-card">
|
<div class="glass-card mesh-status-card">
|
||||||
<div class="mesh-status-header">
|
<div class="mesh-status-header">
|
||||||
@ -499,9 +536,9 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: Tabbed panels -->
|
<!-- RIGHT COLUMN: Tabbed panels -->
|
||||||
<div class="mesh-right">
|
<div class="mesh-right" :class="{ 'mobile-hidden': !mobileShowChat }">
|
||||||
<!-- Tab bar -->
|
<!-- Tab bar (medium desktop only) -->
|
||||||
<div class="mesh-tab-bar">
|
<div v-if="showTabBar" class="mesh-tab-bar">
|
||||||
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
|
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
|
||||||
<button class="mesh-tab" :class="{ active: activeTab === 'bitcoin' }" @click="activeTab = 'bitcoin'">
|
<button class="mesh-tab" :class="{ active: activeTab === 'bitcoin' }" @click="activeTab = 'bitcoin'">
|
||||||
Off-Grid Bitcoin
|
Off-Grid Bitcoin
|
||||||
@ -513,146 +550,8 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Off-Grid Bitcoin Panel -->
|
<!-- Chat Panel (before tools so it gets grid-column 2 on wide) -->
|
||||||
<div v-if="activeTab === 'bitcoin'" class="glass-card mesh-bitcoin-panel">
|
<div v-if="showChatPanel" class="glass-card mesh-chat-card">
|
||||||
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
|
|
||||||
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
|
|
||||||
|
|
||||||
<!-- Relay status notification -->
|
|
||||||
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
|
|
||||||
{{ relayResult }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Block Headers -->
|
|
||||||
<div class="mesh-bitcoin-section">
|
|
||||||
<div class="mesh-bitcoin-section-header">
|
|
||||||
<span class="mesh-bitcoin-label">Latest Block</span>
|
|
||||||
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
|
|
||||||
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="mesh.blockHeaders.length" class="mesh-block-list">
|
|
||||||
<div v-for="h in mesh.blockHeaders.slice(0, 2)" :key="h.height" class="mesh-block-row">
|
|
||||||
<span class="mesh-block-height">#{{ h.height.toLocaleString() }}</span>
|
|
||||||
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- On-Chain / Lightning tabs -->
|
|
||||||
<div class="mesh-send-tabs">
|
|
||||||
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
|
|
||||||
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- On-Chain tab -->
|
|
||||||
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
|
|
||||||
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
|
|
||||||
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
|
|
||||||
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
|
|
||||||
<div class="mesh-relay-mode">
|
|
||||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
|
|
||||||
<input type="radio" v-model="relayMode" value="archy" />
|
|
||||||
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
|
|
||||||
</label>
|
|
||||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
|
|
||||||
<input type="radio" v-model="relayMode" value="broadcast" />
|
|
||||||
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
|
|
||||||
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
|
|
||||||
</button>
|
|
||||||
<details class="mesh-bitcoin-advanced">
|
|
||||||
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
|
|
||||||
<div style="margin-top: 8px;">
|
|
||||||
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
|
|
||||||
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
|
|
||||||
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Lightning tab -->
|
|
||||||
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
|
|
||||||
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
|
|
||||||
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
|
|
||||||
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
|
|
||||||
<div class="mesh-relay-mode">
|
|
||||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
|
|
||||||
<input type="radio" v-model="relayMode" value="archy" />
|
|
||||||
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
|
|
||||||
</label>
|
|
||||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
|
|
||||||
<input type="radio" v-model="relayMode" value="broadcast" />
|
|
||||||
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
|
|
||||||
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dead Man's Switch Panel -->
|
|
||||||
<div v-if="activeTab === 'deadman'" class="glass-card mesh-deadman-panel">
|
|
||||||
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
|
|
||||||
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
|
|
||||||
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
|
|
||||||
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
|
|
||||||
</div>
|
|
||||||
<div v-if="mesh.deadmanStatus.dead_man_enabled && !mesh.deadmanStatus.triggered" class="mesh-deadman-timer">
|
|
||||||
{{ formatTimeRemaining(mesh.deadmanStatus.time_remaining_secs) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="deadmanCustomMsg || mesh.deadmanStatus.dead_man_enabled" class="mesh-deadman-message">
|
|
||||||
{{ deadmanCustomMsg || 'Dead man\'s switch triggered — operator unresponsive' }}
|
|
||||||
</div>
|
|
||||||
<button v-if="mesh.deadmanStatus.dead_man_enabled" class="glass-button mesh-deadman-checkin-btn" @click="handleDeadmanCheckin">
|
|
||||||
I'm OK — Check In
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Configuration -->
|
|
||||||
<div class="mesh-deadman-config">
|
|
||||||
<label class="mesh-deadman-toggle">
|
|
||||||
<input v-model="deadmanEnabled" type="checkbox" @change="handleDeadmanToggle" />
|
|
||||||
<span>{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<template v-if="deadmanEnabled">
|
|
||||||
<div class="mesh-deadman-field">
|
|
||||||
<label class="mesh-bitcoin-label">Trigger Interval</label>
|
|
||||||
<select v-model="deadmanInterval" class="mesh-bitcoin-input mesh-bitcoin-input-sm">
|
|
||||||
<option value="3600">1 hour</option>
|
|
||||||
<option value="21600">6 hours</option>
|
|
||||||
<option value="43200">12 hours</option>
|
|
||||||
<option value="86400">24 hours</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mesh-deadman-field">
|
|
||||||
<label class="mesh-bitcoin-label">Alert Message</label>
|
|
||||||
<input v-model="deadmanCustomMsg" class="mesh-bitcoin-input" placeholder="Dead man's switch triggered — operator unresponsive" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mesh-deadman-info">
|
|
||||||
<span v-if="mesh.deadmanStatus?.has_gps" class="mesh-deadman-info-item">GPS: included</span>
|
|
||||||
<span class="mesh-deadman-info-item">Contacts: {{ mesh.deadmanStatus?.emergency_contacts ?? 0 }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="glass-button" :disabled="deadmanConfiguring" @click="handleDeadmanConfigure">
|
|
||||||
{{ deadmanConfiguring ? 'Saving...' : 'Save Configuration' }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chat Panel (existing) -->
|
|
||||||
<div v-if="activeTab === 'chat'" class="glass-card mesh-chat-card">
|
|
||||||
<!-- No chat selected -->
|
<!-- No chat selected -->
|
||||||
<div v-if="!hasActiveChat" class="mesh-chat-empty">
|
<div v-if="!hasActiveChat" class="mesh-chat-empty">
|
||||||
<div class="mesh-chat-empty-icon">📡</div>
|
<div class="mesh-chat-empty-icon">📡</div>
|
||||||
@ -758,12 +657,228 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
{{ mesh.sending ? '...' : 'Send' }}
|
{{ mesh.sending ? '...' : 'Send' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mesh-chat-compose-meta">
|
|
||||||
<span>{{ messageText.length }}/160</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Off-Grid Bitcoin + Dead Man's Switch panels -->
|
||||||
|
<div class="mesh-tools-wrapper">
|
||||||
|
<!-- Tools tab bar (wide desktop only — mobile has its own outside mesh-right) -->
|
||||||
|
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
|
||||||
|
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
|
||||||
|
Off-Grid Bitcoin
|
||||||
|
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
|
||||||
|
Dead Man
|
||||||
|
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Off-Grid Bitcoin Panel -->
|
||||||
|
<div v-if="showBitcoinPanel" class="glass-card mesh-bitcoin-panel">
|
||||||
|
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
|
||||||
|
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
|
||||||
|
|
||||||
|
<!-- Relay status notification -->
|
||||||
|
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
|
||||||
|
{{ relayResult }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Block Headers -->
|
||||||
|
<div class="mesh-bitcoin-section">
|
||||||
|
<div class="mesh-bitcoin-section-header">
|
||||||
|
<span class="mesh-bitcoin-label">Latest Block</span>
|
||||||
|
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
|
||||||
|
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="mesh.blockHeaders.length" class="mesh-block-list">
|
||||||
|
<div v-for="h in mesh.blockHeaders.slice(0, 2)" :key="h.height" class="mesh-block-row">
|
||||||
|
<span class="mesh-block-height">#{{ h.height.toLocaleString() }}</span>
|
||||||
|
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- On-Chain / Lightning tabs -->
|
||||||
|
<div class="mesh-send-tabs">
|
||||||
|
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
|
||||||
|
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- On-Chain tab -->
|
||||||
|
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
|
||||||
|
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
|
||||||
|
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
|
||||||
|
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
|
||||||
|
<div class="mesh-relay-mode">
|
||||||
|
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
|
||||||
|
<input type="radio" v-model="relayMode" value="archy" />
|
||||||
|
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
|
||||||
|
</label>
|
||||||
|
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
|
||||||
|
<input type="radio" v-model="relayMode" value="broadcast" />
|
||||||
|
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
|
||||||
|
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
|
||||||
|
</button>
|
||||||
|
<details class="mesh-bitcoin-advanced">
|
||||||
|
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
|
||||||
|
<div style="margin-top: 8px;">
|
||||||
|
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
|
||||||
|
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
|
||||||
|
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightning tab -->
|
||||||
|
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
|
||||||
|
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
|
||||||
|
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
|
||||||
|
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
|
||||||
|
<div class="mesh-relay-mode">
|
||||||
|
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
|
||||||
|
<input type="radio" v-model="relayMode" value="archy" />
|
||||||
|
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
|
||||||
|
</label>
|
||||||
|
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
|
||||||
|
<input type="radio" v-model="relayMode" value="broadcast" />
|
||||||
|
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
|
||||||
|
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dead Man's Switch Panel -->
|
||||||
|
<div v-if="showDeadmanPanel" class="glass-card mesh-deadman-panel">
|
||||||
|
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
|
||||||
|
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
|
||||||
|
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
|
||||||
|
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="mesh.deadmanStatus.dead_man_enabled && !mesh.deadmanStatus.triggered" class="mesh-deadman-timer">
|
||||||
|
{{ formatTimeRemaining(mesh.deadmanStatus.time_remaining_secs) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="deadmanCustomMsg || mesh.deadmanStatus.dead_man_enabled" class="mesh-deadman-message">
|
||||||
|
{{ deadmanCustomMsg || 'Dead man\'s switch triggered — operator unresponsive' }}
|
||||||
|
</div>
|
||||||
|
<button v-if="mesh.deadmanStatus.dead_man_enabled" class="glass-button mesh-deadman-checkin-btn" @click="handleDeadmanCheckin">
|
||||||
|
I'm OK — Check In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration -->
|
||||||
|
<div class="mesh-deadman-config">
|
||||||
|
<button
|
||||||
|
@click="deadmanEnabled = !deadmanEnabled; handleDeadmanToggle()"
|
||||||
|
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left mb-3"
|
||||||
|
:class="deadmanEnabled
|
||||||
|
? 'bg-white/10 border-orange-500/40'
|
||||||
|
: 'bg-black/20 border-white/10 hover:border-white/20'"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 shrink-0" :class="deadmanEnabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium" :class="deadmanEnabled ? 'text-white/95' : 'text-white/70'">{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</p>
|
||||||
|
<p class="text-xs text-white/50 mt-0.5">Auto-alerts your contacts if you don't check in</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch :model-value="deadmanEnabled" @click.stop @update:model-value="deadmanEnabled = $event; handleDeadmanToggle()" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-if="deadmanEnabled">
|
||||||
|
<div class="mesh-deadman-field">
|
||||||
|
<label class="mesh-bitcoin-label">Trigger Interval</label>
|
||||||
|
<select v-model="deadmanInterval" class="mesh-bitcoin-input mesh-bitcoin-input-sm">
|
||||||
|
<option value="3600">1 hour</option>
|
||||||
|
<option value="21600">6 hours</option>
|
||||||
|
<option value="43200">12 hours</option>
|
||||||
|
<option value="86400">24 hours</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mesh-deadman-field">
|
||||||
|
<label class="mesh-bitcoin-label">Alert Message</label>
|
||||||
|
<input v-model="deadmanCustomMsg" class="mesh-bitcoin-input" placeholder="Dead man's switch triggered — operator unresponsive" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mesh-deadman-info">
|
||||||
|
<span v-if="mesh.deadmanStatus?.has_gps" class="mesh-deadman-info-item">GPS: included</span>
|
||||||
|
<span class="mesh-deadman-info-item">Contacts: {{ mesh.deadmanStatus?.emergency_contacts ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="glass-button" :disabled="deadmanConfiguring" @click="handleDeadmanConfigure">
|
||||||
|
{{ deadmanConfiguring ? 'Saving...' : 'Save Configuration' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /.mesh-tools-wrapper -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile tools: show under peers list on first view -->
|
||||||
|
<div v-if="showMobileTools" class="mesh-mobile-tools">
|
||||||
|
<div class="mesh-tools-tab-bar">
|
||||||
|
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
|
||||||
|
Off-Grid Bitcoin
|
||||||
|
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
|
||||||
|
Dead Man
|
||||||
|
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="showBitcoinPanel" class="glass-card mesh-bitcoin-panel">
|
||||||
|
<!-- Reuse same content via a shared approach - for now inline -->
|
||||||
|
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
|
||||||
|
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
|
||||||
|
<div class="mesh-bitcoin-section">
|
||||||
|
<div class="mesh-bitcoin-section-header">
|
||||||
|
<span class="mesh-bitcoin-label">Latest Block</span>
|
||||||
|
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
|
||||||
|
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showDeadmanPanel" class="glass-card mesh-deadman-panel">
|
||||||
|
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
|
||||||
|
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
|
||||||
|
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
|
||||||
|
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
|
||||||
|
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mesh-deadman-config">
|
||||||
|
<button
|
||||||
|
@click="deadmanEnabled = !deadmanEnabled; handleDeadmanToggle()"
|
||||||
|
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left"
|
||||||
|
:class="deadmanEnabled
|
||||||
|
? 'bg-white/10 border-orange-500/40'
|
||||||
|
: 'bg-black/20 border-white/10 hover:border-white/20'"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 shrink-0" :class="deadmanEnabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium" :class="deadmanEnabled ? 'text-white/95' : 'text-white/70'">{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</p>
|
||||||
|
<p class="text-xs text-white/50 mt-0.5">Auto-alerts your contacts if you don't check in</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch :model-value="deadmanEnabled" @click.stop @update:model-value="deadmanEnabled = $event; handleDeadmanToggle()" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -772,7 +887,7 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.mesh-view {
|
.mesh-view {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
max-width: 1200px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -873,6 +988,65 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tools wrapper: holds Off-Grid Bitcoin + Dead Man panels */
|
||||||
|
.mesh-tools-wrapper {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tools tab bar: hidden by default (medium desktop uses main tab bar) */
|
||||||
|
.mesh-tools-tab-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Wide desktop: 3-column layout ─── */
|
||||||
|
.mesh-columns-wide {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 340px 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesh-columns-wide .mesh-left {
|
||||||
|
grid-column: 1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesh-columns-wide .mesh-right {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesh-columns-wide .mesh-chat-card {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesh-columns-wide .mesh-tools-wrapper {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesh-columns-wide .mesh-tools-tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide main tab bar and mobile back button on wide desktop */
|
||||||
|
.mesh-columns-wide .mesh-mobile-back-btn,
|
||||||
|
.mesh-columns-wide .mesh-tab-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Status card ─── */
|
/* ─── Status card ─── */
|
||||||
.mesh-status-card { padding: 16px; flex-shrink: 0; }
|
.mesh-status-card { padding: 16px; flex-shrink: 0; }
|
||||||
|
|
||||||
@ -1408,12 +1582,17 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Mobile: keep single column ─── */
|
/* ─── Mobile back button (hidden on desktop) ─── */
|
||||||
@media (max-width: 768px) {
|
.mesh-mobile-back-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Mobile: single column with panel switching ─── */
|
||||||
|
@media (max-width: 1279px) {
|
||||||
.mesh-view {
|
.mesh-view {
|
||||||
height: auto;
|
height: auto;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
padding: 0 12px 12px 12px;
|
padding: 0 12px 100px 12px; /* bottom padding clears tab bar */
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-columns {
|
.mesh-columns {
|
||||||
@ -1427,7 +1606,44 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-right {
|
.mesh-right {
|
||||||
min-height: 400px;
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat takes available viewport height minus tab bars */
|
||||||
|
.mesh-chat-card {
|
||||||
|
min-height: 60dvh;
|
||||||
|
max-height: 75dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide tools-wrapper inside mesh-right (shown via mesh-mobile-tools instead) */
|
||||||
|
.mesh-tools-wrapper {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tools section under peers — fixed height so no jump on tab switch */
|
||||||
|
.mesh-mobile-tools {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesh-mobile-tools .mesh-tools-tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed-height panel container so switching tabs doesn't resize */
|
||||||
|
.mesh-mobile-tools .mesh-bitcoin-panel,
|
||||||
|
.mesh-mobile-tools .mesh-deadman-panel {
|
||||||
|
min-height: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-status-grid {
|
.mesh-status-grid {
|
||||||
@ -1437,6 +1653,23 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
.mesh-chat-back {
|
.mesh-chat-back {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide panel on mobile when toggled */
|
||||||
|
.mobile-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bitcoin and deadman panels should not flex-grow on mobile */
|
||||||
|
.mesh-bitcoin-panel,
|
||||||
|
.mesh-deadman-panel {
|
||||||
|
flex: none;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesh-mobile-back-btn:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Session badge ─── */
|
/* ─── Session badge ─── */
|
||||||
@ -1571,7 +1804,7 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
}
|
}
|
||||||
.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.3); }
|
.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.3); }
|
||||||
.mesh-bitcoin-input:focus { outline: none; border-color: rgba(251,146,60,0.4); }
|
.mesh-bitcoin-input:focus { outline: none; border-color: rgba(251,146,60,0.4); }
|
||||||
.mesh-bitcoin-input-sm { max-width: 200px; }
|
.mesh-bitcoin-input-sm { width: 100%; }
|
||||||
.mesh-relay-mode {
|
.mesh-relay-mode {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@ -102,14 +102,7 @@
|
|||||||
:key="rule.kind"
|
:key="rule.kind"
|
||||||
class="flex items-center gap-3 p-3 bg-white/5 rounded-lg"
|
class="flex items-center gap-3 p-3 bg-white/5 rounded-lg"
|
||||||
>
|
>
|
||||||
<label class="monitoring-alert-toggle">
|
<ToggleSwitch :model-value="rule.enabled" @update:model-value="toggleAlertRule(rule.kind, $event)" />
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="rule.enabled"
|
|
||||||
@change="toggleAlertRule(rule.kind, !rule.enabled)"
|
|
||||||
/>
|
|
||||||
<span class="monitoring-alert-toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm text-white">{{ ruleLabel(rule.kind) }}</p>
|
<p class="text-sm text-white">{{ ruleLabel(rule.kind) }}</p>
|
||||||
<p class="text-xs text-white/40">{{ rule.description }}</p>
|
<p class="text-xs text-white/40">{{ rule.description }}</p>
|
||||||
@ -223,6 +216,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import LineChart from '@/components/LineChart.vue'
|
import LineChart from '@/components/LineChart.vue'
|
||||||
|
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||||
|
|
||||||
interface SystemMetrics {
|
interface SystemMetrics {
|
||||||
|
|||||||
@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
<div v-else-if="catalogError" class="glass-card p-6">
|
<div v-else-if="catalogError" class="glass-card p-6">
|
||||||
<p class="text-red-400 text-sm mb-3">{{ catalogError }}</p>
|
<div class="alert-error mb-4">{{ catalogError }}</div>
|
||||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button>
|
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -88,16 +88,7 @@
|
|||||||
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
|
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ToggleSwitch v-model="autoSyncEnabled" />
|
||||||
@click="toggleAutoSync"
|
|
||||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors shrink-0"
|
|
||||||
:class="autoSyncEnabled ? 'bg-green-500' : 'bg-white/20'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform shadow"
|
|
||||||
:class="autoSyncEnabled ? 'translate-x-7' : 'translate-x-1'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -123,7 +114,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Overview Cards -->
|
<!-- Overview Cards -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
|
||||||
<!-- Local Network Card -->
|
<!-- Local Network Card -->
|
||||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
|
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
|
||||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||||
@ -284,92 +275,86 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Network Interfaces -->
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="glass-card p-6 mb-6">
|
<!-- Network Interfaces -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="glass-card p-6">
|
||||||
<div>
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
|
<div>
|
||||||
<p class="text-sm text-white/60">Detected hardware and virtual interfaces</p>
|
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
|
||||||
</div>
|
<p class="text-sm text-white/60">Detected hardware and virtual interfaces</p>
|
||||||
<button
|
</div>
|
||||||
v-if="wifiAvailable"
|
<button
|
||||||
@click="showWifiModal = true"
|
v-if="wifiAvailable"
|
||||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
@click="showWifiModal = true"
|
||||||
>
|
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||||
Scan WiFi
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="interfacesLoading">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div v-for="i in 3" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-14"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="iface in physicalInterfaces"
|
|
||||||
:key="iface.name"
|
|
||||||
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
Scan WiFi
|
||||||
<div class="w-2 h-2 rounded-full" :class="iface.state === 'up' ? 'bg-green-400' : 'bg-white/30'"></div>
|
</button>
|
||||||
<div>
|
</div>
|
||||||
<p class="text-sm font-medium text-white">{{ iface.name }}</p>
|
|
||||||
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} · {{ iface.mac }}</p>
|
<template v-if="interfacesLoading">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-for="i in 3" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-14"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="iface in physicalInterfaces"
|
||||||
|
:key="iface.name"
|
||||||
|
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="iface.state === 'up' ? 'bg-green-400' : 'bg-white/30'"></div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-white">{{ iface.name }}</p>
|
||||||
|
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} · {{ iface.mac }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p v-if="iface.ipv4.length > 0" class="text-sm text-white/80">{{ iface.ipv4[0] }}</p>
|
||||||
|
<p v-else class="text-sm text-white/40">No IP</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
|
||||||
<p v-if="iface.ipv4.length > 0" class="text-sm text-white/80">{{ iface.ipv4[0] }}</p>
|
|
||||||
<p v-else class="text-sm text-white/40">No IP</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
|
</template>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tor Services -->
|
|
||||||
<div class="glass-card px-6 py-6 mb-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-semibold text-white/96">Tor Services</h2>
|
|
||||||
<p class="text-sm text-white/60 mt-1">Manage hidden service addresses for your node and apps</p>
|
|
||||||
</div>
|
|
||||||
<button @click="loadTorServices" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="torServicesLoading && torServices.length === 0" class="text-sm text-white/40 py-4 text-center">Loading Tor services...</div>
|
|
||||||
<div v-else-if="torServices.length === 0" class="text-sm text-white/40 py-4 text-center">No Tor services configured</div>
|
<!-- Tor Services -->
|
||||||
<div v-else class="space-y-2">
|
<div class="glass-card p-6">
|
||||||
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex-1 min-w-0">
|
<div>
|
||||||
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
|
<h2 class="text-xl font-semibold text-white/96">Tor Services</h2>
|
||||||
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="copyTorAddress(svc.onion_address)">{{ svc.onion_address }}</p>
|
<p class="text-sm text-white/60 mt-1">Manage hidden service addresses for your node and apps</p>
|
||||||
<p v-else class="text-white/30 text-xs">No .onion address</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<button @click="loadTorServices" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
||||||
<button
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
v-if="svc.onion_address && svc.enabled"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
@click="rotateService(svc.name)"
|
</svg>
|
||||||
:disabled="torRotating === svc.name"
|
Refresh
|
||||||
class="glass-button px-3 py-1.5 rounded-lg text-xs"
|
</button>
|
||||||
>
|
</div>
|
||||||
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
|
<div v-if="torServicesLoading && torServices.length === 0" class="text-sm text-white/40 py-4 text-center">Loading Tor services...</div>
|
||||||
</button>
|
<div v-else-if="torServices.length === 0" class="text-sm text-white/40 py-4 text-center">No Tor services configured</div>
|
||||||
<label class="tor-toggle-label">
|
<div v-else class="space-y-2">
|
||||||
<input
|
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
|
||||||
type="checkbox"
|
<div class="flex-1 min-w-0">
|
||||||
:checked="svc.enabled"
|
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
|
||||||
@change="toggleTorApp(svc.name, !svc.enabled)"
|
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="copyTorAddress(svc.onion_address)">{{ svc.onion_address }}</p>
|
||||||
class="tor-toggle-input"
|
<p v-else class="text-white/30 text-xs">No .onion address</p>
|
||||||
/>
|
</div>
|
||||||
<span class="tor-toggle-slider"></span>
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
</label>
|
<button
|
||||||
|
v-if="svc.onion_address && svc.enabled"
|
||||||
|
@click="rotateService(svc.name)"
|
||||||
|
:disabled="torRotating === svc.name"
|
||||||
|
class="glass-button px-3 py-1.5 rounded-lg text-xs"
|
||||||
|
>
|
||||||
|
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
|
||||||
|
</button>
|
||||||
|
<ToggleSwitch :model-value="svc.enabled" @update:model-value="toggleTorApp(svc.name, $event)" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -512,6 +497,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||||
|
|
||||||
// Connected nodes
|
// Connected nodes
|
||||||
const connectedNodes = ref(0)
|
const connectedNodes = ref(0)
|
||||||
@ -904,10 +890,6 @@ async function checkConnectivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAutoSync() {
|
|
||||||
autoSyncEnabled.value = !autoSyncEnabled.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const logsToast = ref('')
|
const logsToast = ref('')
|
||||||
|
|
||||||
function viewLogs() {
|
function viewLogs() {
|
||||||
|
|||||||
@ -247,7 +247,7 @@
|
|||||||
<div data-controller-container tabindex="0" class="mb-6">
|
<div data-controller-container tabindex="0" class="mb-6">
|
||||||
<button
|
<button
|
||||||
@click="showChangePasswordModal = true"
|
@click="showChangePasswordModal = true"
|
||||||
class="w-full flex items-center justify-center gap-2 mb-4 px-4 py-2 rounded-lg border border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10 transition-colors"
|
class="w-full flex items-center justify-center gap-2 mb-4 px-4 py-2 rounded-lg glass-button glass-button-warning font-medium"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
@ -341,7 +341,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="text-xs font-semibold px-2 py-1 rounded-full"
|
class="text-xs font-semibold px-2 py-1 rounded-full"
|
||||||
:class="totpEnabled ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-white/50'"
|
:class="totpEnabled ? 'status-success' : 'bg-white/10 text-white/50'"
|
||||||
>
|
>
|
||||||
{{ totpEnabled ? t('common.enabled') : t('common.disabled') }}
|
{{ totpEnabled ? t('common.enabled') : t('common.disabled') }}
|
||||||
</span>
|
</span>
|
||||||
@ -349,7 +349,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="!totpEnabled"
|
v-if="!totpEnabled"
|
||||||
@click="showTotpSetupModal = true"
|
@click="showTotpSetupModal = true"
|
||||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10 transition-colors"
|
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg glass-button glass-button-warning font-medium"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
@ -359,7 +359,7 @@
|
|||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
@click="showTotpDisableModal = true"
|
@click="showTotpDisableModal = true"
|
||||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-red-500/50 text-red-400 font-medium hover:bg-red-500/10 transition-colors"
|
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg glass-button glass-button-danger font-medium"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||||
@ -617,7 +617,7 @@
|
|||||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors"
|
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors"
|
||||||
:class="claudeConnected
|
:class="claudeConnected
|
||||||
? 'border-white/20 text-white/70 hover:bg-white/5'
|
? 'border-white/20 text-white/70 hover:bg-white/5'
|
||||||
: 'border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10'"
|
: 'glass-button-warning font-medium'"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||||
@ -674,15 +674,7 @@
|
|||||||
<p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">{{ t('common.enableAll') }}</p>
|
<p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">{{ t('common.enableAll') }}</p>
|
||||||
<p class="text-xs text-white/50 mt-0.5">{{ t('settings.enableAllDesc') }}</p>
|
<p class="text-xs text-white/50 mt-0.5">{{ t('settings.enableAllDesc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<ToggleSwitch :model-value="aiPermissions.allEnabled" @update:model-value="aiPermissions.allEnabled ? aiPermissions.disableAll() : aiPermissions.enableAll()" @click.stop />
|
||||||
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
|
|
||||||
:class="aiPermissions.allEnabled ? 'bg-orange-500' : 'bg-white/15'"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
|
||||||
:class="aiPermissions.allEnabled ? 'translate-x-5' : 'translate-x-1'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
@ -705,15 +697,7 @@
|
|||||||
<p class="text-sm font-medium" :class="aiPermissions.isEnabled(cat.id) ? 'text-white/95' : 'text-white/70'">{{ cat.label }}</p>
|
<p class="text-sm font-medium" :class="aiPermissions.isEnabled(cat.id) ? 'text-white/95' : 'text-white/70'">{{ cat.label }}</p>
|
||||||
<p class="text-xs text-white/50 mt-0.5">{{ cat.description }}</p>
|
<p class="text-xs text-white/50 mt-0.5">{{ cat.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<ToggleSwitch :model-value="aiPermissions.isEnabled(cat.id)" @update:model-value="aiPermissions.toggle(cat.id)" @click.stop />
|
||||||
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
|
|
||||||
:class="aiPermissions.isEnabled(cat.id) ? 'bg-orange-500' : 'bg-white/15'"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
|
||||||
:class="aiPermissions.isEnabled(cat.id) ? 'translate-x-5' : 'translate-x-1'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -744,22 +728,7 @@
|
|||||||
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.webhookNotifications') }}</h2>
|
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.webhookNotifications') }}</h2>
|
||||||
<p class="text-sm text-white/60 mt-1">{{ t('settings.webhookNotificationsDesc') }}</p>
|
<p class="text-sm text-white/60 mt-1">{{ t('settings.webhookNotificationsDesc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<ToggleSwitch :model-value="webhookConfig.enabled" @update:model-value="toggleWebhookEnabled" />
|
||||||
<button
|
|
||||||
@click="toggleWebhookEnabled"
|
|
||||||
role="switch"
|
|
||||||
:aria-checked="webhookConfig.enabled"
|
|
||||||
:aria-label="webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
|
|
||||||
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
|
|
||||||
:class="webhookConfig.enabled ? 'bg-orange-500' : 'bg-white/15'"
|
|
||||||
:title="webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
|
||||||
:class="webhookConfig.enabled ? 'translate-x-5' : 'translate-x-1'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@ -824,7 +793,7 @@
|
|||||||
<button
|
<button
|
||||||
@click="saveWebhookConfig"
|
@click="saveWebhookConfig"
|
||||||
:disabled="savingWebhook"
|
:disabled="savingWebhook"
|
||||||
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 bg-orange-500/20 border-orange-500/30 disabled:opacity-50"
|
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ savingWebhook ? t('settings.savingWebhook') : t('common.saveConfiguration') }}
|
{{ savingWebhook ? t('settings.savingWebhook') : t('common.saveConfiguration') }}
|
||||||
</button>
|
</button>
|
||||||
@ -839,7 +808,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Webhook status message -->
|
<!-- Webhook status message -->
|
||||||
<div v-if="webhookStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="webhookStatusType === 'error' ? 'bg-red-500/15 text-red-300' : 'bg-green-500/15 text-green-300'">
|
<div v-if="webhookStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="webhookStatusType === 'error' ? 'alert-error' : 'alert-success'">
|
||||||
{{ webhookStatusMsg }}
|
{{ webhookStatusMsg }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -855,7 +824,7 @@
|
|||||||
@click="toggleTelemetry"
|
@click="toggleTelemetry"
|
||||||
:disabled="telemetryLoading"
|
:disabled="telemetryLoading"
|
||||||
class="shrink-0 ml-4 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
class="shrink-0 ml-4 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
:class="telemetryEnabled ? 'bg-green-500/20 text-green-300 border border-green-500/30 hover:bg-green-500/30' : 'glass-button'"
|
:class="telemetryEnabled ? 'glass-button glass-button-success' : 'glass-button'"
|
||||||
>
|
>
|
||||||
{{ telemetryLoading ? '...' : telemetryEnabled ? 'Enabled' : 'Enable' }}
|
{{ telemetryLoading ? '...' : telemetryEnabled ? 'Enabled' : 'Enable' }}
|
||||||
</button>
|
</button>
|
||||||
@ -906,7 +875,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Backup status message -->
|
<!-- Backup status message -->
|
||||||
<div v-if="backupStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="backupStatusType === 'error' ? 'bg-red-500/15 text-red-300' : 'bg-green-500/15 text-green-300'">
|
<div v-if="backupStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="backupStatusType === 'error' ? 'alert-error' : 'alert-success'">
|
||||||
{{ backupStatusMsg }}
|
{{ backupStatusMsg }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -928,7 +897,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 mt-5">
|
<div class="flex gap-3 mt-5">
|
||||||
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
|
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
|
||||||
<button @click="createBackup" :disabled="creatingBackup || !backupPassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
|
<button @click="createBackup" :disabled="creatingBackup || !backupPassphrase" class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex-1 disabled:opacity-50">
|
||||||
{{ creatingBackup ? t('settings.creatingBackup') : t('settings.createBackup') }}
|
{{ creatingBackup ? t('settings.creatingBackup') : t('settings.createBackup') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -948,7 +917,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 mt-5">
|
<div class="flex gap-3 mt-5">
|
||||||
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
|
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
|
||||||
<button @click="restoreBackup" :disabled="restoringBackup || !restorePassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-red-500/20 border-red-500/30 disabled:opacity-50">
|
<button @click="restoreBackup" :disabled="restoringBackup || !restorePassphrase" class="glass-button glass-button-danger px-4 py-2 rounded-lg text-sm flex-1 disabled:opacity-50">
|
||||||
{{ restoringBackup ? t('common.restoring') : t('common.restore') }}
|
{{ restoringBackup ? t('common.restoring') : t('common.restore') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -979,7 +948,7 @@
|
|||||||
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.
|
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
class="glass-button text-red-400 border-red-500/30 hover:border-red-500/50"
|
class="glass-button glass-button-danger"
|
||||||
@click="showFactoryResetConfirm = true"
|
@click="showFactoryResetConfirm = true"
|
||||||
>
|
>
|
||||||
Factory Reset
|
Factory Reset
|
||||||
@ -997,7 +966,7 @@
|
|||||||
<div class="flex gap-3 justify-end">
|
<div class="flex gap-3 justify-end">
|
||||||
<button class="glass-button" @click="showFactoryResetConfirm = false">Cancel</button>
|
<button class="glass-button" @click="showFactoryResetConfirm = false">Cancel</button>
|
||||||
<button
|
<button
|
||||||
class="glass-button text-red-400 border-red-500/30"
|
class="glass-button glass-button-danger"
|
||||||
:disabled="factoryResetLoading"
|
:disabled="factoryResetLoading"
|
||||||
@click="performFactoryReset"
|
@click="performFactoryReset"
|
||||||
>
|
>
|
||||||
@ -1020,6 +989,7 @@ import { useAppStore } from '../stores/app'
|
|||||||
import { useUIModeStore } from '@/stores/uiMode'
|
import { useUIModeStore } from '@/stores/uiMode'
|
||||||
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
|
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
|
||||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||||
|
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
import type { UIMode } from '@/types/api'
|
import type { UIMode } from '@/types/api'
|
||||||
|
|||||||
@ -34,16 +34,16 @@
|
|||||||
<p v-else class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
|
<p v-else class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userDid" class="flex gap-2">
|
<div v-if="userDid" class="flex gap-2 mt-auto">
|
||||||
<button
|
<button
|
||||||
@click="copyDid"
|
@click="copyDid"
|
||||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
{{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }}
|
{{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="showDidDocument"
|
@click="showDidDocument"
|
||||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
{{ t('web5.viewDidDocument') }}
|
{{ t('web5.viewDidDocument') }}
|
||||||
</button>
|
</button>
|
||||||
@ -52,7 +52,7 @@
|
|||||||
v-else
|
v-else
|
||||||
@click="createDID"
|
@click="createDID"
|
||||||
:disabled="creatingDid"
|
:disabled="creatingDid"
|
||||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ creatingDid ? t('web5.creatingDid') : t('web5.createDid') }}
|
{{ creatingDid ? t('web5.creatingDid') : t('web5.createDid') }}
|
||||||
</button>
|
</button>
|
||||||
@ -70,17 +70,17 @@
|
|||||||
<p v-else class="text-xs text-white/60">Not published</p>
|
<p v-else class="text-xs text-white/60">Not published</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="dhtDid" class="flex gap-2">
|
<div v-if="dhtDid" class="flex gap-2 mt-auto">
|
||||||
<button
|
<button
|
||||||
@click="copyDhtDid"
|
@click="copyDhtDid"
|
||||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
{{ dhtDidCopied ? 'Copied!' : 'Copy' }}
|
{{ dhtDidCopied ? 'Copied!' : 'Copy' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="refreshDhtDid"
|
@click="refreshDhtDid"
|
||||||
:disabled="publishingDht"
|
:disabled="publishingDht"
|
||||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }}
|
{{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }}
|
||||||
</button>
|
</button>
|
||||||
@ -89,7 +89,7 @@
|
|||||||
v-else-if="userDid"
|
v-else-if="userDid"
|
||||||
@click="publishDhtDid"
|
@click="publishDhtDid"
|
||||||
:disabled="publishingDht"
|
:disabled="publishingDht"
|
||||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ publishingDht ? 'Publishing...' : 'Publish to DHT' }}
|
{{ publishingDht ? 'Publishing...' : 'Publish to DHT' }}
|
||||||
</button>
|
</button>
|
||||||
@ -109,7 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="connectWallet"
|
@click="connectWallet"
|
||||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||||
:disabled="connectingWallet"
|
:disabled="connectingWallet"
|
||||||
>
|
>
|
||||||
{{ connectingWallet ? t('common.connecting') : walletConnected ? t('common.disconnect') : t('common.connect') }}
|
{{ connectingWallet ? t('common.connecting') : walletConnected ? t('common.disconnect') : t('common.connect') }}
|
||||||
@ -130,7 +130,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="manageRelays"
|
@click="manageRelays"
|
||||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
{{ t('common.manage') }}
|
{{ t('common.manage') }}
|
||||||
</button>
|
</button>
|
||||||
@ -148,18 +148,18 @@
|
|||||||
<p class="text-xs text-white/60">{{ t('web5.peersKnown', { count: connectedNodesCount }) }}</p>
|
<p class="text-xs text-white/60">{{ t('web5.peersKnown', { count: connectedNodesCount }) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 mt-auto">
|
||||||
<button
|
<button
|
||||||
@click="router.push('/dashboard/server/federation')"
|
@click="router.push('/dashboard/server/federation')"
|
||||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
{{ t('web5.findNodes') }}
|
Nodes
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="showSendMessageModal = true"
|
@click="showSendMessageModal = true"
|
||||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
{{ t('web5.sendMessage') }}
|
Message
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -167,7 +167,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hardware Wallet Detected Banner -->
|
<!-- Hardware Wallet Detected Banner -->
|
||||||
<div v-if="detectedHwWallets.length > 0" class="mb-6 p-4 bg-orange-500/10 border border-orange-500/20 rounded-xl flex items-center gap-3">
|
<div v-if="detectedHwWallets.length > 0" class="mb-6 alert-warning flex items-center gap-3">
|
||||||
<div class="w-8 h-8 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0">
|
<div class="w-8 h-8 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0">
|
||||||
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
@ -413,6 +413,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
|
<div v-if="walletError" class="alert-error mb-3">{{ walletError }}</div>
|
||||||
|
|
||||||
<div class="space-y-3 flex-1 min-h-0">
|
<div class="space-y-3 flex-1 min-h-0">
|
||||||
<!-- On-chain Balance -->
|
<!-- On-chain Balance -->
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||||
@ -587,8 +589,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Connected Nodes + Shared Content grid -->
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
|
||||||
|
|
||||||
<!-- Connected Nodes (P2P over Tor) -->
|
<!-- Connected Nodes (P2P over Tor) -->
|
||||||
<div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 mb-8 scroll-mt-24">
|
<div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 scroll-mt-24 flex flex-col">
|
||||||
<!-- Desktop: side-by-side layout -->
|
<!-- Desktop: side-by-side layout -->
|
||||||
<div class="hidden md:flex items-start gap-4 mb-4">
|
<div class="hidden md:flex items-start gap-4 mb-4">
|
||||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
@ -758,11 +763,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto pt-4">
|
||||||
<button
|
<button
|
||||||
v-if="nodesContainerTab === 'peers'"
|
v-if="nodesContainerTab === 'peers'"
|
||||||
@click="discoverAndAddPeers"
|
@click="discoverAndAddPeers"
|
||||||
:disabled="discovering"
|
:disabled="discovering"
|
||||||
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
|
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
|
||||||
</button>
|
</button>
|
||||||
@ -770,7 +776,7 @@
|
|||||||
v-else-if="nodesContainerTab === 'messages'"
|
v-else-if="nodesContainerTab === 'messages'"
|
||||||
@click="loadReceivedMessages"
|
@click="loadReceivedMessages"
|
||||||
:disabled="loadingMessages"
|
:disabled="loadingMessages"
|
||||||
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ loadingMessages ? t('common.loading') : t('web5.refreshMessages') }}
|
{{ loadingMessages ? t('common.loading') : t('web5.refreshMessages') }}
|
||||||
</button>
|
</button>
|
||||||
@ -778,14 +784,15 @@
|
|||||||
v-else
|
v-else
|
||||||
@click="loadConnectionRequests"
|
@click="loadConnectionRequests"
|
||||||
:disabled="loadingRequests"
|
:disabled="loadingRequests"
|
||||||
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
|
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shared Content -->
|
<!-- Shared Content -->
|
||||||
<div class="glass-card p-6 mb-8">
|
<div class="glass-card p-6">
|
||||||
<!-- Desktop: side-by-side -->
|
<!-- Desktop: side-by-side -->
|
||||||
<div class="hidden md:flex items-center justify-between mb-4">
|
<div class="hidden md:flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@ -835,6 +842,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Browse Peer Selector -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
v-model="browsePeerOnion"
|
||||||
|
class="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white text-sm border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||||
|
>
|
||||||
|
<option value="">{{ t('web5.selectPeer') }}</option>
|
||||||
|
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
|
||||||
|
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
@click="browsePeerContent"
|
||||||
|
:disabled="!browsePeerOnion || browsingPeerContent"
|
||||||
|
class="glass-button glass-button-sm px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ browsingPeerContent ? t('common.loading') : t('web5.browse') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="browsePeerError" class="text-xs text-red-400 mt-2">{{ browsePeerError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tabs: My Content | Browse Peers -->
|
<!-- Tabs: My Content | Browse Peers -->
|
||||||
<div class="flex gap-1 mb-4 border-b border-white/10">
|
<div class="flex gap-1 mb-4 border-b border-white/10">
|
||||||
<button
|
<button
|
||||||
@ -941,29 +971,6 @@
|
|||||||
|
|
||||||
<!-- Browse Peers tab -->
|
<!-- Browse Peers tab -->
|
||||||
<div v-show="contentTab === 'browse'">
|
<div v-show="contentTab === 'browse'">
|
||||||
<!-- Peer Selector -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<select
|
|
||||||
v-model="browsePeerOnion"
|
|
||||||
class="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white text-sm border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
|
||||||
>
|
|
||||||
<option value="">{{ t('web5.selectPeer') }}</option>
|
|
||||||
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
|
|
||||||
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
@click="browsePeerContent"
|
|
||||||
:disabled="!browsePeerOnion || browsingPeerContent"
|
|
||||||
class="glass-button glass-button-sm px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ browsingPeerContent ? t('common.loading') : t('web5.browse') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="browsePeerError" class="text-xs text-red-400 mt-2">{{ browsePeerError }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Peer Content Loading -->
|
<!-- Peer Content Loading -->
|
||||||
<div v-if="browsingPeerContent" class="py-4 text-center">
|
<div v-if="browsingPeerContent" class="py-4 text-center">
|
||||||
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
|
||||||
@ -1038,6 +1045,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div> <!-- end Connected Nodes + Shared Content grid -->
|
||||||
|
|
||||||
<!-- Content Streaming Player -->
|
<!-- Content Streaming Player -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="streamingItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closePlayer" @keydown.escape="closePlayer">
|
<div v-if="streamingItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closePlayer" @keydown.escape="closePlayer">
|
||||||
@ -1087,8 +1096,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Player Error -->
|
<!-- Player Error -->
|
||||||
<div v-if="playerError" class="mt-3 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
<div v-if="playerError" class="mt-3 alert-error">
|
||||||
<p class="text-red-400 text-sm">{{ playerError }}</p>
|
<p>{{ playerError }}</p>
|
||||||
<p class="text-white/50 text-xs mt-1">This may be a Tor-only resource. Copy the URL to use with a Tor-enabled media player.</p>
|
<p class="text-white/50 text-xs mt-1">This may be a Tor-only resource. Copy the URL to use with a Tor-enabled media player.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1118,15 +1127,15 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-sm block mb-1">Filename</label>
|
<label class="text-white/60 text-sm block mb-1">Filename</label>
|
||||||
<input v-model="newContentFilename" type="text" placeholder="my-file.mp3" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="newContentFilename" type="text" placeholder="my-file.mp3" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-sm block mb-1">MIME Type</label>
|
<label class="text-white/60 text-sm block mb-1">MIME Type</label>
|
||||||
<input v-model="newContentMimeType" type="text" placeholder="audio/mpeg" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="newContentMimeType" type="text" placeholder="audio/mpeg" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-sm block mb-1">Description (optional)</label>
|
<label class="text-white/60 text-sm block mb-1">Description (optional)</label>
|
||||||
<input v-model="newContentDescription" type="text" placeholder="A short description" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="newContentDescription" type="text" placeholder="A short description" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-sm block mb-2">Access</label>
|
<label class="text-white/60 text-sm block mb-2">Access</label>
|
||||||
@ -1144,16 +1153,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="newContentAccess === 'paid'">
|
<div v-if="newContentAccess === 'paid'">
|
||||||
<label class="text-white/60 text-sm block mb-1">Price (sats)</label>
|
<label class="text-white/60 text-sm block mb-1">Price (sats)</label>
|
||||||
<input v-model.number="newContentPrice" type="number" min="1" placeholder="100" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model.number="newContentPrice" type="number" min="1" placeholder="100" class="w-full input-glass" />
|
||||||
<p v-if="newContentPrice > 0" class="text-xs text-orange-400/80 mt-1">Peers will pay {{ newContentPrice }} sats to access this</p>
|
<p v-if="newContentPrice > 0" class="text-xs text-orange-400/80 mt-1">Peers will pay {{ newContentPrice }} sats to access this</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="addContentError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
|
<div v-if="addContentError" class="mt-3 alert-error">
|
||||||
<p class="text-red-300 text-xs">{{ addContentError }}</p>
|
<p class="text-xs">{{ addContentError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 mt-6">
|
<div class="flex gap-3 mt-6">
|
||||||
<button @click="showAddContentModal = false; addContentError = ''" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
|
<button @click="showAddContentModal = false; addContentError = ''" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
|
||||||
<button @click="addContentItem" :disabled="addingContent || !newContentFilename.trim()" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
|
<button @click="addContentItem" :disabled="addingContent || !newContentFilename.trim()" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
{{ addingContent ? 'Adding...' : 'Add' }}
|
{{ addingContent ? 'Adding...' : 'Add' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1161,8 +1170,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Identities + DWN grid -->
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
|
||||||
|
|
||||||
<!-- Identity Management -->
|
<!-- Identity Management -->
|
||||||
<div class="glass-card p-6 mb-8">
|
<div class="glass-card p-6">
|
||||||
<!-- Desktop: side-by-side -->
|
<!-- Desktop: side-by-side -->
|
||||||
<div class="hidden md:flex items-center justify-between mb-4">
|
<div class="hidden md:flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@ -1302,7 +1314,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-sm block mb-1">Name</label>
|
<label class="text-white/60 text-sm block mb-1">Name</label>
|
||||||
<input v-model="newIdentityName" type="text" placeholder="Personal" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="newIdentityName" type="text" placeholder="Personal" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-sm block mb-1">Purpose</label>
|
<label class="text-white/60 text-sm block mb-1">Purpose</label>
|
||||||
@ -1317,8 +1329,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="createIdentityError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
|
<div v-if="createIdentityError" class="mt-3 alert-error">
|
||||||
<p class="text-red-300 text-xs">{{ createIdentityError }}</p>
|
<p class="text-xs">{{ createIdentityError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 mt-6">
|
<div class="flex gap-3 mt-6">
|
||||||
<button @click="showCreateIdentityModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
|
<button @click="showCreateIdentityModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
|
||||||
@ -1338,7 +1350,7 @@
|
|||||||
<p class="text-white/60 text-sm mb-4">{{ t('web5.deleteIdentityConfirm') }}</p>
|
<p class="text-white/60 text-sm mb-4">{{ t('web5.deleteIdentityConfirm') }}</p>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button @click="deleteIdentityTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
|
<button @click="deleteIdentityTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
|
||||||
<button @click="deleteIdentity" :disabled="deletingIdentity" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/20 border-red-500/30">
|
<button @click="deleteIdentity" :disabled="deletingIdentity" class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium">
|
||||||
{{ deletingIdentity ? t('web5.deleting') : t('common.delete') }}
|
{{ deletingIdentity ? t('web5.deleting') : t('common.delete') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1430,7 +1442,7 @@
|
|||||||
v-model="keyViewerPassword"
|
v-model="keyViewerPassword"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
|
class="flex-1 input-glass"
|
||||||
@keydown.enter="unlockPrivateKeys"
|
@keydown.enter="unlockPrivateKeys"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -1507,41 +1519,41 @@
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-xs block mb-1">Display Name</label>
|
<label class="text-white/60 text-xs block mb-1">Display Name</label>
|
||||||
<input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-xs block mb-1">About / Bio</label>
|
<label class="text-white/60 text-xs block mb-1">About / Bio</label>
|
||||||
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30 resize-none"></textarea>
|
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full input-glass resize-none"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-xs block mb-1">Profile Picture URL</label>
|
<label class="text-white/60 text-xs block mb-1">Profile Picture URL</label>
|
||||||
<input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-xs block mb-1">Banner Image URL</label>
|
<label class="text-white/60 text-xs block mb-1">Banner Image URL</label>
|
||||||
<input v-model="profileForm.banner" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="profileForm.banner" type="url" placeholder="https://..." class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-xs block mb-1">Website</label>
|
<label class="text-white/60 text-xs block mb-1">Website</label>
|
||||||
<input v-model="profileForm.website" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="profileForm.website" type="url" placeholder="https://..." class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-xs block mb-1">NIP-05 (Nostr address)</label>
|
<label class="text-white/60 text-xs block mb-1">NIP-05 (Nostr address)</label>
|
||||||
<input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-xs block mb-1">Lightning Address (LUD-16)</label>
|
<label class="text-white/60 text-xs block mb-1">Lightning Address (LUD-16)</label>
|
||||||
<input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="profileError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
|
<div v-if="profileError" class="mt-3 alert-error">
|
||||||
<p class="text-red-300 text-xs">{{ profileError }}</p>
|
<p class="text-xs">{{ profileError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="profileSuccess" class="mt-3 p-2 bg-green-500/20 border border-green-500/30 rounded-lg">
|
<div v-if="profileSuccess" class="mt-3 alert-success">
|
||||||
<p class="text-green-300 text-xs">{{ profileSuccess }}</p>
|
<p class="text-xs">{{ profileSuccess }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 mt-5">
|
<div class="flex gap-3 mt-5">
|
||||||
@ -1549,7 +1561,7 @@
|
|||||||
<button @click="saveProfile" :disabled="profileSaving" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
<button @click="saveProfile" :disabled="profileSaving" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
||||||
{{ profileSaving ? 'Saving...' : 'Save' }}
|
{{ profileSaving ? 'Saving...' : 'Save' }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30">
|
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">
|
||||||
{{ profilePublishing ? 'Publishing...' : 'Save & Publish' }}
|
{{ profilePublishing ? 'Publishing...' : 'Save & Publish' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1582,7 +1594,7 @@
|
|||||||
<!-- Amount -->
|
<!-- Amount -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
|
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
|
||||||
<input v-model.number="unifiedSendAmount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model.number="unifiedSendAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Destination (varies by method) -->
|
<!-- Destination (varies by method) -->
|
||||||
@ -1590,7 +1602,7 @@
|
|||||||
<label class="text-white/60 text-sm block mb-1">
|
<label class="text-white/60 text-sm block mb-1">
|
||||||
{{ effectiveSendMethod === 'lightning' ? 'Lightning Invoice (BOLT11)' : 'Bitcoin Address' }}
|
{{ effectiveSendMethod === 'lightning' ? 'Lightning Invoice (BOLT11)' : 'Bitcoin Address' }}
|
||||||
</label>
|
</label>
|
||||||
<textarea v-model="unifiedSendDest" rows="2" :placeholder="effectiveSendMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-white/30"></textarea>
|
<textarea v-model="unifiedSendDest" rows="2" :placeholder="effectiveSendMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ecash token output -->
|
<!-- Ecash token output -->
|
||||||
@ -1635,7 +1647,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mesh Relay Prompt — shown when offline -->
|
<!-- Mesh Relay Prompt — shown when offline -->
|
||||||
<div v-if="showMeshRelayPrompt" class="mb-3 p-4 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
<div v-if="showMeshRelayPrompt" class="mb-3 alert-warning">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<span class="text-lg">📡</span>
|
<span class="text-lg">📡</span>
|
||||||
<p class="text-orange-300 text-sm font-medium">You are offline</p>
|
<p class="text-orange-300 text-sm font-medium">You are offline</p>
|
||||||
@ -1643,12 +1655,12 @@
|
|||||||
<p class="text-white/70 text-xs mb-3">Send this transaction via mesh radio? It will be relayed by the nearest internet-connected node and you'll receive confirmation updates.</p>
|
<p class="text-white/70 text-xs mb-3">Send this transaction via mesh radio? It will be relayed by the nearest internet-connected node and you'll receive confirmation updates.</p>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button @click="dismissMeshRelayPrompt" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs">Cancel</button>
|
<button @click="dismissMeshRelayPrompt" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs">Cancel</button>
|
||||||
<button @click="handleMeshRelaySend" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium bg-orange-500/20 border-orange-500/30">Send via Mesh</button>
|
<button @click="handleMeshRelaySend" class="flex-1 glass-button glass-button-warning px-3 py-2 rounded-lg text-xs font-medium">Send via Mesh</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mesh Relay Status -->
|
<!-- Mesh Relay Status -->
|
||||||
<div v-if="meshRelayActive" class="mb-3 p-3 bg-orange-500/10 border border-orange-500/20 rounded-lg">
|
<div v-if="meshRelayActive" class="mb-3 alert-warning">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<svg class="animate-spin h-3 w-3 text-orange-400" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin h-3 w-3 text-orange-400" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
@ -1660,23 +1672,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- On-chain txid result -->
|
<!-- On-chain txid result -->
|
||||||
<div v-if="sendResultTxid" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
|
<div v-if="sendResultTxid" class="mb-3 alert-success">
|
||||||
<p class="text-green-400 text-xs">Sent! TX: {{ sendResultTxid }}</p>
|
<p class="text-xs">Sent! TX: {{ sendResultTxid }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lightning payment result -->
|
<!-- Lightning payment result -->
|
||||||
<div v-if="sendResultHash" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
|
<div v-if="sendResultHash" class="mb-3 alert-success">
|
||||||
<p class="text-green-400 text-xs">Paid! Hash: {{ sendResultHash }}</p>
|
<p class="text-xs">Paid! Hash: {{ sendResultHash }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="unifiedSendError" class="mb-3 text-xs text-red-400">{{ unifiedSendError }}</div>
|
<div v-if="unifiedSendError" class="mb-3 text-xs text-red-400">{{ unifiedSendError }}</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button @click="closeUnifiedSendModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
<button @click="closeUnifiedSendModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||||
<button v-if="psbtStep === 'created'" @click="finalizePsbt" :disabled="unifiedSendProcessing || !signedPsbtInput.trim()" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
|
<button v-if="psbtStep === 'created'" @click="finalizePsbt" :disabled="unifiedSendProcessing || !signedPsbtInput.trim()" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
{{ unifiedSendProcessing ? 'Broadcasting...' : 'Broadcast' }}
|
{{ unifiedSendProcessing ? 'Broadcasting...' : 'Broadcast' }}
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="unifiedSend" :disabled="unifiedSendProcessing || !unifiedSendAmount" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
|
<button v-else @click="unifiedSend" :disabled="unifiedSendProcessing || !unifiedSendAmount" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
{{ unifiedSendProcessing ? 'Sending...' : (useHardwareWallet && effectiveSendMethod === 'onchain' ? 'Create PSBT' : 'Send') }}
|
{{ unifiedSendProcessing ? 'Sending...' : (useHardwareWallet && effectiveSendMethod === 'onchain' ? 'Create PSBT' : 'Send') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1705,11 +1717,11 @@
|
|||||||
<div v-if="receiveMethod === 'lightning'">
|
<div v-if="receiveMethod === 'lightning'">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
|
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
|
||||||
<input v-model.number="receiveInvoiceAmount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model.number="receiveInvoiceAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">Memo (optional)</label>
|
<label class="text-white/60 text-sm block mb-1">Memo (optional)</label>
|
||||||
<input v-model="receiveInvoiceMemo" type="text" placeholder="Payment for..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="receiveInvoiceMemo" type="text" placeholder="Payment for..." class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="receiveInvoiceResult" class="mb-3 p-2 bg-white/5 rounded-lg">
|
<div v-if="receiveInvoiceResult" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||||
<p class="text-white/50 text-xs mb-1">Invoice (share with sender):</p>
|
<p class="text-white/50 text-xs mb-1">Invoice (share with sender):</p>
|
||||||
@ -1735,7 +1747,7 @@
|
|||||||
<div v-if="receiveMethod === 'ecash'">
|
<div v-if="receiveMethod === 'ecash'">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">Paste ecash token</label>
|
<label class="text-white/60 text-sm block mb-1">Paste ecash token</label>
|
||||||
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuSend_..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"></textarea>
|
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
|
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1744,7 +1756,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button @click="closeUnifiedReceiveModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
<button @click="closeUnifiedReceiveModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||||
<button @click="unifiedReceive" :disabled="unifiedReceiveProcessing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-green-500/20 border-green-500/30 disabled:opacity-50">
|
<button @click="unifiedReceive" :disabled="unifiedReceiveProcessing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
{{ unifiedReceiveProcessing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }}
|
{{ unifiedReceiveProcessing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1753,7 +1765,7 @@
|
|||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<!-- Decentralized Web Node (DWN) -->
|
<!-- Decentralized Web Node (DWN) -->
|
||||||
<div class="glass-card p-6 mb-8">
|
<div class="glass-card p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
@ -1907,6 +1919,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div> <!-- end Identities + DWN grid -->
|
||||||
|
|
||||||
<!-- Verifiable Credentials -->
|
<!-- Verifiable Credentials -->
|
||||||
<div class="glass-card p-6 mb-8">
|
<div class="glass-card p-6 mb-8">
|
||||||
<!-- Desktop: side-by-side -->
|
<!-- Desktop: side-by-side -->
|
||||||
@ -2018,16 +2032,16 @@
|
|||||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-xs block mb-1">Username</label>
|
<label class="text-white/60 text-xs block mb-1">Username</label>
|
||||||
<input v-model="newDomainName" type="text" placeholder="satoshi" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="newDomainName" type="text" placeholder="satoshi" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-xs block mb-1">Domain</label>
|
<label class="text-white/60 text-xs block mb-1">Domain</label>
|
||||||
<input v-model="newDomainDomain" type="text" placeholder="example.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="newDomainDomain" type="text" placeholder="example.com" class="w-full input-glass" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="text-white/60 text-xs block mb-1">Link to Identity</label>
|
<label class="text-white/60 text-xs block mb-1">Link to Identity</label>
|
||||||
<select v-model="newDomainIdentityId" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30">
|
<select v-model="newDomainIdentityId" class="w-full input-glass">
|
||||||
<option value="" disabled>Select identity...</option>
|
<option value="" disabled>Select identity...</option>
|
||||||
<option v-for="id in managedIdentities" :key="id.id" :value="id.id">{{ id.name }} ({{ (id.did || '').slice(0, 24) }}...)</option>
|
<option v-for="id in managedIdentities" :key="id.id" :value="id.id">{{ id.name }} ({{ (id.did || '').slice(0, 24) }}...)</option>
|
||||||
</select>
|
</select>
|
||||||
@ -2042,7 +2056,7 @@
|
|||||||
<div class="border-t border-white/10 pt-4 mt-4">
|
<div class="border-t border-white/10 pt-4 mt-4">
|
||||||
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.verifyNip05') }}</h3>
|
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.verifyNip05') }}</h3>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input v-model="verifyNip05Input" type="text" placeholder="user@domain.com" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
<input v-model="verifyNip05Input" type="text" placeholder="user@domain.com" class="flex-1 input-glass" />
|
||||||
<button @click="verifyNip05" :disabled="nip05Verifying || !verifyNip05Input.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
<button @click="verifyNip05" :disabled="nip05Verifying || !verifyNip05Input.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
{{ nip05Verifying ? '...' : 'Verify' }}
|
{{ nip05Verifying ? '...' : 'Verify' }}
|
||||||
</button>
|
</button>
|
||||||
@ -2093,7 +2107,7 @@
|
|||||||
<div class="border-t border-white/10 pt-4">
|
<div class="border-t border-white/10 pt-4">
|
||||||
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.addRelay') }}</h3>
|
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.addRelay') }}</h3>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input v-model="newRelayUrl" type="text" :placeholder="t('web5.relayUrlPlaceholder')" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" @keyup.enter="addNostrRelay" />
|
<input v-model="newRelayUrl" type="text" :placeholder="t('web5.relayUrlPlaceholder')" class="flex-1 input-glass" @keyup.enter="addNostrRelay" />
|
||||||
<button @click="addNostrRelay" :disabled="!newRelayUrl.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
<button @click="addNostrRelay" :disabled="!newRelayUrl.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
@ -2541,6 +2555,7 @@ const walletConnected = ref(false)
|
|||||||
const connectingWallet = ref(false)
|
const connectingWallet = ref(false)
|
||||||
const lndOnchainBalance = ref(0)
|
const lndOnchainBalance = ref(0)
|
||||||
const lndChannelBalance = ref(0)
|
const lndChannelBalance = ref(0)
|
||||||
|
const walletError = ref('')
|
||||||
|
|
||||||
// Incoming Transactions
|
// Incoming Transactions
|
||||||
interface WalletTransaction {
|
interface WalletTransaction {
|
||||||
@ -2567,13 +2582,15 @@ async function loadTransactions() {
|
|||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
|
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
|
||||||
walletTransactions.value = res.transactions || []
|
walletTransactions.value = res.transactions || []
|
||||||
|
walletError.value = ''
|
||||||
// Auto-show panel when new unconfirmed incoming txs appear
|
// Auto-show panel when new unconfirmed incoming txs appear
|
||||||
const pending = res.incoming_pending_count || 0
|
const pending = res.incoming_pending_count || 0
|
||||||
if (pending > 0 && !showIncomingTxPanel.value) {
|
if (pending > 0 && !showIncomingTxPanel.value) {
|
||||||
showIncomingTxPanel.value = true
|
showIncomingTxPanel.value = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
walletTransactions.value = []
|
walletTransactions.value = []
|
||||||
|
walletError.value = e instanceof Error ? e.message : 'Failed to load transactions'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3876,10 +3893,12 @@ async function loadLndBalances() {
|
|||||||
lndOnchainBalance.value = res.balance_sats || 0
|
lndOnchainBalance.value = res.balance_sats || 0
|
||||||
lndChannelBalance.value = res.channel_balance_sats || 0
|
lndChannelBalance.value = res.channel_balance_sats || 0
|
||||||
walletConnected.value = true
|
walletConnected.value = true
|
||||||
} catch {
|
walletError.value = ''
|
||||||
|
} catch (e) {
|
||||||
walletConnected.value = false
|
walletConnected.value = false
|
||||||
lndOnchainBalance.value = 0
|
lndOnchainBalance.value = 0
|
||||||
lndChannelBalance.value = 0
|
lndChannelBalance.value = 0
|
||||||
|
walletError.value = e instanceof Error ? e.message : 'Failed to load wallet balances'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -130,7 +130,7 @@
|
|||||||
v-model="openForm.peerUri"
|
v-model="openForm.peerUri"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="pubkey@host:port"
|
placeholder="pubkey@host:port"
|
||||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
|
class="w-full input-glass"
|
||||||
/>
|
/>
|
||||||
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
|
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
|
||||||
</div>
|
</div>
|
||||||
@ -141,14 +141,14 @@
|
|||||||
type="number"
|
type="number"
|
||||||
min="20000"
|
min="20000"
|
||||||
placeholder="100000"
|
placeholder="100000"
|
||||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
|
class="w-full input-glass"
|
||||||
/>
|
/>
|
||||||
<p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p>
|
<p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="openError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
|
<div v-if="openError" class="mt-3 alert-error">
|
||||||
<p class="text-red-300 text-xs">{{ openError }}</p>
|
<p class="text-xs">{{ openError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 mt-6">
|
<div class="flex gap-3 mt-6">
|
||||||
@ -169,15 +169,15 @@
|
|||||||
<div class="glass-card p-6 w-full max-w-sm mx-4">
|
<div class="glass-card p-6 w-full max-w-sm mx-4">
|
||||||
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
|
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
|
||||||
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
|
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
|
||||||
<div v-if="closeError" class="mb-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
|
<div v-if="closeError" class="mb-3 alert-error">
|
||||||
<p class="text-red-300 text-xs">{{ closeError }}</p>
|
<p class="text-xs">{{ closeError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||||
<button
|
<button
|
||||||
@click="closeChannel"
|
@click="closeChannel"
|
||||||
:disabled="closingChannel"
|
:disabled="closingChannel"
|
||||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/20 border-red-500/30"
|
class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
{{ closingChannel ? 'Closing...' : 'Close' }}
|
{{ closingChannel ? 'Closing...' : 'Close' }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1192,17 +1192,21 @@ lines = ["SocksPort 9050", "ControlPort 0", ""]
|
|||||||
try:
|
try:
|
||||||
with open("/var/lib/archipelago/tor/services.json") as f:
|
with open("/var/lib/archipelago/tor/services.json") as f:
|
||||||
cfg = json.load(f)
|
cfg = json.load(f)
|
||||||
|
extra_ports = {"lnd": [8080]} # LND REST API over Tor
|
||||||
for svc in cfg.get("services", []):
|
for svc in cfg.get("services", []):
|
||||||
if svc.get("enabled", True):
|
if svc.get("enabled", True):
|
||||||
n = svc["name"]
|
n = svc["name"]
|
||||||
p = svc["local_port"]
|
p = svc["local_port"]
|
||||||
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
|
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
|
||||||
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
|
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
|
||||||
|
for ep in extra_ports.get(n, []):
|
||||||
|
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (ep, ep))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
except Exception:
|
except Exception:
|
||||||
for n, p in [("archipelago",80),("bitcoin",8333),("electrumx",50001),("lnd",9735),("btcpay",23000),("mempool",4080),("fedimint",8175)]:
|
for n, ports in [("archipelago",[80]),("bitcoin",[8333]),("electrumx",[50001]),("lnd",[9735,8080]),("btcpay",[23000]),("mempool",[4080]),("fedimint",[8175])]:
|
||||||
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
|
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
|
||||||
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
|
for p in ports:
|
||||||
|
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
with open("/etc/tor/torrc", "w") as f:
|
with open("/etc/tor/torrc", "w") as f:
|
||||||
f.write("\n".join(lines) + "\n")
|
f.write("\n".join(lines) + "\n")
|
||||||
|
|||||||
@ -156,24 +156,38 @@ case $choice in
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$RUNTIME" ]; then
|
if [ -z "$RUNTIME" ]; then
|
||||||
echo ""
|
|
||||||
echo "No working container runtime detected."
|
|
||||||
echo ""
|
|
||||||
if command -v podman &>/dev/null; then
|
if command -v podman &>/dev/null; then
|
||||||
echo "Podman is installed but the machine isn't running:"
|
echo " Podman machine not running — starting it..."
|
||||||
echo " podman machine start"
|
if ! podman machine ls --format '{{.Name}}' 2>/dev/null | grep -q .; then
|
||||||
|
echo " No Podman machine found — initializing..."
|
||||||
|
podman machine init
|
||||||
|
fi
|
||||||
|
podman machine start
|
||||||
|
if podman ps &>/dev/null; then
|
||||||
|
if command -v podman-compose &>/dev/null; then
|
||||||
|
RUNTIME="podman"
|
||||||
|
COMPOSE="podman-compose"
|
||||||
|
else
|
||||||
|
RUNTIME="podman"
|
||||||
|
COMPOSE="podman compose"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Failed to start Podman machine."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
elif command -v docker &>/dev/null; then
|
elif command -v docker &>/dev/null; then
|
||||||
|
echo ""
|
||||||
echo "Docker is installed but the daemon isn't running."
|
echo "Docker is installed but the daemon isn't running."
|
||||||
echo "Start Docker Desktop and try again."
|
echo "Start Docker Desktop and try again."
|
||||||
|
exit 1
|
||||||
else
|
else
|
||||||
echo "Install Docker Desktop or Podman:"
|
echo ""
|
||||||
echo " brew install --cask docker"
|
echo "No container runtime found. Install one:"
|
||||||
echo " # or"
|
|
||||||
echo " brew install podman podman-compose"
|
echo " brew install podman podman-compose"
|
||||||
echo " podman machine init && podman machine start"
|
echo " # or"
|
||||||
|
echo " brew install --cask docker"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo ""
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " Using: $RUNTIME"
|
echo " Using: $RUNTIME"
|
||||||
|
|||||||
@ -109,9 +109,97 @@ else
|
|||||||
log "Swap already configured"
|
log "Swap already configured"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Rootless podman prerequisites (run as root, configures for archipelago user)
|
||||||
|
log "Setting up rootless podman prerequisites..."
|
||||||
|
# Allow binding to ports >= 80 (rootless default is 1024)
|
||||||
|
if ! grep -q "unprivileged_port_start=80" /etc/sysctl.d/99-rootless-podman.conf 2>/dev/null; then
|
||||||
|
echo "net.ipv4.ip_unprivileged_port_start=80" > /etc/sysctl.d/99-rootless-podman.conf
|
||||||
|
sysctl -p /etc/sysctl.d/99-rootless-podman.conf 2>/dev/null
|
||||||
|
log " Rootless port binding enabled (>=80)"
|
||||||
|
fi
|
||||||
|
# Linger for container persistence after logout
|
||||||
|
if [ "$(loginctl show-user archipelago 2>/dev/null | grep Linger)" != "Linger=yes" ]; then
|
||||||
|
loginctl enable-linger archipelago 2>/dev/null
|
||||||
|
log " Linger enabled for archipelago user"
|
||||||
|
fi
|
||||||
|
# Ensure subuid/subgid mappings
|
||||||
|
grep -q "^archipelago:" /etc/subuid 2>/dev/null || {
|
||||||
|
echo "archipelago:100000:65536" >> /etc/subuid
|
||||||
|
echo "archipelago:100000:65536" >> /etc/subgid
|
||||||
|
log " subuid/subgid configured"
|
||||||
|
}
|
||||||
|
# Ensure /etc/hosts is readable (rootless podman needs it)
|
||||||
|
chmod 644 /etc/hosts 2>/dev/null
|
||||||
|
|
||||||
# Ensure network exists (matches deploy)
|
# Ensure network exists (matches deploy)
|
||||||
$DOCKER network create archy-net 2>/dev/null || true
|
$DOCKER network create archy-net 2>/dev/null || true
|
||||||
|
|
||||||
|
# Rootless podman UID mapping: fix data dir ownership so container processes
|
||||||
|
# can write. Rootless podman maps container UIDs via subuid (container UID N
|
||||||
|
# → host UID 100000+N). Must run BEFORE container creation.
|
||||||
|
log "Fixing rootless podman UID mapping..."
|
||||||
|
# Containers running as root (UID 0 → host UID 100000)
|
||||||
|
for dir in lnd electrumx btcpay nbxplorer immich jellyfin vaultwarden \
|
||||||
|
home-assistant fedimint fedimint-gateway photoprism ollama filebrowser \
|
||||||
|
nextcloud uptime-kuma onlyoffice nginx-proxy-manager portainer nostr-rs-relay; do
|
||||||
|
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100000:100000 "/var/lib/archipelago/$dir" 2>/dev/null
|
||||||
|
done
|
||||||
|
# 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
|
||||||
|
for dir in postgres-btcpay immich-db penpot-postgres; do
|
||||||
|
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100070:100070 "/var/lib/archipelago/$dir" 2>/dev/null
|
||||||
|
done
|
||||||
|
# MariaDB: container UID 999 → host UID 100999
|
||||||
|
for dir in mempool mysql-mempool; do
|
||||||
|
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100999:100999 "/var/lib/archipelago/$dir" 2>/dev/null
|
||||||
|
done
|
||||||
|
# Grafana: container UID 472 → host UID 100472
|
||||||
|
[ -d /var/lib/archipelago/grafana ] && chown -R 100472:100472 /var/lib/archipelago/grafana 2>/dev/null
|
||||||
|
log "UID mapping done"
|
||||||
|
|
||||||
|
# ── Memory limits per container ──────────────────────────────────────────
|
||||||
|
# Matches core/archipelago/src/api/rpc/package.rs get_memory_limit()
|
||||||
|
# Prevents a single runaway container from OOMing the whole system.
|
||||||
|
TOTAL_MEM_MB=$(($(awk '/MemTotal/{print $2}' /proc/meminfo) / 1024))
|
||||||
|
LOW_MEM=false
|
||||||
|
[ "$TOTAL_MEM_MB" -lt 12000 ] && LOW_MEM=true && log "Low-memory system (${TOTAL_MEM_MB}MB) — reducing limits"
|
||||||
|
|
||||||
|
mem_limit() {
|
||||||
|
case "$1" in
|
||||||
|
bitcoin-knots) $LOW_MEM && echo "1g" || echo "2g";;
|
||||||
|
onlyoffice) $LOW_MEM && echo "1g" || echo "2g";;
|
||||||
|
ollama) $LOW_MEM && echo "1g" || echo "4g";;
|
||||||
|
lnd) echo "512m";;
|
||||||
|
electrumx) echo "1g";;
|
||||||
|
nextcloud) echo "1g";;
|
||||||
|
immich_server) echo "1g";;
|
||||||
|
btcpay-server) echo "1g";;
|
||||||
|
homeassistant) echo "512m";;
|
||||||
|
fedimint) echo "512m";;
|
||||||
|
fedimint-gateway) echo "512m";;
|
||||||
|
photoprism) $LOW_MEM && echo "512m" || echo "1g";;
|
||||||
|
mempool-api) echo "512m";;
|
||||||
|
jellyfin) echo "1g";;
|
||||||
|
searxng) echo "512m";;
|
||||||
|
archy-btcpay-db) echo "512m";;
|
||||||
|
archy-nbxplorer) echo "512m";;
|
||||||
|
archy-mempool-db) echo "512m";;
|
||||||
|
archy-mempool-web) echo "256m";;
|
||||||
|
grafana) echo "256m";;
|
||||||
|
vaultwarden) echo "256m";;
|
||||||
|
uptime-kuma) echo "256m";;
|
||||||
|
filebrowser) echo "256m";;
|
||||||
|
portainer) echo "256m";;
|
||||||
|
nginx-proxy-manager) echo "256m";;
|
||||||
|
immich_postgres) echo "256m";;
|
||||||
|
immich_redis) echo "128m";;
|
||||||
|
tailscale) echo "256m";;
|
||||||
|
indeedhub|archy-bitcoin-ui|archy-lnd-ui|archy-electrs-ui) echo "128m";;
|
||||||
|
*) echo "512m";;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
# ── Tier 1: Databases & Core Infrastructure ──────────────────────────────
|
# ── Tier 1: Databases & Core Infrastructure ──────────────────────────────
|
||||||
log "=== Tier 1: Databases & Core Infrastructure ==="
|
log "=== Tier 1: Databases & Core Infrastructure ==="
|
||||||
|
|
||||||
@ -130,14 +218,14 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|arch
|
|||||||
BTC_DBCACHE=4096
|
BTC_DBCACHE=4096
|
||||||
log " Large disk (${DISK_GB}GB) — enabling txindex"
|
log " Large disk (${DISK_GB}GB) — enabling txindex"
|
||||||
fi
|
fi
|
||||||
if $DOCKER run -d --name bitcoin-knots --restart unless-stopped --network archy-net \
|
if $DOCKER run -d --name bitcoin-knots --restart unless-stopped --memory=$(mem_limit bitcoin-knots) --network archy-net \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 8332:8332 -p 8333:8333 \
|
-p 8332:8332 -p 8333:8333 \
|
||||||
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
|
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
|
||||||
docker.io/bitcoinknots/bitcoin:latest \
|
docker.io/bitcoinknots/bitcoin:latest \
|
||||||
-server=1 $BTC_EXTRA_ARGS \
|
-server=1 $BTC_EXTRA_ARGS \
|
||||||
-rpcallowip=127.0.0.1/32 -rpcallowip=10.88.0.0/16 -rpcbind=0.0.0.0:8332 \
|
-rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 \
|
||||||
-rpcuser=$BITCOIN_RPC_USER -rpcpassword=$BITCOIN_RPC_PASS \
|
-rpcuser=$BITCOIN_RPC_USER -rpcpassword=$BITCOIN_RPC_PASS \
|
||||||
-proxy=127.0.0.1:9050 -listen=1 -bind=0.0.0.0:8333 \
|
-proxy=127.0.0.1:9050 -listen=1 -bind=0.0.0.0:8333 \
|
||||||
-dbcache=$BTC_DBCACHE 2>>"$LOG"; then
|
-dbcache=$BTC_DBCACHE 2>>"$LOG"; then
|
||||||
@ -163,7 +251,7 @@ fi
|
|||||||
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-mempool-db|mysql-mempool'; then
|
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-mempool-db|mysql-mempool'; then
|
||||||
log "Creating mysql-mempool..."
|
log "Creating mysql-mempool..."
|
||||||
mkdir -p /var/lib/archipelago/mysql-mempool
|
mkdir -p /var/lib/archipelago/mysql-mempool
|
||||||
$DOCKER run -d --name archy-mempool-db --restart unless-stopped --network archy-net \
|
$DOCKER run -d --name archy-mempool-db --restart unless-stopped --memory=$(mem_limit archy-mempool-db) --network archy-net \
|
||||||
-v /var/lib/archipelago/mysql-mempool:/var/lib/mysql \
|
-v /var/lib/archipelago/mysql-mempool:/var/lib/mysql \
|
||||||
-e MYSQL_DATABASE=mempool -e MYSQL_USER=mempool -e MYSQL_PASSWORD=$MEMPOOL_DB_PASS \
|
-e MYSQL_DATABASE=mempool -e MYSQL_USER=mempool -e MYSQL_PASSWORD=$MEMPOOL_DB_PASS \
|
||||||
-e MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS \
|
-e MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS \
|
||||||
@ -180,7 +268,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then
|
|||||||
else
|
else
|
||||||
log "Creating electrumx..."
|
log "Creating electrumx..."
|
||||||
mkdir -p /var/lib/archipelago/electrumx
|
mkdir -p /var/lib/archipelago/electrumx
|
||||||
$DOCKER run -d --name electrumx --restart unless-stopped --network archy-net \
|
$DOCKER run -d --name electrumx --restart unless-stopped --memory=$(mem_limit electrumx) --network archy-net \
|
||||||
-p 50001:50001 -v /var/lib/archipelago/electrumx:/data \
|
-p 50001:50001 -v /var/lib/archipelago/electrumx:/data \
|
||||||
-e DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ \
|
-e DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ \
|
||||||
-e COIN=Bitcoin -e DB_DIRECTORY=/data \
|
-e COIN=Bitcoin -e DB_DIRECTORY=/data \
|
||||||
@ -192,7 +280,7 @@ fi
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-api; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-api; then
|
||||||
log "Creating mempool-api..."
|
log "Creating mempool-api..."
|
||||||
mkdir -p /var/lib/archipelago/mempool
|
mkdir -p /var/lib/archipelago/mempool
|
||||||
$DOCKER run -d --name mempool-api --restart unless-stopped --network archy-net \
|
$DOCKER run -d --name mempool-api --restart unless-stopped --memory=$(mem_limit mempool-api) --network archy-net \
|
||||||
-p 8999:8999 -v /var/lib/archipelago/mempool:/data \
|
-p 8999:8999 -v /var/lib/archipelago/mempool:/data \
|
||||||
-e MEMPOOL_BACKEND=electrum -e ELECTRUM_HOST=electrumx -e ELECTRUM_PORT=50001 \
|
-e MEMPOOL_BACKEND=electrum -e ELECTRUM_HOST=electrumx -e ELECTRUM_PORT=50001 \
|
||||||
-e ELECTRUM_TLS_ENABLED=false -e CORE_RPC_HOST="$TARGET_IP" -e CORE_RPC_PORT=8332 \
|
-e ELECTRUM_TLS_ENABLED=false -e CORE_RPC_HOST="$TARGET_IP" -e CORE_RPC_PORT=8332 \
|
||||||
@ -204,7 +292,7 @@ fi
|
|||||||
|
|
||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-mempool-web|mempool-web'; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-mempool-web|mempool-web'; then
|
||||||
log "Creating mempool frontend..."
|
log "Creating mempool frontend..."
|
||||||
$DOCKER run -d --name archy-mempool-web --restart unless-stopped --network archy-net \
|
$DOCKER run -d --name archy-mempool-web --restart unless-stopped --memory=$(mem_limit archy-mempool-web) --network archy-net \
|
||||||
-p 4080:8080 -e FRONTEND_HTTP_PORT=8080 -e BACKEND_MAINNET_HTTP_HOST=mempool-api \
|
-p 4080:8080 -e FRONTEND_HTTP_PORT=8080 -e BACKEND_MAINNET_HTTP_HOST=mempool-api \
|
||||||
docker.io/mempool/frontend:v2.5.0 2>>"$LOG" || true
|
docker.io/mempool/frontend:v2.5.0 2>>"$LOG" || true
|
||||||
fi
|
fi
|
||||||
@ -231,7 +319,7 @@ fi
|
|||||||
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-btcpay-db|postgres-btcpay'; then
|
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-btcpay-db|postgres-btcpay'; then
|
||||||
log "Creating PostgreSQL for BTCPay..."
|
log "Creating PostgreSQL for BTCPay..."
|
||||||
mkdir -p /var/lib/archipelago/postgres-btcpay
|
mkdir -p /var/lib/archipelago/postgres-btcpay
|
||||||
$DOCKER run -d --name archy-btcpay-db --restart unless-stopped --network archy-net \
|
$DOCKER run -d --name archy-btcpay-db --restart unless-stopped --memory=$(mem_limit archy-btcpay-db) --network archy-net \
|
||||||
-v /var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data \
|
-v /var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data \
|
||||||
-e POSTGRES_DB=btcpay -e POSTGRES_USER=btcpay -e POSTGRES_PASSWORD=$BTCPAY_DB_PASS \
|
-e POSTGRES_DB=btcpay -e POSTGRES_USER=btcpay -e POSTGRES_PASSWORD=$BTCPAY_DB_PASS \
|
||||||
docker.io/postgres:15-alpine 2>>"$LOG" || true
|
docker.io/postgres:15-alpine 2>>"$LOG" || true
|
||||||
@ -249,7 +337,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-nbxplorer; the
|
|||||||
else
|
else
|
||||||
log "Creating NBXplorer..."
|
log "Creating NBXplorer..."
|
||||||
mkdir -p /var/lib/archipelago/nbxplorer
|
mkdir -p /var/lib/archipelago/nbxplorer
|
||||||
$DOCKER run -d --name archy-nbxplorer --restart unless-stopped --network archy-net \
|
$DOCKER run -d --name archy-nbxplorer --restart unless-stopped --memory=$(mem_limit archy-nbxplorer) --network archy-net \
|
||||||
-p 32838:32838 -v /var/lib/archipelago/nbxplorer:/data \
|
-p 32838:32838 -v /var/lib/archipelago/nbxplorer:/data \
|
||||||
-e NBXPLORER_DATADIR=/data -e NBXPLORER_NETWORK=mainnet -e NBXPLORER_CHAINS=btc \
|
-e NBXPLORER_DATADIR=/data -e NBXPLORER_NETWORK=mainnet -e NBXPLORER_CHAINS=btc \
|
||||||
-e NBXPLORER_BIND=0.0.0.0:32838 -e NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 \
|
-e NBXPLORER_BIND=0.0.0.0:32838 -e NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 \
|
||||||
@ -262,7 +350,7 @@ fi
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then
|
||||||
log "Creating BTCPay Server..."
|
log "Creating BTCPay Server..."
|
||||||
mkdir -p /var/lib/archipelago/btcpay
|
mkdir -p /var/lib/archipelago/btcpay
|
||||||
$DOCKER run -d --name btcpay-server --restart unless-stopped --network archy-net \
|
$DOCKER run -d --name btcpay-server --restart unless-stopped --memory=$(mem_limit btcpay-server) --network archy-net \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 23000:49392 -v /var/lib/archipelago/btcpay:/datadir \
|
-p 23000:49392 -v /var/lib/archipelago/btcpay:/datadir \
|
||||||
@ -312,7 +400,7 @@ autopilot.active=false
|
|||||||
LNDCONF
|
LNDCONF
|
||||||
log "LND config created (archy-net → bitcoin-knots:8332, rpcpolling)"
|
log "LND config created (archy-net → bitcoin-knots:8332, rpcpolling)"
|
||||||
fi
|
fi
|
||||||
$DOCKER run -d --name lnd --restart unless-stopped --network archy-net \
|
$DOCKER run -d --name lnd --restart unless-stopped --memory=$(mem_limit lnd) --network archy-net \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 9735:9735 -p 10009:10009 -p 8080:8080 \
|
-p 9735:9735 -p 10009:10009 -p 8080:8080 \
|
||||||
@ -324,7 +412,7 @@ fi
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint; then
|
||||||
log "Creating Fedimint..."
|
log "Creating Fedimint..."
|
||||||
mkdir -p /var/lib/archipelago/fedimint
|
mkdir -p /var/lib/archipelago/fedimint
|
||||||
$DOCKER run -d --name fedimint --restart unless-stopped --network archy-net \
|
$DOCKER run -d --name fedimint --restart unless-stopped --memory=$(mem_limit fedimint) --network archy-net \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 8173:8173 -p 8174:8174 -p 8175:8175 \
|
-p 8173:8173 -p 8174:8174 -p 8175:8175 \
|
||||||
@ -346,7 +434,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; th
|
|||||||
LND_MACAROON=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon
|
LND_MACAROON=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon
|
||||||
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^lnd$' && [ -f "$LND_CERT" ] && [ -f "$LND_MACAROON" ]; then
|
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^lnd$' && [ -f "$LND_CERT" ] && [ -f "$LND_MACAROON" ]; then
|
||||||
log " LND detected — using lnd mode"
|
log " LND detected — using lnd mode"
|
||||||
$DOCKER run -d --name fedimint-gateway --restart unless-stopped --network archy-net \
|
$DOCKER run -d --name fedimint-gateway --restart unless-stopped --memory=$(mem_limit fedimint-gateway) --network archy-net \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 8176:8176 \
|
-p 8176:8176 \
|
||||||
@ -361,7 +449,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; th
|
|||||||
lnd --lnd-rpc-host "$TARGET_IP":10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon 2>>"$LOG" || true
|
lnd --lnd-rpc-host "$TARGET_IP":10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon 2>>"$LOG" || true
|
||||||
else
|
else
|
||||||
log " No LND found — using ldk (built-in Lightning)"
|
log " No LND found — using ldk (built-in Lightning)"
|
||||||
$DOCKER run -d --name fedimint-gateway --restart unless-stopped --network archy-net \
|
$DOCKER run -d --name fedimint-gateway --restart unless-stopped --memory=$(mem_limit fedimint-gateway) --network archy-net \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 8176:8176 -p 9737:9737 \
|
-p 8176:8176 -p 9737:9737 \
|
||||||
@ -383,7 +471,7 @@ sleep 5 # Let core services stabilize
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'homeassistant|home-assistant'; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'homeassistant|home-assistant'; then
|
||||||
log "Creating Home Assistant..."
|
log "Creating Home Assistant..."
|
||||||
mkdir -p /var/lib/archipelago/home-assistant
|
mkdir -p /var/lib/archipelago/home-assistant
|
||||||
$DOCKER run -d --name homeassistant --restart unless-stopped \
|
$DOCKER run -d --name homeassistant --restart unless-stopped --memory=$(mem_limit homeassistant) \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 8123:8123 -v /var/lib/archipelago/home-assistant:/config \
|
-p 8123:8123 -v /var/lib/archipelago/home-assistant:/config \
|
||||||
@ -396,7 +484,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q grafana; then
|
|||||||
log "Creating Grafana..."
|
log "Creating Grafana..."
|
||||||
mkdir -p /var/lib/archipelago/grafana
|
mkdir -p /var/lib/archipelago/grafana
|
||||||
chown 472:472 /var/lib/archipelago/grafana 2>/dev/null || true
|
chown 472:472 /var/lib/archipelago/grafana 2>/dev/null || true
|
||||||
$DOCKER run -d --name grafana --restart unless-stopped \
|
$DOCKER run -d --name grafana --restart unless-stopped --memory=$(mem_limit grafana) \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
|
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \
|
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \
|
||||||
@ -407,7 +495,7 @@ fi
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q uptime-kuma; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q uptime-kuma; then
|
||||||
log "Creating Uptime Kuma..."
|
log "Creating Uptime Kuma..."
|
||||||
mkdir -p /var/lib/archipelago/uptime-kuma
|
mkdir -p /var/lib/archipelago/uptime-kuma
|
||||||
$DOCKER run -d --name uptime-kuma --restart unless-stopped \
|
$DOCKER run -d --name uptime-kuma --restart unless-stopped --memory=$(mem_limit uptime-kuma) \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID \
|
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 3001:3001 -v /var/lib/archipelago/uptime-kuma:/app/data \
|
-p 3001:3001 -v /var/lib/archipelago/uptime-kuma:/app/data \
|
||||||
@ -417,7 +505,7 @@ fi
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q jellyfin; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q jellyfin; then
|
||||||
log "Creating Jellyfin..."
|
log "Creating Jellyfin..."
|
||||||
mkdir -p /var/lib/archipelago/jellyfin/config /var/lib/archipelago/jellyfin/cache
|
mkdir -p /var/lib/archipelago/jellyfin/config /var/lib/archipelago/jellyfin/cache
|
||||||
$DOCKER run -d --name jellyfin --restart unless-stopped \
|
$DOCKER run -d --name jellyfin --restart unless-stopped --memory=$(mem_limit jellyfin) \
|
||||||
--cap-drop ALL --security-opt no-new-privileges:true \
|
--cap-drop ALL --security-opt no-new-privileges:true \
|
||||||
-p 8096:8096 \
|
-p 8096:8096 \
|
||||||
-v /var/lib/archipelago/jellyfin/config:/config \
|
-v /var/lib/archipelago/jellyfin/config:/config \
|
||||||
@ -427,7 +515,7 @@ fi
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q photoprism; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q photoprism; then
|
||||||
log "Creating PhotoPrism..."
|
log "Creating PhotoPrism..."
|
||||||
mkdir -p /var/lib/archipelago/photoprism
|
mkdir -p /var/lib/archipelago/photoprism
|
||||||
$DOCKER run -d --name photoprism --restart unless-stopped \
|
$DOCKER run -d --name photoprism --restart unless-stopped --memory=$(mem_limit photoprism) \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
|
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 2342:2342 -v /var/lib/archipelago/photoprism:/photoprism/storage \
|
-p 2342:2342 -v /var/lib/archipelago/photoprism:/photoprism/storage \
|
||||||
@ -437,7 +525,7 @@ fi
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q ollama; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q ollama; then
|
||||||
log "Creating Ollama..."
|
log "Creating Ollama..."
|
||||||
mkdir -p /var/lib/archipelago/ollama
|
mkdir -p /var/lib/archipelago/ollama
|
||||||
$DOCKER run -d --name ollama --restart unless-stopped \
|
$DOCKER run -d --name ollama --restart unless-stopped --memory=$(mem_limit ollama) \
|
||||||
--cap-drop ALL --security-opt no-new-privileges:true \
|
--cap-drop ALL --security-opt no-new-privileges:true \
|
||||||
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \
|
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \
|
||||||
-p 11434:11434 -v /var/lib/archipelago/ollama:/root/.ollama \
|
-p 11434:11434 -v /var/lib/archipelago/ollama:/root/.ollama \
|
||||||
@ -446,7 +534,7 @@ fi
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q vaultwarden; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q vaultwarden; then
|
||||||
log "Creating Vaultwarden..."
|
log "Creating Vaultwarden..."
|
||||||
mkdir -p /var/lib/archipelago/vaultwarden
|
mkdir -p /var/lib/archipelago/vaultwarden
|
||||||
$DOCKER run -d --name vaultwarden --restart unless-stopped \
|
$DOCKER run -d --name vaultwarden --restart unless-stopped --memory=$(mem_limit vaultwarden) \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
|
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 8082:80 -v /var/lib/archipelago/vaultwarden:/data \
|
-p 8082:80 -v /var/lib/archipelago/vaultwarden:/data \
|
||||||
@ -455,7 +543,7 @@ fi
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nextcloud; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nextcloud; then
|
||||||
log "Creating Nextcloud..."
|
log "Creating Nextcloud..."
|
||||||
mkdir -p /var/lib/archipelago/nextcloud
|
mkdir -p /var/lib/archipelago/nextcloud
|
||||||
$DOCKER run -d --name nextcloud --restart unless-stopped \
|
$DOCKER run -d --name nextcloud --restart unless-stopped --memory=$(mem_limit nextcloud) \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 8085:80 -v /var/lib/archipelago/nextcloud:/var/www/html \
|
-p 8085:80 -v /var/lib/archipelago/nextcloud:/var/www/html \
|
||||||
@ -463,7 +551,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nextcloud; then
|
|||||||
fi
|
fi
|
||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q searxng; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q searxng; then
|
||||||
log "Creating SearXNG..."
|
log "Creating SearXNG..."
|
||||||
$DOCKER run -d --name searxng --restart unless-stopped \
|
$DOCKER run -d --name searxng --restart unless-stopped --memory=$(mem_limit searxng) \
|
||||||
--cap-drop ALL --security-opt no-new-privileges:true \
|
--cap-drop ALL --security-opt no-new-privileges:true \
|
||||||
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \
|
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \
|
||||||
-p 8888:8080 \
|
-p 8888:8080 \
|
||||||
@ -471,7 +559,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q searxng; then
|
|||||||
fi
|
fi
|
||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q onlyoffice; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q onlyoffice; then
|
||||||
log "Creating OnlyOffice..."
|
log "Creating OnlyOffice..."
|
||||||
$DOCKER run -d --name onlyoffice --restart unless-stopped \
|
$DOCKER run -d --name onlyoffice --restart unless-stopped --memory=$(mem_limit onlyoffice) \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 9980:80 \
|
-p 9980:80 \
|
||||||
@ -480,14 +568,14 @@ fi
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q filebrowser; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q filebrowser; then
|
||||||
log "Creating File Browser..."
|
log "Creating File Browser..."
|
||||||
mkdir -p /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-db
|
mkdir -p /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-db
|
||||||
$DOCKER run -d --name filebrowser --restart unless-stopped \
|
$DOCKER run -d --name filebrowser --restart unless-stopped --memory=$(mem_limit filebrowser) \
|
||||||
-p 8083:80 -v /var/lib/archipelago/filebrowser:/srv \
|
-p 8083:80 -v /var/lib/archipelago/filebrowser:/srv \
|
||||||
docker.io/filebrowser/filebrowser:v2.27.0 2>>"$LOG" || true
|
docker.io/filebrowser/filebrowser:v2.27.0 2>>"$LOG" || true
|
||||||
fi
|
fi
|
||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nginx-proxy-manager; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nginx-proxy-manager; then
|
||||||
log "Creating Nginx Proxy Manager..."
|
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 /var/lib/archipelago/nginx-proxy-manager/letsencrypt
|
||||||
$DOCKER run -d --name nginx-proxy-manager --restart unless-stopped \
|
$DOCKER run -d --name nginx-proxy-manager --restart unless-stopped --memory=$(mem_limit nginx-proxy-manager) \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
|
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 81:81 -p 8084:80 -p 8443:443 \
|
-p 81:81 -p 8084:80 -p 8443:443 \
|
||||||
@ -498,7 +586,7 @@ fi
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q portainer; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q portainer; then
|
||||||
log "Creating Portainer..."
|
log "Creating Portainer..."
|
||||||
mkdir -p /var/lib/archipelago/portainer
|
mkdir -p /var/lib/archipelago/portainer
|
||||||
$DOCKER run -d --name portainer --restart unless-stopped \
|
$DOCKER run -d --name portainer --restart unless-stopped --memory=$(mem_limit portainer) \
|
||||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
-p 9000:9000 \
|
-p 9000:9000 \
|
||||||
@ -510,7 +598,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q tailscale; then
|
|||||||
log "Creating Tailscale..."
|
log "Creating Tailscale..."
|
||||||
mkdir -p /var/lib/archipelago/tailscale
|
mkdir -p /var/lib/archipelago/tailscale
|
||||||
# Tailscale needs NET_ADMIN + NET_RAW + TUN device (no --privileged)
|
# Tailscale needs NET_ADMIN + NET_RAW + TUN device (no --privileged)
|
||||||
$DOCKER run -d --name tailscale --restart unless-stopped \
|
$DOCKER run -d --name tailscale --restart unless-stopped --memory=$(mem_limit tailscale) \
|
||||||
--network host \
|
--network host \
|
||||||
--cap-drop=ALL \
|
--cap-drop=ALL \
|
||||||
--cap-add=NET_ADMIN \
|
--cap-add=NET_ADMIN \
|
||||||
@ -537,7 +625,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
|
|||||||
mkdir -p /var/lib/archipelago/immich /var/lib/archipelago/immich-db
|
mkdir -p /var/lib/archipelago/immich /var/lib/archipelago/immich-db
|
||||||
$DOCKER network create immich-net 2>/dev/null || true
|
$DOCKER network create immich-net 2>/dev/null || true
|
||||||
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q immich_postgres; then
|
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q immich_postgres; then
|
||||||
$DOCKER run -d --name immich_postgres --restart unless-stopped --network immich-net \
|
$DOCKER run -d --name immich_postgres --restart unless-stopped --memory=$(mem_limit immich_postgres) --network immich-net \
|
||||||
-v /var/lib/archipelago/immich-db:/var/lib/postgresql/data \
|
-v /var/lib/archipelago/immich-db:/var/lib/postgresql/data \
|
||||||
-e POSTGRES_PASSWORD=$IMMICH_DB_PASS -e POSTGRES_USER=postgres -e POSTGRES_DB=immich \
|
-e POSTGRES_PASSWORD=$IMMICH_DB_PASS -e POSTGRES_USER=postgres -e POSTGRES_DB=immich \
|
||||||
ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 2>>"$LOG" || true
|
ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 2>>"$LOG" || true
|
||||||
@ -548,12 +636,12 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_redis; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_redis; then
|
||||||
$DOCKER run -d --name immich_redis --restart unless-stopped --network immich-net \
|
$DOCKER run -d --name immich_redis --restart unless-stopped --memory=$(mem_limit immich_redis) --network immich-net \
|
||||||
docker.io/valkey/valkey:7-alpine 2>>"$LOG" || true
|
docker.io/valkey/valkey:7-alpine 2>>"$LOG" || true
|
||||||
sleep 2
|
sleep 2
|
||||||
fi
|
fi
|
||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
|
||||||
$DOCKER run -d --name immich_server --restart unless-stopped --network immich-net \
|
$DOCKER run -d --name immich_server --restart unless-stopped --memory=$(mem_limit immich_server) --network immich-net \
|
||||||
-p 2283:2283 -v /var/lib/archipelago/immich:/usr/src/app/upload \
|
-p 2283:2283 -v /var/lib/archipelago/immich:/usr/src/app/upload \
|
||||||
-e DB_HOSTNAME=immich_postgres -e DB_USERNAME=postgres -e DB_PASSWORD=$IMMICH_DB_PASS \
|
-e DB_HOSTNAME=immich_postgres -e DB_USERNAME=postgres -e DB_PASSWORD=$IMMICH_DB_PASS \
|
||||||
-e DB_DATABASE_NAME=immich -e REDIS_HOSTNAME=immich_redis \
|
-e DB_DATABASE_NAME=immich -e REDIS_HOSTNAME=immich_redis \
|
||||||
@ -568,20 +656,20 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; the
|
|||||||
mkdir -p /var/lib/archipelago/penpot-assets /var/lib/archipelago/penpot-postgres
|
mkdir -p /var/lib/archipelago/penpot-assets /var/lib/archipelago/penpot-postgres
|
||||||
$DOCKER network create penpot-net 2>/dev/null || true
|
$DOCKER network create penpot-net 2>/dev/null || true
|
||||||
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q penpot-postgres; then
|
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q penpot-postgres; then
|
||||||
$DOCKER run -d --name penpot-postgres --restart unless-stopped --network penpot-net \
|
$DOCKER run -d --name penpot-postgres --restart unless-stopped --memory=$(mem_limit penpot-postgres) --network penpot-net \
|
||||||
-v /var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data \
|
-v /var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data \
|
||||||
-e POSTGRES_DB=penpot -e POSTGRES_USER=penpot -e POSTGRES_PASSWORD=$PENPOT_DB_PASS \
|
-e POSTGRES_DB=penpot -e POSTGRES_USER=penpot -e POSTGRES_PASSWORD=$PENPOT_DB_PASS \
|
||||||
docker.io/postgres:15 2>>"$LOG" || true
|
docker.io/postgres:15 2>>"$LOG" || true
|
||||||
sleep 5
|
sleep 5
|
||||||
fi
|
fi
|
||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-valkey; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-valkey; then
|
||||||
$DOCKER run -d --name penpot-valkey --restart unless-stopped --network penpot-net \
|
$DOCKER run -d --name penpot-valkey --restart unless-stopped --memory=$(mem_limit penpot-valkey) --network penpot-net \
|
||||||
-e VALKEY_EXTRA_FLAGS="--maxmemory 128mb --maxmemory-policy volatile-lfu" \
|
-e VALKEY_EXTRA_FLAGS="--maxmemory 128mb --maxmemory-policy volatile-lfu" \
|
||||||
docker.io/valkey/valkey:8.1 2>>"$LOG" || true
|
docker.io/valkey/valkey:8.1 2>>"$LOG" || true
|
||||||
sleep 3
|
sleep 3
|
||||||
fi
|
fi
|
||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-backend; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-backend; then
|
||||||
$DOCKER run -d --name penpot-backend --restart unless-stopped --network penpot-net \
|
$DOCKER run -d --name penpot-backend --restart unless-stopped --memory=$(mem_limit penpot-backend) --network penpot-net \
|
||||||
-v /var/lib/archipelago/penpot-assets:/opt/data/assets \
|
-v /var/lib/archipelago/penpot-assets:/opt/data/assets \
|
||||||
-e PENPOT_PUBLIC_URI="http://${TARGET_IP}:9001" \
|
-e PENPOT_PUBLIC_URI="http://${TARGET_IP}:9001" \
|
||||||
-e PENPOT_SECRET_KEY=archipelago-penpot-secret-key-change-in-production \
|
-e PENPOT_SECRET_KEY=archipelago-penpot-secret-key-change-in-production \
|
||||||
@ -595,7 +683,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; the
|
|||||||
sleep 5
|
sleep 5
|
||||||
fi
|
fi
|
||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-exporter; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-exporter; then
|
||||||
$DOCKER run -d --name penpot-exporter --restart unless-stopped --network penpot-net \
|
$DOCKER run -d --name penpot-exporter --restart unless-stopped --memory=$(mem_limit penpot-exporter) --network penpot-net \
|
||||||
-e PENPOT_SECRET_KEY=archipelago-penpot-secret-key-change-in-production \
|
-e PENPOT_SECRET_KEY=archipelago-penpot-secret-key-change-in-production \
|
||||||
-e PENPOT_PUBLIC_URI=http://penpot-frontend:8080 \
|
-e PENPOT_PUBLIC_URI=http://penpot-frontend:8080 \
|
||||||
-e PENPOT_REDIS_URI=redis://penpot-valkey/0 \
|
-e PENPOT_REDIS_URI=redis://penpot-valkey/0 \
|
||||||
@ -603,7 +691,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; the
|
|||||||
sleep 2
|
sleep 2
|
||||||
fi
|
fi
|
||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; then
|
||||||
$DOCKER run -d --name penpot-frontend --restart unless-stopped --network penpot-net \
|
$DOCKER run -d --name penpot-frontend --restart unless-stopped --memory=$(mem_limit penpot-frontend) --network penpot-net \
|
||||||
-p 9001:8080 -v /var/lib/archipelago/penpot-assets:/opt/data/assets \
|
-p 9001:8080 -v /var/lib/archipelago/penpot-assets:/opt/data/assets \
|
||||||
-e PENPOT_PUBLIC_URI="http://${TARGET_IP}:9001" \
|
-e PENPOT_PUBLIC_URI="http://${TARGET_IP}:9001" \
|
||||||
-e PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies \
|
-e PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies \
|
||||||
@ -617,7 +705,7 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'nos
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nostr-rs-relay; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nostr-rs-relay; then
|
||||||
log "Creating nostr-rs-relay..."
|
log "Creating nostr-rs-relay..."
|
||||||
mkdir -p /var/lib/archipelago/nostr-rs-relay
|
mkdir -p /var/lib/archipelago/nostr-rs-relay
|
||||||
$DOCKER run -d --name nostr-rs-relay --restart unless-stopped \
|
$DOCKER run -d --name nostr-rs-relay --restart unless-stopped --memory=$(mem_limit nostr-rs-relay) \
|
||||||
-p 7047:7047 -v /var/lib/archipelago/nostr-rs-relay:/data \
|
-p 7047:7047 -v /var/lib/archipelago/nostr-rs-relay:/data \
|
||||||
scsibug/nostr-rs-relay:latest 2>>"$LOG" || true
|
scsibug/nostr-rs-relay:latest 2>>"$LOG" || true
|
||||||
fi
|
fi
|
||||||
@ -626,7 +714,7 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'str
|
|||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q strfry; then
|
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q strfry; then
|
||||||
log "Creating strfry..."
|
log "Creating strfry..."
|
||||||
mkdir -p /var/lib/archipelago/strfry
|
mkdir -p /var/lib/archipelago/strfry
|
||||||
$DOCKER run -d --name strfry --restart unless-stopped \
|
$DOCKER run -d --name strfry --restart unless-stopped --memory=$(mem_limit strfry) \
|
||||||
-p 7777:7777 -v /var/lib/archipelago/strfry:/data \
|
-p 7777:7777 -v /var/lib/archipelago/strfry:/data \
|
||||||
hoytech/strfry:latest 2>>"$LOG" || true
|
hoytech/strfry:latest 2>>"$LOG" || true
|
||||||
fi
|
fi
|
||||||
@ -644,7 +732,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q indeedhub; then
|
|||||||
fi
|
fi
|
||||||
if [ -n "$INDEEDHUB_IMAGE" ]; then
|
if [ -n "$INDEEDHUB_IMAGE" ]; then
|
||||||
log "Creating Indeehub from $INDEEDHUB_IMAGE..."
|
log "Creating Indeehub from $INDEEDHUB_IMAGE..."
|
||||||
$DOCKER run -d --name indeedhub --restart unless-stopped \
|
$DOCKER run -d --name indeedhub --restart unless-stopped --memory=$(mem_limit indeedhub) \
|
||||||
--cap-drop ALL --security-opt no-new-privileges:true \
|
--cap-drop ALL --security-opt no-new-privileges:true \
|
||||||
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m --tmpfs /app/.next/cache:rw,noexec,nosuid,size=128m \
|
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m --tmpfs /app/.next/cache:rw,noexec,nosuid,size=128m \
|
||||||
-p 7777:7777 \
|
-p 7777:7777 \
|
||||||
|
|||||||
@ -21,13 +21,38 @@ chmod 755 "$SSL_DIR"
|
|||||||
|
|
||||||
# Generate self-signed cert if missing (valid 365 days)
|
# Generate self-signed cert if missing (valid 365 days)
|
||||||
# SAN includes common dev IPs so cert works when accessing via IP
|
# SAN includes common dev IPs so cert works when accessing via IP
|
||||||
|
# Build dynamic SAN with all node IPs (LAN + Tailscale + loopback)
|
||||||
|
SAN_IPS="DNS:archipelago.local,DNS:localhost,IP:127.0.0.1"
|
||||||
|
# Add all IPv4 addresses on this machine (LAN, Tailscale, etc.)
|
||||||
|
for ip in $(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.' | grep -v '^127\.'); do
|
||||||
|
SAN_IPS="$SAN_IPS,IP:$ip"
|
||||||
|
done
|
||||||
|
# Always include common LAN IPs as fallback
|
||||||
|
for ip in 192.168.1.228 192.168.1.198 10.0.0.1; do
|
||||||
|
echo "$SAN_IPS" | grep -q "$ip" || SAN_IPS="$SAN_IPS,IP:$ip"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Regenerate cert if missing OR if current cert doesn't include this node's primary IP
|
||||||
|
REGEN=false
|
||||||
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
|
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
|
||||||
|
REGEN=true
|
||||||
|
else
|
||||||
|
# Check if cert has this node's primary IP
|
||||||
|
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||||
|
if [ -n "$MY_IP" ] && ! openssl x509 -in "$CERT" -noout -text 2>/dev/null | grep -q "$MY_IP"; then
|
||||||
|
echo " Certificate missing this node's IP ($MY_IP) — regenerating..."
|
||||||
|
REGEN=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$REGEN" = true ]; then
|
||||||
echo "Generating self-signed certificate for PWA (HTTPS)..."
|
echo "Generating self-signed certificate for PWA (HTTPS)..."
|
||||||
|
echo " SAN: $SAN_IPS"
|
||||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||||
-keyout "$KEY" \
|
-keyout "$KEY" \
|
||||||
-out "$CERT" \
|
-out "$CERT" \
|
||||||
-subj "/CN=archipelago.local/O=Archipelago/C=US" \
|
-subj "/CN=archipelago.local/O=Archipelago/C=US" \
|
||||||
-addext "subjectAltName=DNS:archipelago.local,DNS:localhost,IP:127.0.0.1,IP:192.168.1.228,IP:192.168.1.198,IP:10.0.0.1"
|
-addext "subjectAltName=$SAN_IPS"
|
||||||
chmod 644 "$CERT"
|
chmod 644 "$CERT"
|
||||||
chmod 600 "$KEY"
|
chmod 600 "$KEY"
|
||||||
echo " Certificate created at $CERT"
|
echo " Certificate created at $CERT"
|
||||||
@ -74,8 +99,9 @@ fi
|
|||||||
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null; then
|
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null; then
|
||||||
echo "HTTPS already configured in nginx."
|
echo "HTTPS already configured in nginx."
|
||||||
nginx -t 2>/dev/null && systemctl reload nginx
|
nginx -t 2>/dev/null && systemctl reload nginx
|
||||||
|
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||||
echo ""
|
echo ""
|
||||||
echo "PWA: Use https://192.168.1.228 (not http) - accept cert once, then Install app."
|
echo "PWA: Use https://${MY_IP:-192.168.1.228} (not http) - accept cert once, then Install app."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -250,4 +276,5 @@ echo "Added HTTPS (port 443) to nginx config."
|
|||||||
# Test and reload
|
# Test and reload
|
||||||
nginx -t && systemctl reload nginx
|
nginx -t && systemctl reload nginx
|
||||||
echo ""
|
echo ""
|
||||||
echo "HTTPS enabled. PWA install: https://192.168.1.228 (accept the certificate warning once, then Install app)."
|
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||||
|
echo "HTTPS enabled. PWA install: https://${MY_IP:-192.168.1.228} (accept the certificate warning once, then Install app)."
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user