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) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-19 16:12:01 +00:00
parent 851d8001d6
commit 623c0fa954
18 changed files with 3067 additions and 174 deletions

View File

@ -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.

View File

@ -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

View File

@ -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<serde_json::Value>) -> Result<serde_json::Value> {
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<serde_json::Value> = 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<serde_json::Value> {
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
if !fleet_dir.exists() {
return Ok(serde_json::json!({ "nodes": [] }));
}
let mut nodes: Vec<serde_json::Value> = 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::<serde_json::Value>(&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<serde_json::Value>) -> Result<serde_json::Value> {
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<serde_json::Value> = 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<serde_json::Value> {
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<serde_json::Value> = 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(),
}))
}
}

View File

@ -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<serde_json::Value> = 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::*;

View File

@ -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/ {

View File

@ -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"), {

View File

@ -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",

View File

@ -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",

View File

@ -0,0 +1,590 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import type { NodePosition } from '@/stores/mesh'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
const mesh = useMeshStore()
const mapContainer = ref<HTMLElement | null>(null)
let map: L.Map | null = null
const markersLayer = ref<L.LayerGroup | null>(null)
const linesLayer = ref<L.LayerGroup | null>(null)
// Whether we have any position data to show
const hasPositions = computed(() => mesh.nodePositions.size > 0)
// Location sharing state
const sharingLocation = ref(false)
const locationSource = ref<'browser' | 'device'>('browser')
const locationError = ref('')
const hasDeviceGps = computed(() => mesh.deadmanStatus?.has_gps ?? false)
let geoWatchId: number | null = null
function toggleLocationSharing() {
if (sharingLocation.value) {
stopSharing()
} else {
startSharing()
}
}
function switchSource(source: 'browser' | 'device') {
locationSource.value = source
if (sharingLocation.value) {
stopSharing()
startSharing()
}
}
function startSharing() {
locationError.value = ''
if (locationSource.value === 'browser') {
if (!navigator.geolocation) {
locationError.value = 'Geolocation not supported'
return
}
geoWatchId = navigator.geolocation.watchPosition(
(pos) => {
mesh.updateSelfPosition(pos.coords.latitude, pos.coords.longitude, 'This Node')
sharingLocation.value = true
locationError.value = ''
},
(err) => {
locationError.value = err.code === 1 ? 'Location permission denied' : err.message
sharingLocation.value = false
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 30000 },
)
sharingLocation.value = true
} else {
// Device GPS position data comes from deadman/mesh GPS module
sharingLocation.value = true
}
}
function stopSharing() {
if (geoWatchId !== null) {
navigator.geolocation.clearWatch(geoWatchId)
geoWatchId = null
}
sharingLocation.value = false
mesh.nodePositions.delete(-1)
}
function createMarkerIcon(type: 'self' | 'online' | 'offline'): L.DivIcon {
const colorMap = {
self: { bg: '#fb923c', border: '#f59e0b', shadow: 'rgba(251,146,60,0.5)' },
online: { bg: '#4ade80', border: '#22c55e', shadow: 'rgba(74,222,128,0.4)' },
offline: { bg: '#6b7280', border: '#4b5563', shadow: 'rgba(107,114,128,0.3)' },
}
const c = colorMap[type]
const size = type === 'self' ? 16 : 12
const pulse = type === 'self'
? `<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:${size + 12}px;height:${size + 12}px;border-radius:50%;background:${c.shadow};animation:mesh-map-pulse 2s infinite;"></div>`
: ''
return L.divIcon({
className: 'mesh-map-marker-wrapper',
iconSize: [size + 12, size + 12],
iconAnchor: [(size + 12) / 2, (size + 12) / 2],
popupAnchor: [0, -(size / 2 + 6)],
html: `
${pulse}
<div style="
width:${size}px;height:${size}px;
border-radius:50%;
background:${c.bg};
border:2px solid ${c.border};
box-shadow:0 0 8px ${c.shadow};
position:absolute;top:50%;left:50%;
transform:translate(-50%,-50%);
z-index:2;
"></div>
`,
})
}
function getSignalBars(rssi: number | null): string {
if (rssi === null) return 'Unknown'
if (rssi >= -70) return 'Strong'
if (rssi >= -90) return 'Good'
if (rssi >= -110) return 'Weak'
return 'Very Weak'
}
function formatLastHeard(timestamp: string): string {
const now = Date.now()
const then = new Date(timestamp).getTime()
const diffSecs = Math.floor((now - then) / 1000)
if (diffSecs < 60) return 'Just now'
if (diffSecs < 3600) return `${Math.floor(diffSecs / 60)}m ago`
if (diffSecs < 86400) return `${Math.floor(diffSecs / 3600)}h ago`
return `${Math.floor(diffSecs / 86400)}d ago`
}
function truncatePubkey(pubkey: string | null): string {
if (!pubkey) return 'No pubkey'
if (pubkey.length <= 16) return pubkey
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
function buildPopupContent(
name: string,
pubkey: string | null,
rssi: number | null,
lastHeard: string,
hops: number,
isSelf: boolean,
): string {
const signal = getSignalBars(rssi)
const heard = formatLastHeard(lastHeard)
const truncPk = truncatePubkey(pubkey)
const selfBadge = isSelf
? '<span style="display:inline-block;background:rgba(251,146,60,0.2);color:#fb923c;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">THIS NODE</span>'
: ''
return `
<div style="font-family:'Avenir Next',sans-serif;min-width:160px;">
<div style="font-weight:600;font-size:0.9rem;color:#fff;margin-bottom:4px;">
${name}${selfBadge}
</div>
<div style="font-size:0.72rem;color:rgba(255,255,255,0.5);font-family:monospace;margin-bottom:8px;word-break:break-all;">
${truncPk}
</div>
<div style="display:flex;flex-direction:column;gap:3px;font-size:0.78rem;">
${!isSelf ? `<div style="color:rgba(255,255,255,0.7);">Signal: <span style="color:${rssi !== null && rssi >= -90 ? '#4ade80' : '#fbbf24'};">${signal}</span>${rssi !== null ? ` (${rssi} dBm)` : ''}</div>` : ''}
${!isSelf ? `<div style="color:rgba(255,255,255,0.7);">Hops: <span style="color:rgba(255,255,255,0.9);">${hops}</span></div>` : ''}
<div style="color:rgba(255,255,255,0.7);">Last heard: <span style="color:rgba(255,255,255,0.9);">${heard}</span></div>
</div>
</div>
`
}
function initMap() {
if (!mapContainer.value || map) return
const el = mapContainer.value
const rect = el.getBoundingClientRect()
// If container has no height yet, retry
if (rect.height < 10) {
setTimeout(initMap, 150)
return
}
map = L.map(el, {
zoomControl: true,
attributionControl: true,
center: [30, 0],
zoom: 3,
})
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19,
detectRetina: true,
}).addTo(map)
markersLayer.value = L.layerGroup().addTo(map)
linesLayer.value = L.layerGroup().addTo(map)
// Give Leaflet a frame to measure, then invalidate
requestAnimationFrame(() => {
if (map) {
try { map.invalidateSize() } catch { /* destroyed */ }
}
})
// Style attribution for dark theme
const attrib = el.querySelector('.leaflet-control-attribution')
if (attrib instanceof HTMLElement) {
attrib.style.background = 'rgba(0,0,0,0.6)'
attrib.style.color = 'rgba(255,255,255,0.4)'
attrib.style.fontSize = '0.65rem'
}
updateMarkers()
}
function updateMarkers() {
if (!map || !markersLayer.value || !linesLayer.value) return
markersLayer.value.clearLayers()
linesLayer.value.clearLayers()
const positions = mesh.nodePositions
if (positions.size === 0) return
const bounds: L.LatLngExpression[] = []
const selfPos = positions.get(-1)
// Find which contact_ids are in the peers list (for online status)
const peerMap = new Map(mesh.peers.map(p => [p.contact_id, p]))
positions.forEach((pos: NodePosition, contactId: number) => {
const isSelf = contactId === -1
const peer = peerMap.get(contactId)
const isOnline = isSelf || !!peer
const marker = L.marker([pos.lat, pos.lng], {
icon: createMarkerIcon(isSelf ? 'self' : isOnline ? 'online' : 'offline'),
})
const name = isSelf
? (mesh.status?.self_advert_name ?? 'This Node')
: (peer?.advert_name ?? pos.label ?? `Node ${contactId}`)
const pubkey = isSelf ? null : (peer?.pubkey_hex ?? null)
const rssi = peer?.rssi ?? null
const lastHeard = isSelf ? new Date().toISOString() : (peer?.last_heard ?? pos.timestamp)
const hops = peer?.hops ?? 0
marker.bindPopup(buildPopupContent(name, pubkey, rssi, lastHeard, hops, isSelf), {
className: 'mesh-map-popup',
closeButton: true,
maxWidth: 250,
})
markersLayer.value!.addLayer(marker)
bounds.push([pos.lat, pos.lng])
// Draw dashed line from self to each connected peer
if (!isSelf && selfPos) {
const line = L.polyline(
[[selfPos.lat, selfPos.lng], [pos.lat, pos.lng]],
{
color: isOnline ? 'rgba(74,222,128,0.4)' : 'rgba(107,114,128,0.3)',
weight: 1.5,
dashArray: '6, 8',
opacity: 0.7,
},
)
linesLayer.value!.addLayer(line)
}
})
// Fit map to show all markers
if (bounds.length > 1) {
map.fitBounds(L.latLngBounds(bounds), { padding: [40, 40], maxZoom: 14 })
} else if (bounds.length === 1 && bounds[0]) {
map.setView(bounds[0], 12)
}
}
function handleResize() {
if (map) {
map.invalidateSize()
}
}
// Watch for changes in node positions and peers
watch(
() => [mesh.nodePositions.size, mesh.peers.length],
() => {
updateMarkers()
},
)
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
window.addEventListener('resize', handleResize)
if (mapContainer.value) {
resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
const { height } = entry.contentRect
if (!map && height > 10) {
initMap()
} else if (map) {
try { map.invalidateSize() } catch { /* destroyed */ }
}
})
resizeObserver.observe(mapContainer.value)
}
// Fallback init
setTimeout(initMap, 300)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
stopSharing()
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
if (map) {
map.remove()
map = null
}
markersLayer.value = null
linesLayer.value = null
})
</script>
<template>
<div class="mesh-map-outer">
<div ref="mapContainer" class="mesh-map-inner"></div>
<!-- Floating hint when no positions -->
<div v-if="!hasPositions && !sharingLocation" class="mesh-map-hint">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span>Enable location sharing to see nodes on the map</span>
</div>
<!-- Location sharing overlay -->
<div class="mesh-map-location-bar">
<div class="mesh-map-location-row">
<svg class="mesh-map-location-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span class="mesh-map-location-label">Share Location</span>
<button
class="mesh-map-toggle"
:class="{ active: sharingLocation }"
role="switch"
:aria-checked="sharingLocation"
@click="toggleLocationSharing"
>
<span class="mesh-map-toggle-knob" />
</button>
</div>
<!-- Source selector (visible when sharing and device GPS available) -->
<div v-if="sharingLocation && hasDeviceGps" class="mesh-map-source-row">
<button
class="mesh-map-source-btn"
:class="{ active: locationSource === 'browser' }"
@click="switchSource('browser')"
>This Machine</button>
<button
class="mesh-map-source-btn"
:class="{ active: locationSource === 'device' }"
@click="switchSource('device')"
>Mesh Radio GPS</button>
</div>
<div v-if="locationError" class="mesh-map-location-error">{{ locationError }}</div>
</div>
</div>
</template>
<style>
/* Must be unscoped — Leaflet creates DOM nodes dynamically */
/* CRITICAL: Override Tailwind's img { max-width: 100% } which breaks Leaflet tiles */
.mesh-map-inner img {
max-width: none !important;
max-height: none !important;
}
.mesh-map-outer {
width: 100%;
height: 100%;
min-height: 420px;
position: relative;
overflow: hidden;
}
.mesh-map-inner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #1a1a2e;
}
/* Leaflet adds .leaflet-container to the element itself (not a child) */
.mesh-map-inner.leaflet-container {
background: #1a1a2e;
}
.mesh-map-hint {
position: absolute;
bottom: 64px;
left: 50%;
transform: translateX(-50%);
z-index: 400;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.6);
font-size: 0.78rem;
white-space: nowrap;
pointer-events: none;
}
/* ─── Location sharing overlay ─── */
.mesh-map-location-bar {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
z-index: 500;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.mesh-map-location-row {
display: flex;
align-items: center;
gap: 8px;
}
.mesh-map-location-icon {
color: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.mesh-map-location-label {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
flex: 1;
}
/* Toggle switch */
.mesh-map-toggle {
width: 36px;
height: 20px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.1);
cursor: pointer;
position: relative;
padding: 0;
flex-shrink: 0;
transition: all 0.25s ease;
}
.mesh-map-toggle.active {
background: rgba(251, 146, 60, 0.35);
border-color: rgba(251, 146, 60, 0.5);
}
.mesh-map-toggle-knob {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.6);
position: absolute;
top: 2px;
left: 2px;
transition: all 0.25s ease;
}
.mesh-map-toggle.active .mesh-map-toggle-knob {
left: 18px;
background: #fb923c;
}
/* Source selector */
.mesh-map-source-row {
display: flex;
gap: 4px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
padding: 2px;
}
.mesh-map-source-btn {
flex: 1;
padding: 4px 8px;
font-size: 0.7rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.5);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.mesh-map-source-btn:hover {
color: rgba(255, 255, 255, 0.7);
}
.mesh-map-source-btn.active {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.mesh-map-location-error {
font-size: 0.7rem;
color: #ef4444;
}
</style>
<style>
/* Global styles for Leaflet popup theming - must not be scoped */
.mesh-map-popup .leaflet-popup-content-wrapper {
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
color: #fff;
}
.mesh-map-popup .leaflet-popup-tip {
background: rgba(0, 0, 0, 0.85);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.mesh-map-popup .leaflet-popup-close-button {
color: rgba(255, 255, 255, 0.5);
font-size: 18px;
padding: 4px 8px;
}
.mesh-map-popup .leaflet-popup-close-button:hover {
color: rgba(255, 255, 255, 0.9);
}
/* Marker wrapper reset */
.mesh-map-marker-wrapper {
background: none !important;
border: none !important;
}
/* Pulse animation for self marker */
@keyframes mesh-map-pulse {
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
50% { transform: translate(-50%, -50%) scale(1.8); opacity: 0; }
100% { transform: translate(-50%, -50%) scale(1); opacity: 0; }
}
/* Dark theme for Leaflet zoom controls */
.mesh-map-inner .leaflet-control-zoom a {
background: rgba(0, 0, 0, 0.7) !important;
color: rgba(255, 255, 255, 0.8) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.mesh-map-inner .leaflet-control-zoom a:hover {
background: rgba(0, 0, 0, 0.85) !important;
color: #fff !important;
}
</style>

View File

@ -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<MeshStatus | null>(null)
const peers = ref<MeshPeer[]>([])
@ -111,6 +118,9 @@ export const useMeshStore = defineStore('mesh', () => {
const error = ref<string | null>(null)
const sending = ref(false)
// Node position tracking for map view (contact_id -> position)
const nodePositions = ref<Map<number, NodePosition>>(new Map())
// Track unread message counts per peer (contact_id -> count)
const unreadCounts = ref<Record<number, number>>({})
// 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<number, NodePosition> {
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,

View File

@ -6,7 +6,7 @@
<div class="hidden md:flex items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
</div>
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
@ -100,7 +100,8 @@
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
tabindex="0"
role="link"
class="glass-card card-stagger p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
:class="{ 'card-stagger': showStagger }"
:style="{ '--stagger-index': index }"
@click="goToApp(id as string)"
@keydown.enter="goToApp(id as string)"
@ -307,21 +308,30 @@
</div>
</template>
<script lang="ts">
// Module-level persists across component unmount/remount within same session
// Prevents stagger animation replaying every time user navigates back to Apps
let appsAnimationDone = false
</script>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
const { t } = useI18n()
import { useAppLauncherStore } from '../stores/appLauncher'
import { PackageState, type PackageDataEntry } from '../types/api'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const store = useAppStore()
// Only stagger-animate on first mount skip on revisits
const showStagger = !appsAnimationDone
// Tabs support ?tab=services from Marketplace link
const activeTab = ref<'apps' | 'services'>(
route.query.tab === 'services' ? 'services' : 'apps'
@ -406,6 +416,7 @@ const connectionError = ref('')
let connectionTimer: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
appsAnimationDone = true
if (!store.isConnected) {
connectionTimer = setTimeout(() => {
if (!store.isConnected && sortedPackageEntries.value.length === 0) {

View File

@ -197,9 +197,10 @@
to="/dashboard/apps"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' }"
@click.prevent="router.push({ path: '/dashboard/apps', query: {} })"
>My Apps</RouterLink>
<RouterLink
to="/dashboard/marketplace"
to="/dashboard/discover"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/') || route.path === '/dashboard/discover' }"
>App Store</RouterLink>
@ -302,7 +303,7 @@
:class="{
'nav-tab-active': item.isCombined
? (item.path === '/dashboard/apps'
? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/app-session'))
? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover') || route.path.includes('/app-session'))
: (route.path.includes('/cloud') || route.path.includes('/server')))
: undefined
}"
@ -428,6 +429,7 @@ const ROUTE_BACKGROUNDS: Record<string, string> = {
'/dashboard': 'bg-home.jpg',
'/dashboard/': 'bg-home.jpg',
'/dashboard/apps': 'bg-myapps.jpg',
'/dashboard/discover': 'bg-appstore.jpg',
'/dashboard/marketplace': 'bg-appstore.jpg',
'/dashboard/cloud': 'bg-cloud.jpg',
'/dashboard/mesh': 'bg-mesh.jpg',
@ -449,6 +451,7 @@ const isDarkRoute = computed(() => {
p.includes('/dashboard/settings') ||
(p.includes('/dashboard/apps') && !isDetailRoute(p)) ||
p.includes('/dashboard/marketplace') ||
p.includes('/dashboard/discover') ||
p.includes('/dashboard/cloud')
})

View File

@ -6,13 +6,14 @@
<div class="hidden md:flex mb-6 items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
</div>
<div class="mode-switcher flex-shrink-0">
<RouterLink
to="/dashboard/discover"
class="mode-switcher-btn mode-switcher-btn-active"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
>Discover</RouterLink>
<button
v-for="category in categoriesWithApps"
@ -670,9 +671,6 @@ const categoriesWithApps = computed(() => {
})
})
const currentCategoryName = computed(() => {
return categories.value.find(c => c.id === selectedCategory.value)?.name || 'All'
})
const filteredApps = computed(() => {
let apps = allApps.value
@ -727,7 +725,7 @@ const featuredApps = computed<FeaturedApp[]>(() => {
.map(f => {
const app = allApps.value.find(a => a.id === f.id)
if (!app) return null
return { ...app, featuredDescription: f.desc, privacyTag: f.tag }
return { ...app, featuredDescription: f.desc, privacyTag: f.tag } as FeaturedApp
})
.filter((a): a is FeaturedApp => a !== null)
})

View File

@ -0,0 +1,733 @@
<template>
<div class="pb-6 mobile-scroll-pad">
<!-- Header -->
<div class="hidden md:block mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Fleet Dashboard</h1>
<p class="text-white/70">Beta Telemetry monitoring {{ nodes.length }} node{{ nodes.length !== 1 ? 's' : '' }}</p>
</div>
<div class="flex gap-2 items-center">
<span v-if="autoRefresh" class="text-xs text-white/40">Auto-refresh 60s</span>
<button class="glass-button text-sm px-4 py-2" @click="toggleAutoRefresh">
{{ autoRefresh ? 'Pause' : 'Resume' }}
</button>
<button class="glass-button text-sm px-4 py-2" @click="refreshAll">
Refresh
</button>
<button class="glass-button text-sm px-4 py-2" @click="exportFleetData">
Export JSON
</button>
</div>
</div>
</div>
<!-- Mobile Header -->
<div class="md:hidden mb-6">
<h1 class="text-2xl font-bold text-white mb-1">Fleet Dashboard</h1>
<p class="text-white/60 text-sm mb-3">Monitoring {{ nodes.length }} node{{ nodes.length !== 1 ? 's' : '' }}</p>
<div class="flex gap-2">
<button class="glass-button text-xs px-3 py-2 flex-1" @click="refreshAll">Refresh</button>
<button class="glass-button text-xs px-3 py-2 flex-1" @click="exportFleetData">Export</button>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="text-white/50 text-sm">Loading fleet data...</div>
</div>
<!-- Error State -->
<div v-else-if="errorMessage" class="glass-card p-6 mb-6">
<div class="alert-error rounded-lg mb-4">{{ errorMessage }}</div>
<button class="glass-button text-sm px-4 py-2" @click="refreshAll">Retry</button>
</div>
<!-- Dashboard Content -->
<template v-else>
<!-- Section 1: Fleet Overview Cards -->
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Total Nodes</p>
<p class="text-2xl font-bold text-white">{{ nodes.length }}</p>
<p class="text-xs text-white/40">
<span class="fleet-dot-online"></span> {{ onlineCount }} online
<span class="ml-1 fleet-dot-offline"></span> {{ offlineCount }} offline
</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Fleet Health</p>
<p class="text-2xl font-bold text-white">{{ fleetHealthPct }}%</p>
<p class="text-xs text-white/40">{{ healthyCount }}/{{ nodes.length }} no alerts</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Avg CPU</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgCpu)">{{ avgCpu.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Avg RAM</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgMem)">{{ avgMem.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Avg Disk</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgDisk)">{{ avgDisk.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p>
</div>
</div>
<!-- Section 2: Node Grid -->
<div class="glass-card p-5 mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-white/80">Nodes</h3>
<div class="flex gap-2">
<button
v-for="opt in sortOptions"
:key="opt.value"
class="fleet-sort-btn"
:class="{ 'fleet-sort-btn-active': sortBy === opt.value }"
@click="sortBy = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- Empty State -->
<div v-if="!nodes.length" class="text-white/40 text-sm py-8 text-center">
No nodes reporting. Ensure telemetry is enabled on beta nodes.
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div
v-for="node in sortedNodes"
:key="node.node_id"
class="fleet-node-card"
:class="{ 'fleet-node-card-selected': selectedNodeId === node.node_id }"
@click="selectNode(node.node_id)"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span
class="fleet-status-dot"
:class="isOnline(node.reported_at) ? 'fleet-dot-online' : 'fleet-dot-offline'"
></span>
<span class="text-sm font-mono text-white">{{ node.node_id.slice(0, 8) }}</span>
</div>
<span class="fleet-version-badge">v{{ node.version }}</span>
</div>
<div class="space-y-2 mb-3">
<div class="fleet-metric-row">
<span class="text-xs text-white/50">CPU</span>
<div class="fleet-bar-track">
<div
class="fleet-bar-fill"
:class="healthBarClass(node.cpu_pct)"
:style="{ width: Math.min(node.cpu_pct, 100) + '%' }"
></div>
</div>
<span class="text-xs text-white/60 w-10 text-right">{{ node.cpu_pct.toFixed(0) }}%</span>
</div>
<div class="fleet-metric-row">
<span class="text-xs text-white/50">RAM</span>
<div class="fleet-bar-track">
<div
class="fleet-bar-fill"
:class="healthBarClass(node.mem_pct)"
:style="{ width: Math.min(node.mem_pct, 100) + '%' }"
></div>
</div>
<span class="text-xs text-white/60 w-10 text-right">{{ node.mem_pct.toFixed(0) }}%</span>
</div>
<div class="fleet-metric-row">
<span class="text-xs text-white/50">Disk</span>
<div class="fleet-bar-track">
<div
class="fleet-bar-fill"
:class="healthBarClass(node.disk_pct)"
:style="{ width: Math.min(node.disk_pct, 100) + '%' }"
></div>
</div>
<span class="text-xs text-white/60 w-10 text-right">{{ node.disk_pct.toFixed(0) }}%</span>
</div>
</div>
<div class="flex items-center justify-between text-xs text-white/40">
<span>{{ node.running_count }}/{{ node.container_count }} containers</span>
<span>{{ node.federation_peers }} peers</span>
</div>
<div class="flex items-center justify-between text-xs text-white/40 mt-1">
<span>Up {{ formatUptime(node.uptime_secs) }}</span>
<span>{{ timeAgo(node.reported_at) }}</span>
</div>
</div>
</div>
</div>
<!-- Section 3: Fleet Alerts Timeline -->
<div class="glass-card p-5 mb-6">
<h3 class="text-sm font-medium text-white/80 mb-4">Fleet Alerts</h3>
<div v-if="alertsLoading" class="text-white/40 text-sm py-4 text-center">
Loading alerts...
</div>
<div v-else-if="!fleetAlerts.length" class="text-white/40 text-sm py-4 text-center">
No alerts across the fleet.
</div>
<div v-else class="space-y-2 max-h-80 overflow-y-auto">
<div
v-for="(alert, idx) in fleetAlerts.slice(0, 50)"
:key="idx"
class="flex items-start gap-3 p-3 bg-white/5 rounded-lg"
>
<span
class="inline-block w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
:class="alertSeverityDot(alert.rule)"
></span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<span class="fleet-node-badge">{{ alert.node_id.slice(0, 8) }}</span>
<span class="text-xs text-white/40">{{ alertTypeLabel(alert.rule) }}</span>
</div>
<p class="text-sm text-white/80">{{ alert.message }}</p>
<p class="text-xs text-white/30 mt-0.5">{{ formatTimestamp(alert.timestamp) }}</p>
</div>
</div>
</div>
</div>
<!-- Section 4: Node Detail (expanded view) -->
<div v-if="selectedNodeId && selectedNode" class="glass-card p-5 mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-white/80">
Node Detail <span class="font-mono">{{ selectedNodeId.slice(0, 8) }}</span>
</h3>
<button class="glass-button text-xs px-3 py-1" @click="selectedNodeId = null">Close</button>
</div>
<!-- Node Info Summary -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Version</p>
<p class="text-lg font-bold text-white">v{{ selectedNode.version }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Uptime</p>
<p class="text-lg font-bold text-white">{{ formatUptime(selectedNode.uptime_secs) }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">CPU Cores</p>
<p class="text-lg font-bold text-white">{{ selectedNode.cpu_cores }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Federation Peers</p>
<p class="text-lg font-bold text-white">{{ selectedNode.federation_peers }}</p>
</div>
</div>
<!-- History Charts -->
<div v-if="nodeHistoryLoading" class="text-white/40 text-sm py-4 text-center mb-4">
Loading history...
</div>
<div v-else-if="nodeHistory.length" class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div class="glass-card p-4">
<h4 class="text-xs font-medium text-white/60 mb-2">CPU History</h4>
<LineChart
:datasets="nodeHistoryCpuDatasets"
:labels="nodeHistoryLabels"
:width="chartWidth"
:height="160"
:y-max="100"
/>
</div>
<div class="glass-card p-4">
<h4 class="text-xs font-medium text-white/60 mb-2">RAM History</h4>
<LineChart
:datasets="nodeHistoryMemDatasets"
:labels="nodeHistoryLabels"
:width="chartWidth"
:height="160"
:y-max="100"
/>
</div>
<div class="glass-card p-4">
<h4 class="text-xs font-medium text-white/60 mb-2">Disk History</h4>
<LineChart
:datasets="nodeHistoryDiskDatasets"
:labels="nodeHistoryLabels"
:width="chartWidth"
:height="160"
:y-max="100"
/>
</div>
</div>
<!-- Container List -->
<div class="mb-4">
<h4 class="text-xs font-medium text-white/60 mb-2 uppercase tracking-wide">Containers</h4>
<div v-if="!selectedNode.containers.length" class="text-white/40 text-sm py-2">
No containers reported.
</div>
<div v-else class="space-y-1">
<div
v-for="c in selectedNode.containers"
:key="c.id"
class="flex items-center gap-3 p-2 bg-white/5 rounded-lg"
>
<span
class="inline-block w-2 h-2 rounded-full flex-shrink-0"
:class="c.state === 'running' ? 'bg-green-400' : 'bg-red-400'"
></span>
<span class="text-sm text-white flex-1 truncate">{{ c.id }}</span>
<span class="text-xs text-white/40">{{ c.state }}</span>
<span class="text-xs text-white/30">{{ c.version }}</span>
</div>
</div>
</div>
<!-- Node Alerts -->
<div>
<h4 class="text-xs font-medium text-white/60 mb-2 uppercase tracking-wide">Recent Alerts</h4>
<div v-if="!selectedNode.recent_alerts.length" class="text-white/40 text-sm py-2">
No recent alerts for this node.
</div>
<div v-else class="space-y-1">
<div
v-for="(alert, idx) in selectedNode.recent_alerts"
:key="idx"
class="flex items-start gap-3 p-2 bg-white/5 rounded-lg"
>
<span
class="inline-block w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
:class="alertSeverityDot(alert.rule)"
></span>
<div class="flex-1 min-w-0">
<p class="text-sm text-white/80">{{ alert.message }}</p>
<p class="text-xs text-white/30">{{ formatTimestamp(alert.timestamp) }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Section 5: Container Matrix -->
<div class="glass-card p-5">
<h3 class="text-sm font-medium text-white/80 mb-4">Container Matrix</h3>
<div v-if="!nodes.length" class="text-white/40 text-sm py-4 text-center">
No nodes to display.
</div>
<div v-else class="overflow-x-auto">
<table class="fleet-matrix-table">
<thead>
<tr>
<th class="fleet-matrix-header-cell">App</th>
<th
v-for="node in sortedNodes"
:key="node.node_id"
class="fleet-matrix-header-cell font-mono"
>
{{ node.node_id.slice(0, 6) }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="app in allAppIds" :key="app">
<td class="fleet-matrix-cell text-white/70">{{ app }}</td>
<td
v-for="node in sortedNodes"
:key="node.node_id"
class="fleet-matrix-cell text-center"
>
<span v-if="getContainerState(node, app) === 'running'" class="text-green-400">&#10003;</span>
<span v-else-if="getContainerState(node, app) === 'stopped'" class="text-red-400">&#10007;</span>
<span v-else class="text-white/20">&mdash;</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="text-xs text-white/30 mt-4 text-center">
{{ autoRefresh ? 'Auto-refreshing every 60s' : 'Auto-refresh paused' }}
&middot; Last updated {{ lastRefreshed ? timeAgo(lastRefreshed) : 'never' }}
</p>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import LineChart from '@/components/LineChart.vue'
import type { ChartDataset } from '@/components/LineChart.vue'
// --- Types ---
interface FleetNode {
node_id: string
version: string
uptime_secs: number
cpu_cores: number
cpu_pct: number
mem_pct: number
disk_pct: number
container_count: number
running_count: number
federation_peers: number
recent_alerts: Array<{ rule: string; message: string; timestamp: string }>
containers: Array<{ id: string; state: string; version: string }>
reported_at: string
}
interface FleetAlert {
node_id: string
rule: string
message: string
timestamp: string
}
interface NodeHistoryEntry {
timestamp: string
cpu_pct: number
mem_pct: number
disk_pct: number
}
type SortOption = 'status' | 'last-seen' | 'name'
// --- State ---
const loading = ref(true)
const errorMessage = ref('')
const nodes = ref<FleetNode[]>([])
const fleetAlerts = ref<FleetAlert[]>([])
const alertsLoading = ref(false)
const selectedNodeId = ref<string | null>(null)
const nodeHistory = ref<NodeHistoryEntry[]>([])
const nodeHistoryLoading = ref(false)
const autoRefresh = ref(true)
const lastRefreshed = ref('')
const sortBy = ref<SortOption>('status')
const chartWidth = ref(300)
let pollTimer: ReturnType<typeof setInterval> | null = null
const sortOptions: Array<{ label: string; value: SortOption }> = [
{ label: 'Status', value: 'status' },
{ label: 'Last Seen', value: 'last-seen' },
{ label: 'Name', value: 'name' },
]
// --- Computed ---
const onlineCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at)).length)
const offlineCount = computed(() => nodes.value.length - onlineCount.value)
const healthyCount = computed(() => nodes.value.filter(n => n.recent_alerts.length === 0).length)
const fleetHealthPct = computed(() => {
if (!nodes.value.length) return 0
return Math.round((healthyCount.value / nodes.value.length) * 100)
})
const avgCpu = computed(() => {
if (!nodes.value.length) return 0
return nodes.value.reduce((sum, n) => sum + n.cpu_pct, 0) / nodes.value.length
})
const avgMem = computed(() => {
if (!nodes.value.length) return 0
return nodes.value.reduce((sum, n) => sum + n.mem_pct, 0) / nodes.value.length
})
const avgDisk = computed(() => {
if (!nodes.value.length) return 0
return nodes.value.reduce((sum, n) => sum + n.disk_pct, 0) / nodes.value.length
})
const selectedNode = computed(() => {
if (!selectedNodeId.value) return null
return nodes.value.find(n => n.node_id === selectedNodeId.value) ?? null
})
const sortedNodes = computed(() => {
const sorted = [...nodes.value]
switch (sortBy.value) {
case 'status':
// Offline first, then by last seen descending
sorted.sort((a, b) => {
const aOnline = isOnline(a.reported_at)
const bOnline = isOnline(b.reported_at)
if (aOnline !== bOnline) return aOnline ? 1 : -1
return new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime()
})
break
case 'last-seen':
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
break
case 'name':
sorted.sort((a, b) => a.node_id.localeCompare(b.node_id))
break
}
return sorted
})
const allAppIds = computed(() => {
const appSet = new Set<string>()
for (const node of nodes.value) {
for (const c of node.containers) {
appSet.add(c.id)
}
}
return Array.from(appSet).sort()
})
// Node history chart datasets
const nodeHistoryLabels = computed(() => {
return nodeHistory.value.map(h => {
const d = new Date(h.timestamp)
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
})
})
const nodeHistoryCpuDatasets = computed<ChartDataset[]>(() => [{
label: 'CPU',
data: nodeHistory.value.map(h => h.cpu_pct),
color: '#fb923c',
}])
const nodeHistoryMemDatasets = computed<ChartDataset[]>(() => [{
label: 'RAM',
data: nodeHistory.value.map(h => h.mem_pct),
color: '#3b82f6',
}])
const nodeHistoryDiskDatasets = computed<ChartDataset[]>(() => [{
label: 'Disk',
data: nodeHistory.value.map(h => h.disk_pct),
color: '#a78bfa',
}])
// --- Utility Functions ---
function formatUptime(secs: number): string {
if (secs < 60) return `${secs}s`
const days = Math.floor(secs / 86400)
const hours = Math.floor((secs % 86400) / 3600)
const mins = Math.floor((secs % 3600) / 60)
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${mins}m`
return `${mins}m`
}
function timeAgo(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diffMs = now - then
if (diffMs < 0) return 'just now'
const diffSecs = Math.floor(diffMs / 1000)
if (diffSecs < 60) return `${diffSecs}s ago`
const diffMins = Math.floor(diffSecs / 60)
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
return `${diffDays}d ago`
}
function isOnline(reportedAt: string): boolean {
const thirtyMinMs = 30 * 60 * 1000
return Date.now() - new Date(reportedAt).getTime() < thirtyMinMs
}
function healthBarClass(pct: number): string {
if (pct >= 85) return 'monitoring-bar-danger'
if (pct >= 60) return 'monitoring-bar-warn'
return 'monitoring-bar-ok'
}
function healthTextClass(pct: number): string {
if (pct >= 85) return 'fleet-text-danger'
if (pct >= 60) return 'fleet-text-warn'
return ''
}
function alertSeverityDot(rule: string): string {
const critical = ['container_crash', 'disk_critical', 'node_offline']
if (critical.includes(rule)) return 'bg-red-400'
return 'bg-orange-400'
}
function alertTypeLabel(rule: string): string {
const labels: Record<string, string> = {
container_crash: 'Container Crash',
disk_critical: 'Disk Critical',
disk_warning: 'Disk Warning',
ram_high: 'High RAM',
cpu_high: 'High CPU',
node_offline: 'Node Offline',
version_mismatch: 'Version Mismatch',
}
return labels[rule] ?? rule
}
function formatTimestamp(ts: string): string {
const d = new Date(ts)
return d.toLocaleString()
}
function getContainerState(node: FleetNode, appId: string): string | null {
const container = node.containers.find(c => c.id === appId)
if (!container) return null
return container.state
}
// --- Data Fetching ---
async function fetchFleetStatus() {
try {
const data = await rpcClient.call<{ nodes: FleetNode[] }>({
method: 'telemetry.fleet-status',
})
if (data?.nodes) {
nodes.value = data.nodes
lastRefreshed.value = new Date().toISOString()
}
} catch (err) {
if (loading.value) {
errorMessage.value = err instanceof Error ? err.message : 'Failed to load fleet data'
}
}
}
async function fetchFleetAlerts() {
alertsLoading.value = true
try {
const data = await rpcClient.call<{ alerts: FleetAlert[] }>({
method: 'telemetry.fleet-alerts',
})
if (data?.alerts) {
fleetAlerts.value = data.alerts
}
} catch {
// Non-critical, retry on next poll
} finally {
alertsLoading.value = false
}
}
async function fetchNodeHistory(nodeId: string) {
nodeHistoryLoading.value = true
nodeHistory.value = []
try {
const data = await rpcClient.call<{ history: NodeHistoryEntry[] }>({
method: 'telemetry.fleet-node-history',
params: { node_id: nodeId },
})
if (data?.history) {
nodeHistory.value = data.history
}
} catch {
// Non-critical
} finally {
nodeHistoryLoading.value = false
}
}
async function refreshAll() {
loading.value = !nodes.value.length
errorMessage.value = ''
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
loading.value = false
}
function selectNode(nodeId: string) {
if (selectedNodeId.value === nodeId) {
selectedNodeId.value = null
nodeHistory.value = []
} else {
selectedNodeId.value = nodeId
fetchNodeHistory(nodeId)
}
}
function toggleAutoRefresh() {
autoRefresh.value = !autoRefresh.value
if (autoRefresh.value) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
}
function startAutoRefresh() {
stopAutoRefresh()
pollTimer = setInterval(async () => {
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
// Refresh selected node history if one is selected
if (selectedNodeId.value) {
await fetchNodeHistory(selectedNodeId.value)
}
}, 60000)
}
function stopAutoRefresh() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function exportFleetData() {
const exportData = {
exported_at: new Date().toISOString(),
nodes: nodes.value,
alerts: fleetAlerts.value,
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `fleet-telemetry-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
}
function updateChartWidth() {
const container = document.querySelector('.glass-card')
if (container) {
// For 3-column layout, approximate each chart container width
const cardWidth = container.clientWidth
chartWidth.value = Math.max(Math.floor((cardWidth - 80) / 3), 200)
}
}
// Fetch node history when selection changes
watch(selectedNodeId, (newId) => {
if (newId) {
fetchNodeHistory(newId)
} else {
nodeHistory.value = []
}
})
// --- Lifecycle ---
onMounted(async () => {
updateChartWidth()
window.addEventListener('resize', updateChartWidth)
await refreshAll()
if (autoRefresh.value) {
startAutoRefresh()
}
})
onUnmounted(() => {
stopAutoRefresh()
window.removeEventListener('resize', updateChartWidth)
})
</script>

View File

@ -78,13 +78,14 @@
<div class="hidden md:flex mb-4 items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
</div>
<div class="mode-switcher flex-shrink-0">
<RouterLink
to="/dashboard/discover"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
>Discover</RouterLink>
<button
v-for="category in categoriesWithApps"

View File

@ -5,6 +5,7 @@ import { useTransportStore } from '@/stores/transport'
import type { MeshPeer, SessionStatus } from '@/stores/mesh'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import MeshMap from '@/components/MeshMap.vue'
import { rpcClient } from '@/api/rpc-client'
const mesh = useMeshStore()
@ -38,7 +39,7 @@ const togglingOffGrid = ref(false)
const peerSessionInfo = ref<SessionStatus | null>(null)
// Phase 4: Off-grid Bitcoin + Dead Man's Switch
const activeTab = ref<'chat' | 'bitcoin' | 'deadman'>('chat')
const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'map'>('chat')
const txHexInput = ref('')
const bolt11Input = ref('')
const bolt11AmountInput = ref('')
@ -55,7 +56,7 @@ const deadmanEnabled = ref(false)
const deadmanCustomMsg = ref('')
// Tools tab for 3rd column on wide desktop and mobile below-chat
const toolsTab = ref<'bitcoin' | 'deadman'>('bitcoin')
const toolsTab = ref<'bitcoin' | 'deadman' | 'map'>('bitcoin')
// Panel visibility computeds
const showChatPanel = computed(() =>
@ -70,6 +71,10 @@ const showDeadmanPanel = computed(() => {
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'deadman'
return activeTab.value === 'deadman'
})
const showMapPanel = computed(() => {
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'map'
return activeTab.value === 'map'
})
// 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)
@ -548,6 +553,10 @@ function truncatePubkey(hex: string | null): string {
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
<button class="mesh-tab" :class="{ active: activeTab === 'map' }" @click="activeTab = 'map'">
Map
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
</button>
</div>
<!-- Chat Panel (before tools so it gets grid-column 2 on wide) -->
@ -673,8 +682,15 @@ function truncatePubkey(hex: string | null): string {
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">
Map
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
</button>
</div>
<!-- Map Panel -->
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
<!-- Off-Grid Bitcoin Panel -->
<div v-if="showBitcoinPanel" class="glass-card mesh-bitcoin-panel">
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
@ -839,7 +855,12 @@ function truncatePubkey(hex: string | null): string {
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">
Map
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
</button>
</div>
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></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>
@ -1646,6 +1667,10 @@ function truncatePubkey(hex: string | null): string {
min-height: 320px;
}
.mesh-mobile-tools .mesh-map-panel {
min-height: 400px;
}
.mesh-status-grid {
grid-template-columns: repeat(2, 1fr);
}
@ -1878,6 +1903,16 @@ function truncatePubkey(hex: string | null): string {
.mesh-relay-result.success { background: rgba(74,222,128,0.1); color: #4ade80; border: 1px solid rgba(74,222,128,0.2); }
.mesh-relay-result.error { background: rgba(239,68,68,0.1); color: #ef4444; border: 1px solid rgba(239,68,68,0.2); }
/* ─── Map panel ─── */
.mesh-map-panel {
flex: 1;
min-height: 400px;
padding: 0 !important; /* Override glass-card padding */
overflow: hidden;
border-radius: 12px;
position: relative;
}
/* ─── Dead Man panel ─── */
.mesh-deadman-panel {
display: flex;

View File

@ -5,7 +5,7 @@
<div class="glass-card p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6 gap-4 stagger-grid">
<!-- Networking Profits -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<span class="text-2xl text-orange-500 font-bold"></span>
@ -22,7 +22,7 @@
</div>
<!-- DID Status -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="didStatus === 'active' ? 'bg-green-400' : 'bg-yellow-400'"></div>
@ -59,7 +59,7 @@
</div>
<!-- did:dht Status -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1.5">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1.5">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="dhtDid ? 'bg-blue-400' : 'bg-gray-500'"></div>
@ -96,7 +96,7 @@
</div>
<!-- Wallet Connection -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 2">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 2">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="walletConnected ? 'bg-green-400' : 'bg-red-400'"></div>
@ -117,7 +117,7 @@
</div>
<!-- Nostr Relay Status -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 3">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 3">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="(nostrRelayStats?.connected_count ?? 0) > 0 ? 'bg-green-400' : 'bg-red-400'"></div>
@ -137,7 +137,7 @@
</div>
<!-- Connected Nodes -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 4">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 4">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="connectedNodesCount > 0 ? 'bg-green-400' : 'bg-amber-400'"></div>
@ -268,7 +268,7 @@
<!-- Core Services Overview Cards Row 1 -->
<div class="flex flex-col md:flex-row gap-6 mb-6">
<!-- Bitcoin Domain Name Portfolio -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 0">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col md:w-1/2" style="--stagger-index: 0">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -321,7 +321,7 @@
</div>
<!-- Wallet -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 1">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col md:w-1/2" style="--stagger-index: 1">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -470,7 +470,7 @@
<!-- Core Services Overview Cards Row 2 -->
<div class="flex flex-col md:flex-row gap-6 mb-8">
<!-- Nostr Relays -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 2">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col md:w-1/2" style="--stagger-index: 2">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -523,7 +523,7 @@
</div>
<!-- Node Visibility -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 3">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col md:w-1/2" style="--stagger-index: 3">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -909,7 +909,7 @@
<div
v-for="(item, idx) in contentItems"
:key="item.id"
class="card-stagger p-4 bg-white/5 rounded-lg"
:class="{ 'card-stagger': showStagger }" class="p-4 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<div class="flex items-start justify-between gap-3 mb-3">
@ -999,7 +999,7 @@
<div
v-for="(pItem, idx) in peerContentItems"
:key="pItem.id"
class="card-stagger flex items-center gap-4 p-3 bg-white/5 rounded-lg"
:class="{ 'card-stagger': showStagger }" class="flex items-center gap-4 p-3 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<!-- Media type icon -->
@ -1237,7 +1237,7 @@
<div
v-for="(identity, idx) in managedIdentities"
:key="identity.id"
class="card-stagger flex items-center gap-4 p-4 bg-white/5 rounded-lg"
:class="{ 'card-stagger': showStagger }" class="flex items-center gap-4 p-4 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<!-- Avatar (clickable to edit profile) -->
@ -2127,6 +2127,10 @@
</div>
</template>
<script lang="ts">
let web5AnimationDone = false
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@ -2141,6 +2145,8 @@ import { useTransportStore } from '@/stores/transport'
import { useMeshStore } from '@/stores/mesh'
const route = useRoute()
const showStagger = !web5AnimationDone
const router = useRouter()
const { t } = useI18n()
const messageToast = useMessageToast()
@ -3761,6 +3767,7 @@ async function deleteIdentity() {
}
onMounted(() => {
web5AnimationDone = true
loadPeers()
loadReceivedMessages()
loadIdentities()

File diff suppressed because it is too large Load Diff