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:
parent
851d8001d6
commit
623c0fa954
@ -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.
|
||||
|
||||
119
.claude/plans/tailscale-migration.md
Normal file
119
.claude/plans/tailscale-migration.md
Normal 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
|
||||
@ -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(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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::*;
|
||||
|
||||
@ -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/ {
|
||||
|
||||
@ -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"), {
|
||||
|
||||
41
neode-ui/package-lock.json
generated
41
neode-ui/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
590
neode-ui/src/components/MeshMap.vue
Normal file
590
neode-ui/src/components/MeshMap.vue
Normal 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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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>
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
733
neode-ui/src/views/Fleet.vue
Normal file
733
neode-ui/src/views/Fleet.vue
Normal 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">✓</span>
|
||||
<span v-else-if="getContainerState(node, app) === 'stopped'" class="text-red-400">✗</span>
|
||||
<span v-else class="text-white/20">—</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' }}
|
||||
· 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>
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user