From f20f0650cf5a71eee1578a34248bac52434fed06 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 19 Mar 2026 16:12:01 +0000 Subject: [PATCH] feat: Discover view, Fleet dashboard, MeshMap, type fixes - New Discover.vue (app store redesign) - Fleet.vue dashboard for .228 - MeshMap.vue component - Fixed Discover.vue type errors (unused var, type predicate) - Various UI updates (Apps, Dashboard, Marketplace, Mesh, Web5) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../project_repo_cleanup_and_dev_env.md | 102 +- .claude/plans/tailscale-migration.md | 119 ++ core/archipelago/src/api/rpc/analytics.rs | 237 +++- core/archipelago/src/monitoring/mod.rs | 60 + image-recipe/configs/nginx-archipelago.conf | 4 +- neode-ui/dev-dist/sw.js | 2 +- neode-ui/package-lock.json | 41 +- neode-ui/package.json | 3 + neode-ui/src/components/MeshMap.vue | 590 +++++++++ neode-ui/src/stores/mesh.ts | 74 ++ neode-ui/src/views/Apps.vue | 19 +- neode-ui/src/views/Dashboard.vue | 7 +- neode-ui/src/views/Discover.vue | 10 +- neode-ui/src/views/Fleet.vue | 733 +++++++++++ neode-ui/src/views/Marketplace.vue | 3 +- neode-ui/src/views/Mesh.vue | 39 +- neode-ui/src/views/Web5.vue | 33 +- scripts/deploy-tailscale.sh | 1165 ++++++++++++++++- 18 files changed, 3067 insertions(+), 174 deletions(-) create mode 100644 .claude/plans/tailscale-migration.md create mode 100644 neode-ui/src/components/MeshMap.vue create mode 100644 neode-ui/src/views/Fleet.vue diff --git a/.claude/memory/project_repo_cleanup_and_dev_env.md b/.claude/memory/project_repo_cleanup_and_dev_env.md index 12a5cc75..8d9a248d 100644 --- a/.claude/memory/project_repo_cleanup_and_dev_env.md +++ b/.claude/memory/project_repo_cleanup_and_dev_env.md @@ -1,78 +1,42 @@ --- -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 +name: v1.3.0 Deploy Status +description: March 19 session — pentest remediation, container reliability, deployment to .228/.198 type: project --- -## What Was Done +## v1.3.0 Deployed (2026-03-19) -### 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` +### .228 — Fully deployed and verified +- All 33 pentest security fixes live (including backend auth on /lnd-connect-info) +- ElectrumX headers.subscribe fix — synced at block 941k+ +- Container reliability: memory limits in scripts, crash recovery coordination, health badges +- Backend bound to 127.0.0.1:5678 (systemd + nginx) +- Frontend: iframe auto-retry, TransactionsModal, health-aware badges, What's New v1.3.0 +- 31 containers running, all healthy -### 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 +### .198 — Partially deployed, needs attention +- Binary deployed but machine chronically overloaded (8GB RAM, load 10+) +- Bitcoin RPC 401 FIXED (secrets dir was root-owned) +- SearXNG settings.yml created, LND Tor REST port 8080 added +- Tor uses archipelago torrc NOT system torrc — needs consolidation +- Jellyfin stopped to save resources +- ElectrumX indexing (pruned data, will be slow) -### 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 +### Deploy lessons learned +- `cargo clean -p` + rebuild doesn't always recompile if rsync preserved timestamps +- Fix: append blank line to force mtime change, or use `cargo build --release` after manual touch +- Atomic binary swap: `cp new, mv over running` works; `cp over running` fails with "Text file busy" +- systemd `Restart=always` prevents `systemctl stop` + `cp` — must use atomic mv -### 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 +### Backlog for next session +1. .198 stabilization (reduce containers for 8GB, apply memory limits via container recreation) +2. .198 Tor consolidation (system tor vs archipelago tor process) +3. BTCPay iframe cross-origin error (needs nginx proxy config) +4. Tailscale admin page in iframe +5. ElectrumX UI: Tor first as connect option +6. Stagger animation fix + fleet dashboard + map tab +7. Deploy to Tailscale nodes (Arch 1/2/3) +8. App iframe error page — auto-retry now works, but needs polish -### 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) +**Why:** Track deployment state for session continuity. +**How to apply:** Read at start of next session. Check .198 load before attempting operations. diff --git a/.claude/plans/tailscale-migration.md b/.claude/plans/tailscale-migration.md new file mode 100644 index 00000000..8d9e87c9 --- /dev/null +++ b/.claude/plans/tailscale-migration.md @@ -0,0 +1,119 @@ +# Plan: Seamless Tailscale Migration for Alpha Testers + +## Context + +Tailscale nodes (Arch 1/2/3) are alpha tester machines. They need full deployment — binary, frontend, infrastructure, and containers — with zero friction. Currently `deploy-tailscale.sh` only deploys binary + frontend (85 lines), missing ALL infrastructure that `deploy-to-target.sh --live` provides (rootless prereqs, UID mapping, containers, nginx, Tor, HTTPS, dev mode, UFW, etc.). + +These nodes may also have old **rootful** containers that need migrating to rootless. + +## Approach + +**Don't refactor the 1615-line deploy-to-target.sh** — too risky during beta freeze. Instead: + +1. **Rewrite `deploy-tailscale.sh`** as a full-deploy script with split-mode SSH resilience +2. **Add `--tailscale` flag** to `deploy-to-target.sh` as a convenience wrapper +3. **Add rootful→rootless migration** as an automatic pre-step +4. **Fix `first-boot-containers.sh`** for rootless (separate concern, for ISO builds) + +## Changes + +### 1. Rewrite `scripts/deploy-tailscale.sh` (~400 lines) + +Currently 85 lines doing only binary+frontend. Rewrite to be a full deploy for any node, using split-mode SSH (each step = separate short SSH session) for Tailscale stability. + +**Steps the new script will run (each as its own SSH session):** + +1. SSH connectivity check +2. Install prerequisites (rsync, node, npm) if missing +3. Rsync code to target +4. **Rootful→rootless migration** (detect `sudo podman ps -a`, stop & remove old rootful containers) +5. Build frontend (nohup + poll, or skip if copy-only node) +6. Build backend (nohup + poll, or skip if copy-only node) +7. Create rollback backup +8. Deploy binary (build locally or copy from .228) +9. Deploy frontend (build locally or copy from .228) +10. Deploy AIUI +11. Sync nginx config + HTTPS snippets +12. Sync systemd service +13. **Setup rootless prereqs** (sysctl, linger, podman.socket) +14. **Create data dirs + UID mapping** (full chown table from deploy-to-target.sh:670-689) +15. **Dev mode** (ARCHIPELAGO_DEV_MODE=true for HTTP cookies over Tailscale) +16. Deploy nostr-provider.js +17. Deploy Claude API proxy (if ANTHROPIC_API_KEY available) +18. Setup NTP + swap +19. Restart services +20. **Setup HTTPS** (with node's own IP in SAN) +21. **Read Bitcoin RPC credentials** from server secrets +22. **Create all containers** (Bitcoin, Mempool, BTCPay, ElectrumX, LND, Fedimint, Immich, HA, Grafana, Jellyfin, Vaultwarden, SearXNG, FileBrowser) +23. **Setup Tor** hidden services +24. **Fix UFW** forward policy +25. **Fix IndeedHub** NIP-07 (if running) +26. **Transfer custom images** for copy-only nodes (individual tarballs, never combined) +27. Run container doctor +28. Write deploy manifest +29. Post-deploy health check + +**Copy-only mode**: When target can't build (Arch 1/3), script detects no `cargo`/`npm` on target and copies pre-built artifacts from .228 via SSH pipe. + +**Key sections to port from deploy-to-target.sh:** +- Lines 646-689 — rootless prereqs + UID mapping +- Lines 629-641 — dev mode +- Lines 839-1474 — all container creation +- Lines 1143-1234 — Tor setup +- Lines 1477-1485 — UFW fix +- Lines 1487-1545 — IndeedHub NIP-07 + +### 2. Add `--tailscale` flag to `deploy-to-target.sh` (~30 lines) + +Wrapper that calls `deploy-tailscale.sh` for each node sequentially. Also add `--tailscale-node=arch1|arch2|arch3` for single-node targeting. + +### 3. Rootful→rootless migration (in deploy-tailscale.sh step 4) + +Auto-detect and handle: +``` +ssh TARGET 'ROOTFUL=$(sudo podman ps -a 2>/dev/null | wc -l); if [ $ROOTFUL -gt 1 ]; then sudo podman stop --all; sudo podman rm --all; fi' +``` +Data safe — `/var/lib/archipelago/` never deleted, only ownership fixed by UID mapping step. + +### 4. Fix `scripts/first-boot-containers.sh` (5 targeted edits) + +- **Line 15**: Change root check → archipelago user check (UID 1000) +- **Line 140**: Change `10.88.0.0/16` → `0.0.0.0/0` (match deploy-to-target.sh) +- **After line 111**: Add rootless prereqs (sysctl, linger, podman.socket) +- **After line 113**: Add full UID mapping block +- **Pin `:latest` tags**: photoprism, ollama, searxng, nginx-proxy-manager, penpot + +### 5. Update `scripts/setup-https-dev.sh` + +Dynamic SAN — detect node's own IPs (including Tailscale interface) instead of hardcoding .228/.198. + +## Files Modified + +| File | Change | ~Lines | +|------|--------|--------| +| `scripts/deploy-tailscale.sh` | Full rewrite — complete deploy with split-mode SSH | ~400 | +| `scripts/deploy-to-target.sh` | Add `--tailscale` / `--tailscale-node` flags | ~30 | +| `scripts/first-boot-containers.sh` | Fix for rootless (subnet, UID mapping, prereqs) | ~40 | +| `scripts/setup-https-dev.sh` | Dynamic SAN with Tailscale IPs | ~15 | +| `docs/BETA-PROGRESS.md` | Update TASK-11 status | ~5 | + +## Auth State Preservation + +All user state in `/var/lib/archipelago/` is **never touched** by deploys: +- `sessions.json`, `user.json`, `identities/`, `secrets/`, `federation/` + +## Verification + +1. Deploy to Arch 2 first (has build tools, safest test) +2. Then Arch 1/3 (copy-only mode) +3. For each node: `podman ps` shows containers, `curl /health` returns 200, UI loads, login works +4. Run container doctor — 0 fixes needed + +## Order + +1. Rewrite `deploy-tailscale.sh` (main deliverable) +2. Add `--tailscale` flags to `deploy-to-target.sh` +3. Fix `first-boot-containers.sh` +4. Update `setup-https-dev.sh` +5. Test: Arch 2 → Arch 1 → Arch 3 +6. Update BETA-PROGRESS.md diff --git a/core/archipelago/src/api/rpc/analytics.rs b/core/archipelago/src/api/rpc/analytics.rs index 4826958f..f72d4e9c 100644 --- a/core/archipelago/src/api/rpc/analytics.rs +++ b/core/archipelago/src/api/rpc/analytics.rs @@ -4,8 +4,8 @@ //! Data stays local until explicitly shared via future relay mechanism. use super::RpcHandler; -use anyhow::Result; -use tracing::info; +use anyhow::{Context, Result}; +use tracing::{debug, info, warn}; const ANALYTICS_FILE: &str = "analytics-config.json"; @@ -202,4 +202,237 @@ impl RpcHandler { Ok(report) } + + // ── Fleet telemetry collector endpoints ────────────────────────────── + + /// Receive a telemetry report from a fleet node. + /// Stores it in telemetry-fleet/ directory, indexed by node_id. + /// Does NOT require auth — called by remote nodes posting reports. + pub(super) async fn handle_telemetry_ingest(&self, params: Option) -> Result { + let report = params.context("Missing telemetry report payload")?; + + // Validate required fields + let node_id = report.get("node_id") + .and_then(|v| v.as_str()) + .context("Missing required field: node_id")?; + if node_id.is_empty() || node_id.len() > 64 { + anyhow::bail!("Invalid node_id: must be 1-64 characters"); + } + // Sanitize node_id to prevent path traversal + if node_id.contains('/') || node_id.contains('\\') || node_id.contains("..") { + anyhow::bail!("Invalid node_id: contains disallowed characters"); + } + let _version = report.get("version") + .and_then(|v| v.as_str()) + .context("Missing required field: version")?; + let _reported_at = report.get("reported_at") + .and_then(|v| v.as_str()) + .context("Missing required field: reported_at")?; + + let fleet_dir = self.config.data_dir.join("telemetry-fleet"); + tokio::fs::create_dir_all(&fleet_dir).await + .context("Failed to create telemetry-fleet directory")?; + + // Write latest report (overwrites previous) + let latest_path = fleet_dir.join(format!("{}.json", node_id)); + let report_json = serde_json::to_string_pretty(&report) + .context("Failed to serialize report")?; + tokio::fs::write(&latest_path, &report_json).await + .context("Failed to write latest fleet report")?; + + // Append to history file (cap at 200 entries) + let history_path = fleet_dir.join(format!("{}-history.json", node_id)); + let mut history: Vec = match tokio::fs::read_to_string(&history_path).await { + Ok(data) => serde_json::from_str(&data).unwrap_or_default(), + Err(_) => Vec::new(), + }; + history.push(report.clone()); + // Keep only the last 200 entries + if history.len() > 200 { + let start = history.len() - 200; + history = history.split_off(start); + } + let history_json = serde_json::to_string_pretty(&history) + .context("Failed to serialize history")?; + tokio::fs::write(&history_path, &history_json).await + .context("Failed to write fleet history")?; + + debug!(node_id = %node_id, "Ingested fleet telemetry report"); + + Ok(serde_json::json!({ + "status": "ok", + "node_id": node_id, + })) + } + + /// Get all fleet nodes' latest reports. + /// Reads all {node_id}.json files from telemetry-fleet/ (excluding *-history.json). + pub(super) async fn handle_telemetry_fleet_status(&self) -> Result { + let fleet_dir = self.config.data_dir.join("telemetry-fleet"); + if !fleet_dir.exists() { + return Ok(serde_json::json!({ "nodes": [] })); + } + + let mut nodes: Vec = Vec::new(); + let mut entries = tokio::fs::read_dir(&fleet_dir).await + .context("Failed to read telemetry-fleet directory")?; + + while let Some(entry) = entries.next_entry().await? { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + // Skip history files and non-JSON files + if name.ends_with("-history.json") || !name.ends_with(".json") { + continue; + } + + match tokio::fs::read_to_string(entry.path()).await { + Ok(data) => { + match serde_json::from_str::(&data) { + Ok(mut report) => { + // Compute online/offline status from reported_at + let is_online = report.get("reported_at") + .and_then(|v| v.as_str()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| { + let age = chrono::Utc::now().signed_duration_since(dt); + age.num_minutes() < 30 + }) + .unwrap_or(false); + + // Compute human-readable last_seen + let last_seen = report.get("reported_at") + .and_then(|v| v.as_str()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| { + let age = chrono::Utc::now().signed_duration_since(dt); + let mins = age.num_minutes(); + if mins < 1 { + "just now".to_string() + } else if mins < 60 { + format!("{}m ago", mins) + } else if mins < 1440 { + format!("{}h ago", mins / 60) + } else { + format!("{}d ago", mins / 1440) + } + }) + .unwrap_or_else(|| "unknown".to_string()); + + if let Some(obj) = report.as_object_mut() { + obj.insert("online".to_string(), serde_json::json!(is_online)); + obj.insert("last_seen".to_string(), serde_json::json!(last_seen)); + } + nodes.push(report); + } + Err(e) => { + warn!(file = %name, error = %e, "Skipping corrupt fleet report"); + } + } + } + Err(e) => { + warn!(file = %name, error = %e, "Failed to read fleet report"); + } + } + } + + // Sort by node_id for stable ordering + nodes.sort_by(|a, b| { + let a_id = a.get("node_id").and_then(|v| v.as_str()).unwrap_or(""); + let b_id = b.get("node_id").and_then(|v| v.as_str()).unwrap_or(""); + a_id.cmp(b_id) + }); + + info!(count = nodes.len(), "Fleet status query"); + + Ok(serde_json::json!({ "nodes": nodes })) + } + + /// Get history for a specific fleet node. + /// Reads telemetry-fleet/{node_id}-history.json. + pub(super) async fn handle_telemetry_fleet_node_history(&self, params: Option) -> Result { + let p = params.context("Missing params")?; + let node_id = p.get("node_id") + .and_then(|v| v.as_str()) + .context("Missing required field: node_id")?; + + // Sanitize node_id + if node_id.is_empty() || node_id.len() > 64 + || node_id.contains('/') || node_id.contains('\\') || node_id.contains("..") + { + anyhow::bail!("Invalid node_id"); + } + + let history_path = self.config.data_dir + .join("telemetry-fleet") + .join(format!("{}-history.json", node_id)); + + let history: Vec = match tokio::fs::read_to_string(&history_path).await { + Ok(data) => serde_json::from_str(&data).unwrap_or_default(), + Err(_) => Vec::new(), + }; + + Ok(serde_json::json!({ + "node_id": node_id, + "entries": history, + "count": history.len(), + })) + } + + /// Get aggregated fleet alerts across all nodes. + /// Reads all fleet reports, collects recent_alerts, sorts by timestamp descending. + pub(super) async fn handle_telemetry_fleet_alerts(&self) -> Result { + let fleet_dir = self.config.data_dir.join("telemetry-fleet"); + if !fleet_dir.exists() { + return Ok(serde_json::json!({ "alerts": [] })); + } + + let mut all_alerts: Vec = Vec::new(); + let mut entries = tokio::fs::read_dir(&fleet_dir).await + .context("Failed to read telemetry-fleet directory")?; + + while let Some(entry) = entries.next_entry().await? { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + // Only read latest reports, skip history files + if name.ends_with("-history.json") || !name.ends_with(".json") { + continue; + } + + let data = match tokio::fs::read_to_string(entry.path()).await { + Ok(d) => d, + Err(_) => continue, + }; + let report: serde_json::Value = match serde_json::from_str(&data) { + Ok(r) => r, + Err(_) => continue, + }; + + let node_id = report.get("node_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + if let Some(alerts) = report.get("recent_alerts").and_then(|v| v.as_array()) { + for alert in alerts { + let mut enriched = alert.clone(); + if let Some(obj) = enriched.as_object_mut() { + obj.insert("node_id".to_string(), serde_json::json!(node_id)); + } + all_alerts.push(enriched); + } + } + } + + // Sort by timestamp descending (most recent first) + all_alerts.sort_by(|a, b| { + let a_ts = a.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0); + let b_ts = b.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0); + b_ts.cmp(&a_ts) + }); + + Ok(serde_json::json!({ + "alerts": all_alerts, + "count": all_alerts.len(), + })) + } } diff --git a/core/archipelago/src/monitoring/mod.rs b/core/archipelago/src/monitoring/mod.rs index f9cf83ef..7e5af054 100644 --- a/core/archipelago/src/monitoring/mod.rs +++ b/core/archipelago/src/monitoring/mod.rs @@ -637,6 +637,10 @@ pub fn spawn_telemetry_reporter( let _ = tokio::fs::write(&report_path, &json).await; } + // Always save to local fleet directory so this node appears + // in its own fleet view + save_report_to_fleet_dir(&data_dir, &report).await; + // POST to central collector if configured let collector_url = std::env::var("TELEMETRY_COLLECTOR_URL").ok(); if let Some(url) = collector_url { @@ -742,6 +746,62 @@ async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow: Ok(()) } +/// Save a telemetry report into the local fleet directory. +/// This makes the node's own report visible in the fleet dashboard. +async fn save_report_to_fleet_dir(data_dir: &std::path::Path, report: &serde_json::Value) { + let node_id = match report.get("node_id").and_then(|v| v.as_str()) { + Some(id) if !id.is_empty() => id, + _ => { + warn!("Telemetry report missing node_id — skipping fleet save"); + return; + } + }; + + let fleet_dir = data_dir.join("telemetry-fleet"); + if let Err(e) = tokio::fs::create_dir_all(&fleet_dir).await { + warn!("Failed to create telemetry-fleet directory: {}", e); + return; + } + + // Write latest report (overwrites previous) + let latest_path = fleet_dir.join(format!("{}.json", node_id)); + match serde_json::to_string_pretty(report) { + Ok(json) => { + if let Err(e) = tokio::fs::write(&latest_path, &json).await { + warn!("Failed to write fleet report for {}: {}", node_id, e); + } + } + Err(e) => { + warn!("Failed to serialize fleet report: {}", e); + return; + } + } + + // Append to history file (cap at 200 entries) + let history_path = fleet_dir.join(format!("{}-history.json", node_id)); + let mut history: Vec = match tokio::fs::read_to_string(&history_path).await { + Ok(data) => serde_json::from_str(&data).unwrap_or_default(), + Err(_) => Vec::new(), + }; + history.push(report.clone()); + if history.len() > 200 { + let start = history.len() - 200; + history = history.split_off(start); + } + match serde_json::to_string_pretty(&history) { + Ok(json) => { + if let Err(e) = tokio::fs::write(&history_path, &json).await { + warn!("Failed to write fleet history for {}: {}", node_id, e); + } + } + Err(e) => { + warn!("Failed to serialize fleet history: {}", e); + } + } + + debug!("Saved own telemetry report to fleet directory (node_id={})", node_id); +} + #[cfg(test)] mod tests { use super::*; diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 9a62e607..ab3cbf83 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -17,7 +17,7 @@ server { add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-DNS-Prefetch-Control "off" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; 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: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always; # AIUI SPA (Chat mode iframe) # Use =404 fallback instead of index.html to prevent serving HTML with wrong @@ -725,7 +725,7 @@ server { add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-DNS-Prefetch-Control "off" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; 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: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always; # AIUI SPA (Chat mode iframe) location /aiui/ { diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index d6e12d0e..c76374d5 100644 --- a/neode-ui/dev-dist/sw.js +++ b/neode-ui/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.3ur9h1c6gak" + "revision": "0.8sq55gh6vdc" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index 5aef808b..6f99db60 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -9,10 +9,12 @@ "version": "1.2.0-alpha", "dependencies": { "@types/dompurify": "^3.0.5", + "@vue-leaflet/vue-leaflet": "^0.10.1", "d3": "^7.9.0", "dompurify": "^3.3.3", "fast-json-patch": "^3.1.1", "fuse.js": "^7.1.0", + "leaflet": "^1.9.4", "pinia": "^3.0.4", "qrcode": "^1.5.4", "vue": "^3.5.24", @@ -22,6 +24,7 @@ "devDependencies": { "@playwright/test": "^1.58.2", "@types/d3": "^7.4.3", + "@types/leaflet": "^1.9.21", "@types/node": "^24.10.0", "@types/qrcode": "^1.5.6", "@vite-pwa/assets-generator": "^1.0.2", @@ -3831,9 +3834,20 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", @@ -4087,6 +4101,24 @@ "vscode-uri": "^3.0.8" } }, + "node_modules/@vue-leaflet/vue-leaflet": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@vue-leaflet/vue-leaflet/-/vue-leaflet-0.10.1.tgz", + "integrity": "sha512-RNEDk8TbnwrJl8ujdbKgZRFygLCxd0aBcWLQ05q/pGv4+d0jamE3KXQgQBqGAteE1mbQsk3xoNcqqUgaIGfWVg==", + "license": "MIT", + "dependencies": { + "vue": "^3.2.25" + }, + "peerDependencies": { + "@types/leaflet": "^1.5.7", + "leaflet": "^1.6.0" + }, + "peerDependenciesMeta": { + "@types/leaflet": { + "optional": true + } + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", @@ -8144,6 +8176,13 @@ "node": ">=0.10.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause", + "peer": true + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/neode-ui/package.json b/neode-ui/package.json index 7d579398..eab86cfb 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -24,10 +24,12 @@ }, "dependencies": { "@types/dompurify": "^3.0.5", + "@vue-leaflet/vue-leaflet": "^0.10.1", "d3": "^7.9.0", "dompurify": "^3.3.3", "fast-json-patch": "^3.1.1", "fuse.js": "^7.1.0", + "leaflet": "^1.9.4", "pinia": "^3.0.4", "qrcode": "^1.5.4", "vue": "^3.5.24", @@ -37,6 +39,7 @@ "devDependencies": { "@playwright/test": "^1.58.2", "@types/d3": "^7.4.3", + "@types/leaflet": "^1.9.21", "@types/node": "^24.10.0", "@types/qrcode": "^1.5.6", "@vite-pwa/assets-generator": "^1.0.2", diff --git a/neode-ui/src/components/MeshMap.vue b/neode-ui/src/components/MeshMap.vue new file mode 100644 index 00000000..d51d96c8 --- /dev/null +++ b/neode-ui/src/components/MeshMap.vue @@ -0,0 +1,590 @@ + + + + + + + diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 2670f983..148327b6 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -103,6 +103,13 @@ export interface BlockHeader { announced_by: string } +export interface NodePosition { + lat: number + lng: number + label?: string + timestamp: string +} + export const useMeshStore = defineStore('mesh', () => { const status = ref(null) const peers = ref([]) @@ -111,6 +118,9 @@ export const useMeshStore = defineStore('mesh', () => { const error = ref(null) const sending = ref(false) + // Node position tracking for map view (contact_id -> position) + const nodePositions = ref>(new Map()) + // Track unread message counts per peer (contact_id -> count) const unreadCounts = ref>({}) // Currently viewing chat for this contact_id (clears unread) @@ -161,11 +171,72 @@ export const useMeshStore = defineStore('mesh', () => { } } messages.value = res.messages + // Extract node positions from coordinate messages + updateNodePositionsFromMessages(res.messages) } catch (err: unknown) { error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages' } } + // Convert microdegrees (from mesh protocol) to degrees for Leaflet + // Values > 90 for lat or > 180 for lng indicate microdegrees + function toDegreesIfMicro(lat: number, lng: number): { lat: number; lng: number } { + if (Math.abs(lat) > 90 || Math.abs(lng) > 180) { + return { lat: lat / 1000000, lng: lng / 1000000 } + } + return { lat, lng } + } + + function updateNodePositionsFromMessages(msgs: MeshMessage[]) { + for (const msg of msgs) { + if (msg.message_type === 'coordinate' && msg.typed_payload) { + const payload = msg.typed_payload as CoordinateData + if (typeof payload.lat === 'number' && typeof payload.lng === 'number') { + const existing = nodePositions.value.get(msg.peer_contact_id) + if (!existing || msg.timestamp > existing.timestamp) { + const deg = toDegreesIfMicro(payload.lat, payload.lng) + nodePositions.value.set(msg.peer_contact_id, { + lat: deg.lat, + lng: deg.lng, + label: payload.label, + timestamp: msg.timestamp, + }) + } + } + } + // Also extract coordinates from alert messages that include location + if (msg.message_type === 'alert' && msg.typed_payload) { + const payload = msg.typed_payload as AlertData + if (payload.coordinate && typeof payload.coordinate.lat === 'number' && typeof payload.coordinate.lng === 'number') { + const existing = nodePositions.value.get(msg.peer_contact_id) + if (!existing || msg.timestamp > existing.timestamp) { + const deg = toDegreesIfMicro(payload.coordinate.lat, payload.coordinate.lng) + nodePositions.value.set(msg.peer_contact_id, { + lat: deg.lat, + lng: deg.lng, + label: payload.coordinate.label, + timestamp: msg.timestamp, + }) + } + } + } + } + } + + function getNodePositions(): Map { + return nodePositions.value + } + + // Update self node position from deadman GPS data (contact_id = -1 for self) + function updateSelfPosition(lat: number, lng: number, label?: string) { + nodePositions.value.set(-1, { + lat, + lng, + label: label ?? 'This Node', + timestamp: new Date().toISOString(), + }) + } + function markChatRead(contactId: number) { viewingChatId.value = contactId delete unreadCounts.value[contactId] @@ -368,6 +439,7 @@ export const useMeshStore = defineStore('mesh', () => { sending, unreadCounts, totalUnread, + nodePositions, deadmanStatus, blockHeaders, latestBlockHeight, @@ -385,6 +457,8 @@ export const useMeshStore = defineStore('mesh', () => { sendAlert, getSessionStatus, rotatePrekeys, + getNodePositions, + updateSelfPosition, fetchDeadmanStatus, configureDeadman, deadmanCheckin, diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index ab07f0ce..75553701 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -6,7 +6,7 @@