Compare commits
No commits in common. "main" and "public-prelaunch" have entirely different histories.
main
...
public-pre
@ -465,7 +465,6 @@ impl RpcHandler {
|
||||
signing_key.as_ref().map(|i| i.signing_key()),
|
||||
Some(&peer.pubkey),
|
||||
data.server_info.name.as_deref(),
|
||||
Some(&self.config.data_dir),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@ -279,7 +279,6 @@ impl RpcHandler {
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.fips_timeout(std::time::Duration::from_secs(8))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
@ -365,11 +364,6 @@ impl RpcHandler {
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
// The Cloud page's hottest call: without a fast-fail cap a
|
||||
// cold FIPS path burned ~16.6s before Tor even started,
|
||||
// against the UI's 30s deadline — users saw errors, not
|
||||
// fallback.
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
@ -1143,7 +1137,6 @@ impl RpcHandler {
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer for preview")?;
|
||||
|
||||
@ -865,9 +865,7 @@ impl RpcHandler {
|
||||
"/rpc/v1",
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::Peers)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.record_transport(&self.config.data_dir);
|
||||
.timeout(std::time::Duration::from_secs(30));
|
||||
|
||||
match req.send_json(&body).await {
|
||||
Ok((resp, transport)) if resp.status().is_success() => {
|
||||
|
||||
@ -13,14 +13,7 @@ use anyhow::Result;
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
let mut v = serde_json::to_value(status)?;
|
||||
// Dial outcome counters (process-lifetime): how often peer dials
|
||||
// used FIPS vs fell back to Tor, broken down by reason. This is
|
||||
// the observability that makes "FIPS uptime" measurable.
|
||||
if let Some(obj) = v.as_object_mut() {
|
||||
obj.insert("dial_stats".to_string(), fips::telemetry::snapshot());
|
||||
}
|
||||
Ok(v)
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// Everything the companion app needs to join this node's mesh, embedded
|
||||
|
||||
@ -820,8 +820,6 @@ impl RpcHandler {
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
|
||||
.service(crate::settings::transport::PeerService::MeshFileSharing)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.fips_timeout(std::time::Duration::from_secs(8))
|
||||
.record_transport(&self.config.data_dir)
|
||||
.send_get()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;
|
||||
|
||||
@ -137,7 +137,6 @@ impl RpcHandler {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&self.config.data_dir),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@ -226,7 +225,6 @@ impl RpcHandler {
|
||||
signing_key.as_ref().map(|i| i.signing_key()),
|
||||
Some(&req.from_pubkey),
|
||||
data.server_info.name.as_deref(),
|
||||
Some(&self.config.data_dir),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@ -133,7 +133,6 @@ impl RpcHandler {
|
||||
Some(node_id.signing_key()),
|
||||
recipient_pubkey.as_deref(),
|
||||
node_name.as_deref(),
|
||||
Some(&self.config.data_dir),
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
||||
|
||||
@ -498,9 +498,7 @@ pub(super) async fn notify_federation_peers_address_change(
|
||||
"/rpc/v1",
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::Peers)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.record_transport(data_dir);
|
||||
.timeout(std::time::Duration::from_secs(30));
|
||||
match req.send_json(&payload).await {
|
||||
Ok((_, transport)) => {
|
||||
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
|
||||
|
||||
@ -22,10 +22,8 @@
|
||||
//! single declarative call.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
use tokio::fs;
|
||||
use tokio::process::Command;
|
||||
use tracing::{info, warn};
|
||||
@ -37,15 +35,6 @@ const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
||||
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
||||
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// After a failed repair (image build/pull included), leave the companion
|
||||
/// alone for this long. Without it, a node under IO pressure retried a 900s
|
||||
/// image build every 30s reconcile tick — each build pegging the disk that
|
||||
/// made the probes fail in the first place (live-diagnosed on zaza-optiplex
|
||||
/// 2026-07-28: load 50, podman scans starved, apps page stuck).
|
||||
const REPAIR_COOLDOWN: Duration = Duration::from_secs(600);
|
||||
|
||||
static REPAIR_FAILED_AT: LazyLock<Mutex<HashMap<&'static str, Instant>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Static description of one companion. The full list per backend
|
||||
/// app_id lives in `companions_for`.
|
||||
@ -475,28 +464,12 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
|
||||
match needs_repair(spec).await {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
if let Some(failed_at) =
|
||||
REPAIR_FAILED_AT.lock().unwrap().get(spec.name).copied()
|
||||
{
|
||||
if failed_at.elapsed() < REPAIR_COOLDOWN {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
info!(
|
||||
companion = spec.name,
|
||||
"reconcile: companion not active, repairing"
|
||||
);
|
||||
match install_one(spec).await {
|
||||
Ok(()) => {
|
||||
REPAIR_FAILED_AT.lock().unwrap().remove(spec.name);
|
||||
}
|
||||
Err(e) => {
|
||||
REPAIR_FAILED_AT
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(spec.name, Instant::now());
|
||||
failures.push((spec.name.to_string(), e));
|
||||
}
|
||||
if let Err(e) = install_one(spec).await {
|
||||
failures.push((spec.name.to_string(), e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@ -511,58 +484,19 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
|
||||
|
||||
/// Does this companion need install_one to be re-run? Returns true if
|
||||
/// the unit file is missing, stale, or the service is not active.
|
||||
///
|
||||
/// This probe runs every reconcile tick for every companion, so it must be
|
||||
/// PASSIVE: no image builds, no pulls. It used to call ensure_image_present
|
||||
/// to render the expected unit — under IO pressure the image-existence check
|
||||
/// inside timed out, read as "image missing", and a 900s `podman build` ran
|
||||
/// inside the probe even though the companion was up (the .198 load spiral).
|
||||
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||
let dir = quadlet::unit_dir().await?;
|
||||
let unit_path = dir.join(format!("{}.container", spec.name));
|
||||
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
|
||||
return Ok(true);
|
||||
}
|
||||
let svc = format!("{}.service", spec.name);
|
||||
// A hung `systemctl is-active` under IO pressure must not read as
|
||||
// "companion dead" — that's a repair (and possibly an image build) fired
|
||||
// off exactly when the node can least afford one.
|
||||
match tokio::time::timeout(Duration::from_secs(10), quadlet::is_active(&svc)).await {
|
||||
Ok(active) => {
|
||||
if !active {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
companion = spec.name,
|
||||
"is-active probe timed out; assuming active"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Service is running. Flag it stale only on definitive, cheap signals:
|
||||
// the on-disk unit matching none of the image refs install_one could
|
||||
// have written, or a local build context newer than the built image.
|
||||
let on_disk = fs::read_to_string(&unit_path).await.unwrap_or_default();
|
||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
let matches_known_shape = [&local_image, &local_image_compat, ®istry_image]
|
||||
.iter()
|
||||
.any(|img| build_unit(spec, img).render() == on_disk);
|
||||
if !matches_known_shape {
|
||||
let expected_image = ensure_image_present(spec).await?;
|
||||
let expected_unit = build_unit(spec, &expected_image);
|
||||
if expected_unit.render() != fs::read_to_string(&unit_path).await.unwrap_or_default() {
|
||||
return Ok(true);
|
||||
}
|
||||
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// Conservative on any timeout/error inside: reuse the cache.
|
||||
return Ok(context_is_newer_than_image(dir, &local_image).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
let svc = format!("{}.service", spec.name);
|
||||
Ok(!quadlet::is_active(&svc).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -232,32 +232,26 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
|
||||
/// leaving `anchor_connected=false` and every peer dial falling back to
|
||||
/// a slow Tor timeout.
|
||||
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
// Concurrent, each connect hard-capped: the old serial loop waited
|
||||
// unbounded on every `sudo fipsctl connect`, so one hung subprocess
|
||||
// stalled the whole apply — and the periodic anchor tick behind it,
|
||||
// which is exactly when a wedged daemon most needs the re-apply.
|
||||
let futs = anchors.iter().cloned().map(|anchor| async move {
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"fipsctl",
|
||||
"connect",
|
||||
&anchor.npub,
|
||||
&anchor.address,
|
||||
&anchor.transport,
|
||||
])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
let mut results = Vec::with_capacity(anchors.len());
|
||||
for anchor in anchors {
|
||||
let out = Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"fipsctl",
|
||||
"connect",
|
||||
&anchor.npub,
|
||||
&anchor.address,
|
||||
&anchor.transport,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let result = match out {
|
||||
Ok(Ok(o)) if o.status.success() => ApplyResult {
|
||||
Ok(o) if o.status.success() => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: true,
|
||||
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
},
|
||||
Ok(Ok(o)) => ApplyResult {
|
||||
Ok(o) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!(
|
||||
@ -266,16 +260,11 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
},
|
||||
Ok(Err(e)) => ApplyResult {
|
||||
Err(e) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!("sudo fipsctl launch failed: {}", e),
|
||||
},
|
||||
Err(_) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: "sudo fipsctl connect timed out after 15s".to_string(),
|
||||
},
|
||||
};
|
||||
if result.ok {
|
||||
tracing::debug!(npub = %result.npub, "Seed anchor applied");
|
||||
@ -286,9 +275,9 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
"Seed anchor apply failed (non-fatal)"
|
||||
);
|
||||
}
|
||||
result
|
||||
});
|
||||
futures_util::future::join_all(futs).await
|
||||
results.push(result);
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
/// Outcome of a single `fipsctl connect` call.
|
||||
@ -301,12 +290,12 @@ pub struct ApplyResult {
|
||||
|
||||
/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated
|
||||
/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port.
|
||||
const FIPS_UDP_PORT: u16 = crate::fips::PUBLISHED_UDP_PORT;
|
||||
const FIPS_UDP_PORT: u16 = 8668;
|
||||
|
||||
/// Build transient seed-anchor entries that dial LAN-discovered federation peers
|
||||
/// directly over their FIPS UDP transport. For each peer the registry knows both
|
||||
/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at
|
||||
/// `<lan-ip>:<FIPS_UDP_PORT>`. This lets co-located federation nodes form a DIRECT FIPS link
|
||||
/// `<lan-ip>:8668`. This lets co-located federation nodes form a DIRECT FIPS link
|
||||
/// instead of depending on the global anchor's spanning tree to route between
|
||||
/// them (the cause of every dial falling back to Tor when the anchor link flaps).
|
||||
///
|
||||
@ -459,39 +448,4 @@ mod tests {
|
||||
assert_eq!(a.transport, "udp");
|
||||
assert_eq!(a.label, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_fips_anchor_port_matches_daemon_bind() {
|
||||
// Drift guard: direct LAN anchors must dial the UDP port the
|
||||
// generated fips.yaml actually binds. These were out of sync for
|
||||
// months (anchors dialed 8668, the daemon bound 2121), making the
|
||||
// whole direct-peering feature dial a dead port.
|
||||
let yaml = crate::fips::config::render_config_yaml();
|
||||
assert!(
|
||||
yaml.contains(&format!("0.0.0.0:{FIPS_UDP_PORT}")),
|
||||
"lan_fips_anchors dials :{FIPS_UDP_PORT} but the daemon config binds elsewhere"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_fips_anchors_builds_direct_entry() {
|
||||
let peer = crate::transport::PeerRecord {
|
||||
did: "did:key:zpeer".to_string(),
|
||||
lan_address: Some("192.168.63.198:5678".to_string()),
|
||||
fips_npub: Some("npub1peer".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let out = lan_fips_anchors(&[peer]);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].address, format!("192.168.63.198:{FIPS_UDP_PORT}"));
|
||||
assert_eq!(out[0].transport, "udp");
|
||||
|
||||
// Peers missing either the LAN address or the npub produce nothing.
|
||||
let no_npub = crate::transport::PeerRecord {
|
||||
did: "did:key:zother".to_string(),
|
||||
lan_address: Some("192.168.63.199:5678".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(lan_fips_anchors(&[no_npub]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,21 +240,11 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
||||
// framework-pt). Ship the allowance as a fips.d drop-in on every
|
||||
// install/upgrade so no node ever regresses to a UI-less mesh.
|
||||
sudo_install_dir("/etc/fips/fips.d").await?;
|
||||
// PEER_PORT (5679) carries ALL federation sync, cloud browse/download,
|
||||
// mesh envelopes, DWN and invoices. It was missing from this allowlist
|
||||
// while the comment claimed "web UI + peer API" — so every hardened
|
||||
// node silently dropped peers' FIPS dials at the firewall and the whole
|
||||
// fleet fell back to Tor (root-caused live 2026-07-27: 28k drops on
|
||||
// .198's counter; :5679 answered in 0.35s once the rule was inserted).
|
||||
let dropin = format!(
|
||||
"# Written by archipelago on every daemon config install.\n\
|
||||
# Allows the web UI + peer API through the fips0\n\
|
||||
# default-deny inbound baseline (fips.nft).\n\
|
||||
tcp dport 80 accept\n\
|
||||
tcp dport 8443 accept\n\
|
||||
tcp dport {peer_port} accept\n",
|
||||
peer_port = crate::fips::dial::PEER_PORT
|
||||
);
|
||||
let dropin = "# Written by archipelago on every daemon config install.\n\
|
||||
# Allows the web UI + peer API through the fips0\n\
|
||||
# default-deny inbound baseline (fips.nft).\n\
|
||||
tcp dport 80 accept\n\
|
||||
tcp dport 8443 accept\n";
|
||||
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
|
||||
tokio::fs::write(&nft_stage, dropin)
|
||||
.await
|
||||
|
||||
@ -24,7 +24,6 @@
|
||||
//! ```
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::telemetry::{self, FallbackReason};
|
||||
use anyhow::{Context, Result};
|
||||
use std::net::{IpAddr, Ipv6Addr};
|
||||
use std::time::Duration;
|
||||
@ -151,12 +150,6 @@ pub async fn warm_path(npub: &str) {
|
||||
if !is_service_active().await {
|
||||
return;
|
||||
}
|
||||
warm_path_unchecked(npub).await
|
||||
}
|
||||
|
||||
/// [`warm_path`] without the service-active check — for callers (the warm
|
||||
/// tick) that already verified the daemon once for the whole batch.
|
||||
pub async fn warm_path_unchecked(npub: &str) {
|
||||
let Ok(base) = peer_base_url(npub).await else {
|
||||
return;
|
||||
};
|
||||
@ -283,39 +276,21 @@ pub fn as_ip_addr(v6: Ipv6Addr) -> IpAddr {
|
||||
|
||||
// ── High-level peer request helpers ────────────────────────────────────
|
||||
|
||||
/// TTL for the [`is_service_active`] cache. Every FIPS dial attempt and
|
||||
/// every warm-tick peer used to spawn up to two `systemctl` subprocesses;
|
||||
/// service state changes on human timescales, so 10s staleness is free.
|
||||
const SERVICE_ACTIVE_TTL_MS: u64 = 10_000;
|
||||
static SERVICE_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
static SERVICE_PROBED_AT_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream)
|
||||
/// currently `systemctl is-active`? Cached for [`SERVICE_ACTIVE_TTL_MS`];
|
||||
/// concurrent refreshes are harmless (idempotent probe, last write wins).
|
||||
/// currently `systemctl is-active`? Async wrapper intended for the
|
||||
/// migration call sites; unlike `FipsTransport::is_available` this does
|
||||
/// not maintain a cache, so callers that poll frequently should cache
|
||||
/// themselves.
|
||||
pub async fn is_service_active() -> bool {
|
||||
use std::sync::atomic::Ordering;
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let probed_at = SERVICE_PROBED_AT_MS.load(Ordering::Relaxed);
|
||||
if probed_at != 0 && now_ms.saturating_sub(probed_at) < SERVICE_ACTIVE_TTL_MS {
|
||||
return SERVICE_ACTIVE.load(Ordering::Relaxed);
|
||||
}
|
||||
let mut active = false;
|
||||
for unit in [
|
||||
crate::fips::SERVICE_UNIT,
|
||||
crate::fips::UPSTREAM_SERVICE_UNIT,
|
||||
] {
|
||||
if crate::fips::service::unit_state(unit).await == "active" {
|
||||
active = true;
|
||||
break;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
SERVICE_ACTIVE.store(active, Ordering::Relaxed);
|
||||
SERVICE_PROBED_AT_MS.store(now_ms, Ordering::Relaxed);
|
||||
active
|
||||
false
|
||||
}
|
||||
|
||||
/// Builder for a peer request that may be sent over FIPS (preferred) or
|
||||
@ -342,11 +317,6 @@ pub struct PeerRequest<'a> {
|
||||
/// large content download needs so its long FIPS transfer isn't truncated.
|
||||
pub fips_timeout: Option<std::time::Duration>,
|
||||
pub service: Option<crate::settings::transport::PeerService>,
|
||||
/// When set, the transport that actually served this request is written
|
||||
/// to federation storage (`record_peer_transport`, matched by onion) so
|
||||
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
|
||||
/// every caller has a data dir in scope.
|
||||
pub record_data_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl<'a> PeerRequest<'a> {
|
||||
@ -359,31 +329,6 @@ impl<'a> PeerRequest<'a> {
|
||||
timeout: std::time::Duration::from_secs(30),
|
||||
fips_timeout: None,
|
||||
service: None,
|
||||
record_data_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the transport that serves this request into federation storage
|
||||
/// (matched by this request's onion host). Best-effort, off the hot path.
|
||||
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
|
||||
self.record_data_dir = Some(data_dir.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn spawn_record(&self, kind: crate::transport::TransportKind) {
|
||||
if let Some(dir) = &self.record_data_dir {
|
||||
let dir = dir.clone();
|
||||
let onion = self.onion_host.to_string();
|
||||
let transport = kind.to_string();
|
||||
tokio::spawn(async move {
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&dir,
|
||||
None,
|
||||
Some(&onion),
|
||||
&transport,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -444,22 +389,8 @@ impl<'a> PeerRequest<'a> {
|
||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||
// fall back. FIPS-only never falls back.
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
telemetry::record_fips_ok();
|
||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
FallbackReason::Http404
|
||||
} else {
|
||||
FallbackReason::Http5xx
|
||||
};
|
||||
telemetry::record_fallback(reason);
|
||||
tracing::info!(
|
||||
reason = reason.key(),
|
||||
status = %resp.status(),
|
||||
"FIPS POST {} answered but status triggers Tor fallback",
|
||||
self.path
|
||||
);
|
||||
}
|
||||
None => {
|
||||
if pref == TransportPref::Fips {
|
||||
@ -471,7 +402,6 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
}
|
||||
let resp = self.send_tor_post_json(body).await?;
|
||||
self.spawn_record(crate::transport::TransportKind::Tor);
|
||||
Ok((resp, crate::transport::TransportKind::Tor))
|
||||
}
|
||||
|
||||
@ -483,22 +413,8 @@ impl<'a> PeerRequest<'a> {
|
||||
match self.try_fips_get().await? {
|
||||
Some(resp) => {
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
telemetry::record_fips_ok();
|
||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
FallbackReason::Http404
|
||||
} else {
|
||||
FallbackReason::Http5xx
|
||||
};
|
||||
telemetry::record_fallback(reason);
|
||||
tracing::info!(
|
||||
reason = reason.key(),
|
||||
status = %resp.status(),
|
||||
"FIPS GET {} answered but status triggers Tor fallback",
|
||||
self.path
|
||||
);
|
||||
}
|
||||
None => {
|
||||
if pref == TransportPref::Fips {
|
||||
@ -510,7 +426,6 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
}
|
||||
let resp = self.send_tor_get().await?;
|
||||
self.spawn_record(crate::transport::TransportKind::Tor);
|
||||
Ok((resp, crate::transport::TransportKind::Tor))
|
||||
}
|
||||
|
||||
@ -519,127 +434,67 @@ impl<'a> PeerRequest<'a> {
|
||||
body: &B,
|
||||
) -> Result<Option<reqwest::Response>> {
|
||||
let Some(npub) = self.fips_npub else {
|
||||
telemetry::record_fallback(FallbackReason::NoNpub);
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_service_active().await {
|
||||
telemetry::record_fallback(FallbackReason::ServiceInactive);
|
||||
return Ok(None);
|
||||
}
|
||||
let base = match peer_base_url(npub).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
telemetry::record_fallback(FallbackReason::DnsFail);
|
||||
tracing::info!(
|
||||
reason = FallbackReason::DnsFail.key(),
|
||||
"FIPS resolve for {} failed: {}, falling back to Tor",
|
||||
npub,
|
||||
e
|
||||
);
|
||||
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let budget = self.fips_attempt_timeout();
|
||||
// With an explicit fast-fail cap, halve the per-attempt client
|
||||
// timeout so the one-retry path in send_with_retry fits inside the
|
||||
// budget instead of silently doubling it ("fips_timeout(6s)" used
|
||||
// to really mean ~12.6s). Without one (long streaming downloads),
|
||||
// keep the full budget per attempt — the client timeout also
|
||||
// governs body streaming and must not truncate a real transfer.
|
||||
let per_attempt = if self.fips_timeout.is_some() {
|
||||
budget / 2
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let c = client_with_timeout(per_attempt);
|
||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||
let mut rb = c.post(&url).json(body);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
||||
Ok(Ok(r)) => Ok(Some(r)),
|
||||
Ok(Err(e)) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
reason = FallbackReason::ConnectFail.key(),
|
||||
match send_with_retry(rb).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"FIPS POST {} failed after retry: {}, falling back to Tor",
|
||||
url,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
Err(_) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
reason = FallbackReason::ConnectFail.key(),
|
||||
"FIPS POST {} exceeded attempt budget {:?}, falling back to Tor",
|
||||
url,
|
||||
budget
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
|
||||
let Some(npub) = self.fips_npub else {
|
||||
telemetry::record_fallback(FallbackReason::NoNpub);
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_service_active().await {
|
||||
telemetry::record_fallback(FallbackReason::ServiceInactive);
|
||||
return Ok(None);
|
||||
}
|
||||
let base = match peer_base_url(npub).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
telemetry::record_fallback(FallbackReason::DnsFail);
|
||||
tracing::info!(
|
||||
reason = FallbackReason::DnsFail.key(),
|
||||
"FIPS resolve for {} failed: {}, falling back to Tor",
|
||||
npub,
|
||||
e
|
||||
);
|
||||
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let budget = self.fips_attempt_timeout();
|
||||
// Same budget discipline as the POST path: halve per attempt only
|
||||
// under an explicit fast-fail cap; hard-cap the retry sequence.
|
||||
let per_attempt = if self.fips_timeout.is_some() {
|
||||
budget / 2
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let c = client_with_timeout(per_attempt);
|
||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||
let mut rb = c.get(&url);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
||||
Ok(Ok(r)) => Ok(Some(r)),
|
||||
Ok(Err(e)) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
reason = FallbackReason::ConnectFail.key(),
|
||||
match send_with_retry(rb).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"FIPS GET {} failed after retry: {}, falling back to Tor",
|
||||
url,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
Err(_) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
reason = FallbackReason::ConnectFail.key(),
|
||||
"FIPS GET {} exceeded attempt budget {:?}, falling back to Tor",
|
||||
url,
|
||||
budget
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -31,7 +31,6 @@ pub mod config;
|
||||
pub mod dial;
|
||||
pub mod iface;
|
||||
pub mod service;
|
||||
pub mod telemetry;
|
||||
pub mod update;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -80,73 +79,25 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
||||
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||
// Connectivity watcher state: re-apply seed anchors the moment the
|
||||
// anchor link drops (edge) or the data path degrades (dials keep
|
||||
// failing with zero successes), instead of waiting for the 300s
|
||||
// anchor tick. Bounded: at most one re-apply per RE_APPLY_BACKOFF.
|
||||
const RE_APPLY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
let mut prev_connected: Option<bool> = None;
|
||||
let mut prev_totals = telemetry::totals();
|
||||
let mut last_apply: Option<std::time::Instant> = None;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
// Bring FIPS up on its own once onboarding has materialised the key.
|
||||
ensure_activated(&data_dir).await;
|
||||
if !dial::is_service_active().await {
|
||||
prev_connected = None; // daemon restart = fresh edge detection
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Warm the union of federation peers + configured seed
|
||||
// anchors. Warming only federation npubs left the direct
|
||||
// anchors (vps2, LAN peers) to go cold between 300s ticks.
|
||||
let nodes = crate::federation::load_nodes(&data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let seed = anchors::load(&data_dir).await.unwrap_or_default();
|
||||
let mut warm_npubs: std::collections::BTreeSet<String> = nodes
|
||||
.iter()
|
||||
.filter_map(|n| n.fips_npub.clone())
|
||||
.collect();
|
||||
warm_npubs.extend(seed.iter().map(|a| a.npub.clone()));
|
||||
let mut handles = Vec::new();
|
||||
for npub in warm_npubs {
|
||||
// Service-active was checked once above for the whole batch.
|
||||
handles.push(tokio::spawn(
|
||||
async move { dial::warm_path_unchecked(&npub).await },
|
||||
));
|
||||
for node in nodes {
|
||||
if let Some(npub) = node.fips_npub.clone() {
|
||||
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await }));
|
||||
}
|
||||
}
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
|
||||
// ── Connectivity watcher: detect anchor-link loss AND silent
|
||||
// data-path death (daemon reports "connected" but every dial
|
||||
// connect-fails — observed live on .198, 2026-07-27, where the
|
||||
// 300s tick never healed it).
|
||||
let mut anchor_npubs = vec![service::PUBLIC_ANCHOR_NPUB.to_string()];
|
||||
anchor_npubs.extend(seed.iter().map(|a| a.npub.clone()));
|
||||
let (_, connected) = service::peer_connectivity_summary(&anchor_npubs).await;
|
||||
let totals = telemetry::totals();
|
||||
let link_dropped = prev_connected == Some(true) && !connected;
|
||||
let never_connected = prev_connected.is_none() && !connected;
|
||||
let data_path_dead =
|
||||
totals.1.saturating_sub(prev_totals.1) >= 5 && totals.0 == prev_totals.0;
|
||||
prev_connected = Some(connected);
|
||||
prev_totals = totals;
|
||||
|
||||
let backoff_ok = last_apply.is_none_or(|t| t.elapsed() >= RE_APPLY_BACKOFF);
|
||||
if (link_dropped || never_connected || data_path_dead) && backoff_ok && !seed.is_empty()
|
||||
{
|
||||
tracing::info!(
|
||||
link_dropped,
|
||||
never_connected,
|
||||
data_path_dead,
|
||||
"FIPS connectivity degraded — re-applying seed anchors now"
|
||||
);
|
||||
last_apply = Some(std::time::Instant::now());
|
||||
let _ = anchors::apply(&seed).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,155 +0,0 @@
|
||||
//! In-process counters for FIPS dial outcomes.
|
||||
//!
|
||||
//! Every peer dial that could have used FIPS either succeeds over FIPS or
|
||||
//! falls back to Tor for one of six reasons (F1–F6). Before these counters
|
||||
//! existed, fallbacks were `debug!`-only and invisible in production, which
|
||||
//! made "FIPS uptime" unfalsifiable — several paths were 100% Tor for months
|
||||
//! (dead ports, firewalled listeners, allowlist 404s) and nothing surfaced
|
||||
//! it. The counters are process-lifetime (reset on restart) and exposed via
|
||||
//! `fips.status` as `dial_stats`, so a fleet-wide fallback regression shows
|
||||
//! up on the dashboard instead of as vague slowness.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Why a FIPS-capable dial fell back to Tor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FallbackReason {
|
||||
/// F1 — no FIPS npub known for the peer (never meshed, or pre-npub
|
||||
/// federation record). Expected for non-FIPS peers; high counts here
|
||||
/// mean npub propagation is broken, not the transport.
|
||||
NoNpub,
|
||||
/// F2 — the local FIPS daemon service isn't active.
|
||||
ServiceInactive,
|
||||
/// F3 — the local FIPS DNS resolver couldn't resolve the peer's npub
|
||||
/// (daemon up but peer not in the identity cache / mesh unreachable).
|
||||
DnsFail,
|
||||
/// F4 — TCP/HTTP dial to the peer's ULA failed or exceeded the FIPS
|
||||
/// attempt budget (firewalled :5679, cold hole-punch, peer down).
|
||||
ConnectFail,
|
||||
/// F5 — peer answered over FIPS with 404: its listener doesn't serve
|
||||
/// this path (older build / stricter allowlist).
|
||||
Http404,
|
||||
/// F6 — peer answered over FIPS with a 5xx server error.
|
||||
Http5xx,
|
||||
}
|
||||
|
||||
impl FallbackReason {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::NoNpub => "no_npub",
|
||||
Self::ServiceInactive => "service_inactive",
|
||||
Self::DnsFail => "dns_fail",
|
||||
Self::ConnectFail => "connect_fail",
|
||||
Self::Http404 => "http_404",
|
||||
Self::Http5xx => "http_5xx",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static FIPS_OK: AtomicU64 = AtomicU64::new(0);
|
||||
static NO_NPUB: AtomicU64 = AtomicU64::new(0);
|
||||
static SERVICE_INACTIVE: AtomicU64 = AtomicU64::new(0);
|
||||
static DNS_FAIL: AtomicU64 = AtomicU64::new(0);
|
||||
static CONNECT_FAIL: AtomicU64 = AtomicU64::new(0);
|
||||
static HTTP_404: AtomicU64 = AtomicU64::new(0);
|
||||
static HTTP_5XX: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn counter(reason: FallbackReason) -> &'static AtomicU64 {
|
||||
match reason {
|
||||
FallbackReason::NoNpub => &NO_NPUB,
|
||||
FallbackReason::ServiceInactive => &SERVICE_INACTIVE,
|
||||
FallbackReason::DnsFail => &DNS_FAIL,
|
||||
FallbackReason::ConnectFail => &CONNECT_FAIL,
|
||||
FallbackReason::Http404 => &HTTP_404,
|
||||
FallbackReason::Http5xx => &HTTP_5XX,
|
||||
}
|
||||
}
|
||||
|
||||
/// A dial completed over FIPS (any HTTP status that wasn't a fallback
|
||||
/// trigger — the peer was reached on the mesh).
|
||||
pub fn record_fips_ok() {
|
||||
FIPS_OK.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A FIPS-capable dial fell back to Tor.
|
||||
pub fn record_fallback(reason: FallbackReason) {
|
||||
counter(reason).fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// `(fips_ok, connect_fail)` totals for the connectivity watcher: a window
|
||||
/// where connect_fail grows while fips_ok doesn't is a degraded data path —
|
||||
/// including the "daemon says connected but packets blackhole" failure the
|
||||
/// link-state check alone can't see (observed live 2026-07-27 on .198).
|
||||
pub fn totals() -> (u64, u64) {
|
||||
(
|
||||
FIPS_OK.load(Ordering::Relaxed),
|
||||
CONNECT_FAIL.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
/// Snapshot for `fips.status` (`dial_stats`). Process-lifetime counts.
|
||||
pub fn snapshot() -> serde_json::Value {
|
||||
let f1 = NO_NPUB.load(Ordering::Relaxed);
|
||||
let f2 = SERVICE_INACTIVE.load(Ordering::Relaxed);
|
||||
let f3 = DNS_FAIL.load(Ordering::Relaxed);
|
||||
let f4 = CONNECT_FAIL.load(Ordering::Relaxed);
|
||||
let f5 = HTTP_404.load(Ordering::Relaxed);
|
||||
let f6 = HTTP_5XX.load(Ordering::Relaxed);
|
||||
serde_json::json!({
|
||||
"fips_ok": FIPS_OK.load(Ordering::Relaxed),
|
||||
"fallbacks": {
|
||||
"no_npub": f1,
|
||||
"service_inactive": f2,
|
||||
"dns_fail": f3,
|
||||
"connect_fail": f4,
|
||||
"http_404": f5,
|
||||
"http_5xx": f6,
|
||||
"total": f1 + f2 + f3 + f4 + f5 + f6,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn snapshot_counts_recorded_events() {
|
||||
// Counters are global; assert deltas rather than absolutes so this
|
||||
// test stays correct alongside any other test that dials.
|
||||
let before = snapshot();
|
||||
record_fips_ok();
|
||||
record_fallback(FallbackReason::ConnectFail);
|
||||
record_fallback(FallbackReason::Http404);
|
||||
let after = snapshot();
|
||||
let d = |v: &serde_json::Value, path: &[&str]| -> u64 {
|
||||
let mut cur = v;
|
||||
for p in path {
|
||||
cur = &cur[p];
|
||||
}
|
||||
cur.as_u64().unwrap()
|
||||
};
|
||||
assert_eq!(d(&after, &["fips_ok"]) - d(&before, &["fips_ok"]), 1);
|
||||
assert_eq!(
|
||||
d(&after, &["fallbacks", "connect_fail"]) - d(&before, &["fallbacks", "connect_fail"]),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
d(&after, &["fallbacks", "http_404"]) - d(&before, &["fallbacks", "http_404"]),
|
||||
1
|
||||
);
|
||||
assert!(d(&after, &["fallbacks", "total"]) >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reason_keys_are_stable() {
|
||||
// These strings are the fips.status API surface — renaming one is a
|
||||
// breaking change for the UI.
|
||||
assert_eq!(FallbackReason::NoNpub.key(), "no_npub");
|
||||
assert_eq!(FallbackReason::ServiceInactive.key(), "service_inactive");
|
||||
assert_eq!(FallbackReason::DnsFail.key(), "dns_fail");
|
||||
assert_eq!(FallbackReason::ConnectFail.key(), "connect_fail");
|
||||
assert_eq!(FallbackReason::Http404.key(), "http_404");
|
||||
assert_eq!(FallbackReason::Http5xx.key(), "http_5xx");
|
||||
}
|
||||
}
|
||||
@ -134,7 +134,6 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
|
||||
for onion in &unique_onions {
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(data_dir, onion).await;
|
||||
match sync_single_peer(
|
||||
data_dir,
|
||||
fips_npub.as_deref(),
|
||||
&store,
|
||||
onion,
|
||||
@ -174,7 +173,6 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
|
||||
/// Sync with a single peer: pull their messages and push ours.
|
||||
/// Each HTTP call picks FIPS when a npub is known, otherwise Tor.
|
||||
async fn sync_single_peer(
|
||||
data_dir: &Path,
|
||||
fips_npub: Option<&str>,
|
||||
store: &crate::network::dwn_store::DwnStore,
|
||||
onion: &str,
|
||||
@ -188,8 +186,6 @@ async fn sync_single_peer(
|
||||
let (health_resp, _) = PeerRequest::new(fips_npub, onion, "/dwn/health")
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.record_transport(data_dir)
|
||||
.send_get()
|
||||
.await
|
||||
.context("Peer DWN unreachable")?;
|
||||
@ -215,8 +211,6 @@ async fn sync_single_peer(
|
||||
let (pull_res, _) = PeerRequest::new(fips_npub, onion, "/dwn")
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.record_transport(data_dir)
|
||||
.send_json(&pull_body)
|
||||
.await
|
||||
.context("Failed to query peer DWN")?;
|
||||
@ -275,8 +269,6 @@ async fn sync_single_peer(
|
||||
match PeerRequest::new(fips_npub, onion, "/dwn")
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.record_transport(data_dir)
|
||||
.send_json(&push_body)
|
||||
.await
|
||||
{
|
||||
|
||||
@ -342,9 +342,6 @@ pub async fn send_to_peer(
|
||||
signing_key: Option<&ed25519_dalek::SigningKey>,
|
||||
recipient_pubkey: Option<&str>,
|
||||
from_name: Option<&str>,
|
||||
// Federation data dir for last-transport recording; None skips recording
|
||||
// (callers without a data dir in scope).
|
||||
record_data_dir: Option<&std::path::Path>,
|
||||
) -> Result<()> {
|
||||
validate_onion(onion)?;
|
||||
|
||||
@ -373,15 +370,10 @@ pub async fn send_to_peer(
|
||||
body["from_name"] = serde_json::Value::String(name.to_string());
|
||||
}
|
||||
|
||||
let mut req =
|
||||
let (resp, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub, onion, "/archipelago/node-message")
|
||||
.service(crate::settings::transport::PeerService::Messaging)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.fips_timeout(std::time::Duration::from_secs(8));
|
||||
if let Some(dir) = record_data_dir {
|
||||
req = req.record_transport(dir);
|
||||
}
|
||||
let (resp, transport) = req
|
||||
.send_json(&body)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@ -418,7 +410,6 @@ pub async fn check_peer_reachable(onion: &str, fips_npub: Option<&str>) -> Resul
|
||||
// circuit that hasn't answered /health in 12s is "offline" for UI
|
||||
// purposes; the old 30s made the Connected Nodes probes crawl.
|
||||
.timeout(std::time::Duration::from_secs(12))
|
||||
.fips_timeout(std::time::Duration::from_secs(4))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
|
||||
@ -433,18 +433,8 @@ impl Server {
|
||||
),
|
||||
));
|
||||
|
||||
// LAN transport (mDNS discovery). Advertise our FIPS npub in
|
||||
// the TXT record so co-located peers can form a direct FIPS
|
||||
// link (see `lan_fips_anchors`).
|
||||
let local_fips_npub = crate::identity::fips_npub(&data_dir.join("identity"))
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let mut lan = crate::transport::lan::LanTransport::new(
|
||||
&did,
|
||||
&pubkey_hex,
|
||||
5678,
|
||||
local_fips_npub,
|
||||
);
|
||||
// LAN transport (mDNS discovery)
|
||||
let mut lan = crate::transport::lan::LanTransport::new(&did, &pubkey_hex, 5678);
|
||||
match lan.start(registry.clone()) {
|
||||
Ok(()) => info!("📡 LAN transport (mDNS) started"),
|
||||
Err(e) => debug!("LAN transport init (non-fatal): {}", e),
|
||||
@ -763,25 +753,12 @@ impl Server {
|
||||
// (often flaky) global anchor's spanning tree to route to each
|
||||
// other. For every peer the registry knows both a LAN address
|
||||
// AND a FIPS npub for, dial it on its FIPS UDP transport port
|
||||
// at its LAN IP. This is FIPS's own transport over the
|
||||
// (8668) at its LAN IP. This is FIPS's own transport over the
|
||||
// LAN — NOT Tailscale, NOT the HTTP/LAN messaging port. Pure
|
||||
// FIPS. `fipsctl connect` is idempotent, so re-applying every
|
||||
// tick just keeps the direct link warm; unknown/remote peers
|
||||
// (no LAN address) are left to the anchor as before.
|
||||
if let Some(reg) = fips_peer_registry.as_ref() {
|
||||
// Hydrate FIPS npubs into the registry from federation
|
||||
// storage (did-keyed). Peers discovered before the mDNS
|
||||
// TXT `fips` key existed — or running builds that don't
|
||||
// advertise it yet — would otherwise never satisfy the
|
||||
// `fips_npub` requirement in lan_fips_anchors(), leaving
|
||||
// direct LAN peering a no-op.
|
||||
if let Ok(nodes) = crate::federation::load_nodes(&data_dir).await {
|
||||
for n in &nodes {
|
||||
if let Some(npub) = n.fips_npub.as_deref() {
|
||||
reg.set_fips_npub(&n.did, npub).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let direct = crate::fips::anchors::lan_fips_anchors(®.all_peers().await);
|
||||
if !direct.is_empty() {
|
||||
let _ = crate::fips::anchors::apply(&direct).await;
|
||||
@ -1217,36 +1194,18 @@ async fn peer_late_bind_loop(
|
||||
}
|
||||
};
|
||||
info!("FIPS peer listener bound {}", addr);
|
||||
// Serve until shutdown, a persistent accept failure, or a
|
||||
// fips0 ULA change. The listener must be REBINDABLE: a
|
||||
// daemon re-key tears fips0 down and brings it back with a
|
||||
// (possibly different) ULA, and the old one-shot bind left
|
||||
// the node inbound-dead over FIPS until process restart.
|
||||
tokio::select! {
|
||||
_ = accept_loop(
|
||||
handler.clone(),
|
||||
listener,
|
||||
active_connections.clone(),
|
||||
true, // peer listener: apply path filter
|
||||
shutdown_rx.clone(),
|
||||
addr,
|
||||
) => {
|
||||
if *shutdown_rx.borrow() { return; }
|
||||
warn!("FIPS peer accept loop ended — rebinding");
|
||||
}
|
||||
_ = async {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
if crate::fips::iface::fips0_ula() != Some(ip) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} => {
|
||||
info!("fips0 ULA changed — rebinding FIPS peer listener");
|
||||
// Dropping the select arm cancels accept_loop and
|
||||
// frees the socket; the outer loop rebinds fresh.
|
||||
}
|
||||
}
|
||||
// Once bound, serve until shutdown fires. accept_loop
|
||||
// returns on shutdown, which also ends this outer loop.
|
||||
accept_loop(
|
||||
handler,
|
||||
listener,
|
||||
active_connections,
|
||||
true, // peer listener: apply path filter
|
||||
shutdown_rx,
|
||||
addr,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() { return; }
|
||||
@ -1282,12 +1241,6 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
||||
)
|
||||
// Prefix-matched content endpoints (peer file browse + fetch)
|
||||
|| path.starts_with("/content/")
|
||||
// Mesh file sharing — blob fetch by CID, signature-gated in the
|
||||
// handler. Absent from this list it 404'd over FIPS and the feature
|
||||
// was 100% Tor by construction.
|
||||
|| path.starts_with("/blob/")
|
||||
// DWN sync — /dwn/health is step 1 of every sync; same story.
|
||||
|| path.starts_with("/dwn/")
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
@ -1298,70 +1251,22 @@ async fn accept_loop(
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
local_addr: SocketAddr,
|
||||
) {
|
||||
// Consecutive accept-error tracking: a fips0 teardown/re-key leaves the
|
||||
// peer listener's socket permanently broken — `continue`-ing forever
|
||||
// made the node inbound-dead over FIPS until process restart. After a
|
||||
// burst of consecutive errors the peer accept loop returns so its
|
||||
// caller (peer_late_bind_loop) can rebind on the current ULA.
|
||||
let mut consecutive_errors: u32 = 0;
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = listener.accept() => {
|
||||
let (stream, peer_addr) = match result {
|
||||
Ok(c) => { consecutive_errors = 0; c }
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("{} accept error: {}", local_addr, e);
|
||||
consecutive_errors += 1;
|
||||
if peer_only && consecutive_errors >= 10 {
|
||||
warn!("{} accept failing persistently — returning for rebind", local_addr);
|
||||
return;
|
||||
}
|
||||
// Don't hot-loop on a dead socket.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let handler = handler.clone();
|
||||
// NEVER park the accept loop on the connection budget.
|
||||
// `acquire_owned().await` here froze accept() entirely when
|
||||
// permits drained — and permits drained because half-open
|
||||
// clients and hung upstreams held them forever (the .228
|
||||
// session-flapping / CLOSE-WAIT `inode: 0` signature). Shed
|
||||
// load instead: accept, answer 503, close.
|
||||
let permit = match active_connections.clone().try_acquire_owned() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"{} connection budget exhausted — shedding {}",
|
||||
local_addr, peer_addr
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut stream = stream;
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
stream.write_all(
|
||||
b"HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let _ = stream.shutdown().await;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let permit = active_connections.clone().acquire_owned().await;
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
// Set when a request carries an Upgrade header (websocket):
|
||||
// upgraded connections are legitimately long-lived and are
|
||||
// exempt from the non-upgraded connection deadline below.
|
||||
let upgraded = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let upgraded_flag = upgraded.clone();
|
||||
let service = service_fn(move |mut req: hyper::Request<hyper::Body>| {
|
||||
let handler = handler.clone();
|
||||
if req.headers().contains_key(hyper::header::UPGRADE) {
|
||||
upgraded_flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
async move {
|
||||
// Record the TCP peer so rate limiting only trusts
|
||||
// forwarded headers on loopback (nginx) connections.
|
||||
@ -1380,48 +1285,13 @@ async fn accept_loop(
|
||||
.map_err(|e| std::io::Error::other(format!("{}", e)))
|
||||
}
|
||||
});
|
||||
// header_read_timeout: a client that connects and never
|
||||
// sends a request (slowloris / half-open) is dropped
|
||||
// instead of holding a permit until the heat death of the
|
||||
// node. Long RPCs are safe — the clock only covers header
|
||||
// read.
|
||||
let conn = Http::new()
|
||||
if let Err(e) = Http::new()
|
||||
.http1_keep_alive(false)
|
||||
.http1_header_read_timeout(std::time::Duration::from_secs(30))
|
||||
.serve_connection(stream, service)
|
||||
.with_upgrades();
|
||||
tokio::pin!(conn);
|
||||
// Deadline watchdog for NON-upgraded connections. With
|
||||
// keep-alive off a plain connection serves one exchange;
|
||||
// 15 min bounds even the slowest legitimate RPC/stream
|
||||
// while guaranteeing a hung upstream can't hold a permit
|
||||
// forever. Upgraded (websocket) connections are exempt.
|
||||
const NON_UPGRADED_DEADLINE: std::time::Duration =
|
||||
std::time::Duration::from_secs(900);
|
||||
let started = std::time::Instant::now();
|
||||
let watchdog = async {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
if !upgraded.load(std::sync::atomic::Ordering::Relaxed)
|
||||
&& started.elapsed() >= NON_UPGRADED_DEADLINE
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
r = &mut conn => {
|
||||
if let Err(e) = r {
|
||||
error!("Error serving connection from {}: {}", peer_addr, e);
|
||||
}
|
||||
}
|
||||
_ = watchdog => {
|
||||
warn!(
|
||||
"connection from {} exceeded {}s without completing or upgrading — dropping",
|
||||
peer_addr,
|
||||
NON_UPGRADED_DEADLINE.as_secs()
|
||||
);
|
||||
}
|
||||
.with_upgrades()
|
||||
.await
|
||||
{
|
||||
error!("Error serving connection from {}: {}", peer_addr, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -2074,17 +1944,10 @@ mod merge_tests {
|
||||
);
|
||||
assert!(is_peer_allowed_path("/rpc/v1"));
|
||||
assert!(is_peer_allowed_path("/health"));
|
||||
// Mesh blob fetch + DWN sync — both were missing from the allowlist,
|
||||
// which made them deterministically 404 over FIPS and therefore
|
||||
// 100% Tor by construction.
|
||||
assert!(is_peer_allowed_path("/blob/abc123"), "blob fetch by CID");
|
||||
assert!(is_peer_allowed_path("/dwn/health"), "DWN sync step 1");
|
||||
// Not on the allow-list → rejected (no broad surface over the mesh).
|
||||
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
|
||||
assert!(!is_peer_allowed_path("/"));
|
||||
assert!(!is_peer_allowed_path("/rpc/v2"));
|
||||
assert!(!is_peer_allowed_path("/blobber"), "must not prefix-leak");
|
||||
assert!(!is_peer_allowed_path("/dwnx"), "must not prefix-leak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -24,27 +24,17 @@ pub struct LanTransport {
|
||||
our_did: String,
|
||||
our_pubkey_hex: String,
|
||||
our_port: u16,
|
||||
/// This node's FIPS npub, advertised in the mDNS TXT record so
|
||||
/// co-located peers can form a direct FIPS link (`lan_fips_anchors`)
|
||||
/// without waiting for federation storage to sync.
|
||||
our_fips_npub: Option<String>,
|
||||
daemon: Option<ServiceDaemon>,
|
||||
available: AtomicBool,
|
||||
}
|
||||
|
||||
impl LanTransport {
|
||||
/// Create a new LAN transport. Does not start discovery yet.
|
||||
pub fn new(
|
||||
our_did: &str,
|
||||
our_pubkey_hex: &str,
|
||||
port: u16,
|
||||
our_fips_npub: Option<String>,
|
||||
) -> Self {
|
||||
pub fn new(our_did: &str, our_pubkey_hex: &str, port: u16) -> Self {
|
||||
Self {
|
||||
our_did: our_did.to_string(),
|
||||
our_pubkey_hex: our_pubkey_hex.to_string(),
|
||||
our_port: port,
|
||||
our_fips_npub,
|
||||
daemon: None,
|
||||
available: AtomicBool::new(false),
|
||||
}
|
||||
@ -57,14 +47,11 @@ impl LanTransport {
|
||||
|
||||
// Advertise our service
|
||||
let hostname = format!("archy-{}.local.", &self.our_pubkey_hex[..8]);
|
||||
let mut properties = vec![
|
||||
let properties = vec![
|
||||
("did".to_string(), self.our_did.clone()),
|
||||
("pubkey".to_string(), self.our_pubkey_hex.clone()),
|
||||
("version".to_string(), "0.1.0".to_string()),
|
||||
];
|
||||
if let Some(npub) = &self.our_fips_npub {
|
||||
properties.push(("fips".to_string(), npub.clone()));
|
||||
}
|
||||
|
||||
let service_info = ServiceInfo::new(
|
||||
SERVICE_TYPE,
|
||||
@ -106,11 +93,6 @@ impl LanTransport {
|
||||
.map(|v| v.val_str().to_string());
|
||||
let addresses = info.get_addresses();
|
||||
|
||||
let fips_npub = info
|
||||
.get_properties()
|
||||
.get("fips")
|
||||
.map(|v| v.val_str().to_string());
|
||||
|
||||
if let (Some(did), Some(pubkey)) = (did, pubkey) {
|
||||
if let Some(scoped_ip) = addresses.iter().next() {
|
||||
let ip: std::net::IpAddr = match scoped_ip.to_string().parse() {
|
||||
@ -124,9 +106,6 @@ impl LanTransport {
|
||||
.await;
|
||||
registry_clone.set_lan_address(&did, socket_addr).await;
|
||||
registry_clone.set_name(&did, info.get_fullname()).await;
|
||||
if let Some(npub) = fips_npub.as_deref() {
|
||||
registry_clone.set_fips_npub(&did, npub).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,7 +106,7 @@ pub enum PeerSource {
|
||||
}
|
||||
|
||||
/// Unified peer record with per-transport capabilities.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PeerRecord {
|
||||
pub did: String,
|
||||
pub pubkey_hex: String,
|
||||
|
||||
@ -1,300 +0,0 @@
|
||||
# FIPS near-100% uptime + optimistic UI state — implementation plan
|
||||
|
||||
**Date:** 2026-07-27. **Status:** researched + root-caused live on the fleet; ready to
|
||||
implement for the next release. Two workstreams: (A) make node↔node FIPS transport
|
||||
succeed whenever a FIPS path physically exists, (B) stop the UI reloading everything
|
||||
on every navigation (optimistic/cached cards, stale-while-revalidate) while keeping
|
||||
data fresh.
|
||||
|
||||
**Honesty note on "100%":** if a node's network blackholes every anchor (the .116
|
||||
WiFi case, `docs/HANDOFF-2026-07-20-fips-peer-files.md:117-133`), Tor fallback is
|
||||
*correct*. The achievable target is: **FIPS wins whenever a FIPS path exists, and
|
||||
fallback frequency is measured in-product so regressions are visible.** Today several
|
||||
paths are 0% FIPS *by construction* regardless of network health — that's the bug.
|
||||
|
||||
---
|
||||
|
||||
## Part A — why Cloud/FIPS "commonly falls back to Tor": ranked root causes
|
||||
|
||||
All verified live on 2026-07-27 (.116 local, .198, .228, Framework PT, x250s) plus a
|
||||
full code audit of `core/archipelago/src/{fips,transport,federation,server.rs}`.
|
||||
|
||||
### RC0 — 🔥 The hardening firewall drops the peer-API port on every hardened node (PROVEN)
|
||||
|
||||
The fips0 default-deny baseline (`/etc/fips/fips.nft`) is opened by archipelago's
|
||||
drop-in `80-web-ui.nft` (`fips/config.rs:236-255`) for **80 + 8443 + app ports only**.
|
||||
The peer-API listener — which carries *all* federation sync, cloud browse/download,
|
||||
mesh envelopes, DWN, invoices — is **`PEER_PORT = 5679`** (`fips/dial.rs:35`).
|
||||
**5679 is not in the allowlist.** The drop-in's own comment claims "web UI + peer
|
||||
API" but the peer API port was never added.
|
||||
|
||||
Live proof (2026-07-27):
|
||||
- .116 nft chain: 5,965 dropped packets; .198: **28,670 dropped packets** — that's
|
||||
peers' FIPS dials dying at the firewall.
|
||||
- .198 → .116 `GET :5679/health`: **timeout (6s)** before; **HTTP 200 in 0.35s**
|
||||
after `nft insert rule inet fips inbound iifname fips0 tcp dport 5679 accept`.
|
||||
Same result in reverse direction (200 in 0.64s).
|
||||
- Explains the exact fleet split in `federation/nodes.json`: hardened-baseline nodes
|
||||
(Framework PT, .198, .228, x250-dev, x250-mad2) = `last_transport: tor`;
|
||||
non-hardened nodes (Austin Sapien, X250-Beta, X250-PA) answer :5679 (404 from the
|
||||
path allowlist = listener reachable) = `last_transport: fips`.
|
||||
- Every dial to a hardened peer pays the 8s FIPS connect timeout
|
||||
(`dial.rs:114`) ×2 (retry, `dial.rs:128-140`) → then Tor. That's the "Cloud takes
|
||||
forever / shows Tor" experience.
|
||||
|
||||
**Fix (one line + reload):** add `tcp dport 5679 accept` to the drop-in in
|
||||
`fips/config.rs` (use a constant shared with `dial.rs::PEER_PORT`, not a literal).
|
||||
The drop-in reinstalls on every daemon config install, so it heals fleet-wide on OTA.
|
||||
⚠️ Transient manual rules were inserted on .116 and .198 during diagnosis (2026-07-27)
|
||||
— they vanish on the next `nft -f /etc/fips/fips.nft` reload or reboot; the code fix
|
||||
makes them permanent.
|
||||
|
||||
### RC1 — .228 (Shorty's) runs fips 0.3.0-dev; the 0.4.1 fleet can't reach it
|
||||
|
||||
.228's daemon: `0.3.0-dev (rev 34e00b9f6e)`, both anchor links "connected", but its
|
||||
ULA is 100% unreachable from 0.4.1 nodes (ping loss 100%). FIPS wire format is not
|
||||
stable across revs (`docs/HANDOFF-2026-07-23-companion-apk-deploy.md:78`). Everything
|
||||
to/from .228 rides Tor no matter what else we fix.
|
||||
|
||||
**Fix:** fleet fips-version audit + upgrade to v0.4.1 everywhere (in-product updater
|
||||
exists: `fips/update.rs`; .deb path per `reference_vps2_fips_anchor`). Add a version
|
||||
check to `fips.status` and surface a "peer daemon outdated" warning.
|
||||
|
||||
### RC2 — Direct LAN/endpoint peering is dead code + wrong port + stale seed anchors
|
||||
|
||||
Without direct links, all peer traffic hairpins through the vps2 anchor spanning
|
||||
tree (observed: .116→.198 cold RTT 1.5–3.5s on the same LAN; also the wedged-anchor
|
||||
latency-rot incident, `HANDOFF-2026-07-23:141-160`).
|
||||
|
||||
- **G1 — `lan_fips_anchors()` has never run.** It needs `PeerRecord.fips_npub`, but
|
||||
`PeerRegistry::set_fips_npub` (`transport/mod.rs:302`) has **zero callers** — mDNS
|
||||
TXT records only carry `did`/`pubkey`/`version` (`transport/lan.rs:50-54`). So the
|
||||
"co-located peers form a direct link" feature (`anchors.rs:294-305`,
|
||||
`server.rs:761-766`) is a fleet-wide no-op.
|
||||
- **G2 — wrong UDP port.** `anchors.rs:293` dials `8668`, but the generated
|
||||
fips.yaml binds UDP **2121** (`fips/config.rs:187`, `fips/mod.rs:130`). Even if G1
|
||||
ran, it would dial a dead port. `.116`'s live `seed-anchors.json` still carries
|
||||
`.198@192.168.1.198:8668` — **stale IP (LAN renumbered to 192.168.63.x) AND dead
|
||||
port**; both manual entries are useless today.
|
||||
- No Tailscale/alternate endpoint fallback when LAN is unreachable (the .116↔.198
|
||||
fix of 2026-07-20 was hand-applied per-node config, never productized).
|
||||
|
||||
**Fix:** (a) `FIPS_UDP_PORT` → `crate::fips::PUBLISHED_UDP_PORT` + drift-guard test;
|
||||
(b) hydrate `fips_npub` into the registry from federation storage (did-keyed join) so
|
||||
`lan_fips_anchors` goes live with no wire change; (c) advertise the npub in the mDNS
|
||||
TXT + `set_fips_npub` on resolve as the proper fix; (d) teach the LAN-anchor tick to
|
||||
also try a peer's Tailscale/last-known-good endpoint when LAN fails (reviewed change
|
||||
— this area got handoffs wrong twice, per memory).
|
||||
|
||||
### RC3 — No fast-fail on the hottest call sites; retry silently doubles every budget
|
||||
|
||||
- `content.browse-peer` — **the Cloud page** — has NO `fips_timeout`
|
||||
(`api/rpc/content.rs:363-366`): a cold FIPS path burns up to ~16.6s (8s connect +
|
||||
600ms + 8s retry) before Tor even starts, against a UI deadline of 30s
|
||||
(`Cloud.vue:720`) — and the frontend then retries ×3. Users see errors, not
|
||||
fallback. 12 call sites total lack `fips_timeout` (browse/download/preview-peer,
|
||||
`/blob`, DWN, node_message, rotation notifies).
|
||||
- `dial.rs:128-140` runs 2 full-budget attempts, so `fips_timeout(6s)` really means
|
||||
~12.6s everywhere.
|
||||
|
||||
**Fix:** wrap `send_with_retry` in a single `tokio::time::timeout(fips_attempt_timeout())`
|
||||
(call sites `dial.rs:455`, `dial.rs:488`; halve per-attempt client timeout), then add
|
||||
`.fips_timeout(...)`: `content.rs:366` (6s), `content.rs:281` (8s), `content.rs:1139`
|
||||
(6s), `typed_messages.rs:822` (8s), `dwn_sync.rs:188/213/272` (6s),
|
||||
`node_message.rs:376` (8s), `node_message.rs:412` (4s), `tor/mod.rs:501` (6s),
|
||||
`federation/handlers.rs:869` (6s). **Skip the three 900s streaming downloads**
|
||||
(`content.rs:552/870/1061`, `proxy.rs:236`) — `dial.rs:311-319` documents why; the
|
||||
retry-budget wrap covers their connect phase.
|
||||
|
||||
### RC4 — Two features are 100% Tor by construction (allowlist 404)
|
||||
|
||||
The peer listener path allowlist (`server.rs:1219-1239`) omits `/blob/<cid>` (mesh
|
||||
file sharing, `typed_messages.rs:813-822`) and `/dwn/health` (step 1 of DWN sync,
|
||||
`dwn_sync.rs:186`) → deterministic 404 over FIPS (`dial.rs:44-46` treats 404 as
|
||||
fall-back) → deterministic Tor, after paying the full FIPS cost. Both endpoints are
|
||||
already cryptographically gated, so they meet the allowlist's stated criterion.
|
||||
|
||||
**Fix:** add `|| path.starts_with("/blob/") || path.starts_with("/dwn/")`; extend the
|
||||
existing test block at `server.rs:1935-1945` (assert `/blob/abc` + `/dwn/health`
|
||||
allowed, `/blobber` + `/dwnx` denied).
|
||||
|
||||
### RC5 — Inbound listener can't heal; anchor flap = 5-minute Tor window; probe overhead
|
||||
|
||||
- `peer_late_bind_loop` returns after first successful bind (`server.rs:1203`) and
|
||||
`accept_loop` `continue`s on errors forever (`server.rs:1249-1258`): a fips0
|
||||
teardown/re-key leaves the node inbound-dead until process restart → **every peer**
|
||||
falls back to Tor against it.
|
||||
- Nothing reacts to anchor-link drops: anchors re-apply only on the 300s tick
|
||||
(`server.rs:731`); worst-case 5min Tor-only after a flap (the historic "link dead
|
||||
timeout 30s" flapping made this chronic).
|
||||
- `is_service_active()` spawns up to 2 `systemctl` per FIPS attempt *and* per peer
|
||||
per 25s warm tick (`dial.rs:284-294`); `warm_path` skips peers without
|
||||
`fips_npub` in federation storage (`fips/mod.rs:88-95`); `anchors::apply` is
|
||||
serial with unbounded subprocess waits (`anchors.rs:234-283`).
|
||||
|
||||
**Fix:** rebindable listener; a ~25s connectivity watcher (reuse
|
||||
`service::peer_connectivity_summary`, `fips/service.rs:178-207`) that re-applies
|
||||
anchors immediately on a connected→disconnected edge with bounded backoff; 10s TTL
|
||||
cache for `is_service_active` (mirror `transport/fips.rs:24-107`); warm the union of
|
||||
federation+registry peers; make `apply()` concurrent with per-connect timeouts.
|
||||
|
||||
### RC6 — Zero observability: fallbacks are invisible, so "uptime" is unfalsifiable
|
||||
|
||||
Fallbacks log at `debug!` only (`dial.rs:458,491`); no counters; `last_transport` is
|
||||
written by only 7 of ~20 call sites and **never read** to influence anything
|
||||
(`storage.rs:120-147`). The parallel `TransportRouter` system can't even see FIPS
|
||||
(`FipsTransport` is never constructed — `server.rs:422-442` registers Tor/Mesh/LAN
|
||||
only).
|
||||
|
||||
**Fix:** per-reason fallback counters (F1 no-npub / F2 service-inactive / F3
|
||||
DNS-fail / F4 connect-fail / F5 404 / F6 5xx) surfaced in `fips.status` + `info!`
|
||||
logs with a `reason` field; call `record_peer_transport` from all peer-dial sites;
|
||||
UI: per-peer transport badge on Cloud (the response already carries `transport` —
|
||||
`content.rs:392-400` — Cloud.vue currently throws it away at `:716-721`).
|
||||
|
||||
---
|
||||
|
||||
## Part A — execution phases
|
||||
|
||||
### Phase A0 — fleet triage (no release needed; do first, validates everything)
|
||||
1. Fleet audit: `fipsctl --version` + `nft list table inet fips` + `ss -tlnp | grep 5679`
|
||||
on every node (roster: `reference_test_deploy_roster`).
|
||||
2. Transient `nft insert rule inet fips inbound iifname fips0 tcp dport 5679 accept`
|
||||
on hardened nodes (already done on .116 + .198, 2026-07-27) — instant fleet-wide
|
||||
FIPS recovery while the code fix rides the OTA.
|
||||
3. Upgrade .228 (and any other 0.3.x) fips daemon to v0.4.1.
|
||||
4. Regenerate/clean stale `seed-anchors.json` on .116 (dead 192.168.1.x + :8668 entries).
|
||||
5. Baseline measurement: for each node pair, `content.browse-peer` time + transport.
|
||||
|
||||
### Phase A1 — P0 code (one commit, mechanical, offline-testable)
|
||||
1. **nft drop-in: open 5679** — `fips/config.rs` (share the constant with
|
||||
`dial.rs::PEER_PORT`). ← RC0
|
||||
2. **Allowlist `/blob/`, `/dwn/`** — `server.rs:1219-1239` + tests. ← RC4
|
||||
3. **`FIPS_UDP_PORT` = `PUBLISHED_UDP_PORT` (2121)** — `anchors.rs:293` + drift-guard
|
||||
test against `render_config_yaml()`. ← RC2-G2
|
||||
4. **Un-deaden `lan_fips_anchors`** — hydrate `fips_npub` from federation storage in
|
||||
`server.rs:761-766`; then mDNS TXT `fips` key + `set_fips_npub`
|
||||
(`transport/lan.rs:50-54`, `lan.rs:96-108`, `LanTransport::new` 4th arg via
|
||||
`crate::identity::fips_npub(&data_dir.join("identity"))`). ← RC2-G1
|
||||
5. **Retry-budget wrap + `fips_timeout` on 12 call sites** (list in RC3). ← RC3
|
||||
Verify: `cd core && cargo test -p archipelago` — watch `test_rendered_yaml_exact_snapshot`
|
||||
(`config.rs:419`) + `test_render_is_deterministic` (`config.rs:476`); item 3 must
|
||||
not change rendered output.
|
||||
|
||||
### Phase A2 — telemetry BEFORE tuning (second commit)
|
||||
6. Fallback counters by reason + `fips.status` exposure + `info!` reason logs;
|
||||
`record_peer_transport` from all sites. ← RC6 (gives the baseline that makes A3
|
||||
measurable and "100%" falsifiable)
|
||||
|
||||
### Phase A3 — resilience (third commit, measured against A2 baseline)
|
||||
7. `is_service_active` 10s TTL cache; warm-path union + `warm_path_unchecked`.
|
||||
8. Link-state watcher → immediate anchor re-apply on drop (replaces waiting for the
|
||||
300s tick); concurrent `apply()` with subprocess timeouts.
|
||||
9. Rebindable peer listener (`server.rs:1203`, `1249-1258`).
|
||||
10. (Reviewed, separate PR) endpoint-fallback for direct peering: LAN → Tailscale →
|
||||
last-known-good, npub-keyed. Mesh-routing area — needs careful review per memory.
|
||||
|
||||
### Phase A4 — verification gate (on nodes, before tag)
|
||||
- On .116/.198/framework-pt/.228: `content.browse-peer` to every peer must return
|
||||
`transport: "fips"` with sub-second latency (LAN pairs) / <3s (WAN), 20/20 calls.
|
||||
- Kill the fips daemon on one node → calls fall back to Tor gracefully within the
|
||||
fast-fail budget (<8s), UI shows partial results, no errors.
|
||||
- Restart daemon → FIPS recovers within one watcher tick (~25s), verified in
|
||||
`fips.status` counters.
|
||||
- Flap the anchor link (drop vps2 route) → direct LAN pairs keep FIPS via their
|
||||
direct link (G1 fix proof).
|
||||
- Add these as `tests/multinode/` cases per `docs/multinode-testing-plan.md`; also
|
||||
fix the known `node_rpc()` missing `--max-time` (tracker item).
|
||||
|
||||
---
|
||||
|
||||
## Part B — optimistic loading + state management (frontend)
|
||||
|
||||
Full audit: Pinia exists but pages fetch-on-mount with `loading=true` spinners;
|
||||
`Dashboard.vue:89` keys the router-view by `route.path`, so **every navigation
|
||||
unmounts and refetches everything**; no KeepAlive/onActivated anywhere; no dedup,
|
||||
no abort, no SWR layer. Four hand-rolled cache implementations already exist and
|
||||
prove the pattern (`useFleetData.ts:198-231` sessionStorage hydrate;
|
||||
`homeStatus.ts` sticky-ready loadState; `Home.vue:591-621` wallet localStorage
|
||||
snapshot; `curatedApps.ts:21-77` TTL cache). `SkeletonCard.vue` exists, imported by
|
||||
zero files.
|
||||
|
||||
### B1 — one shared primitive: `useCachedResource` composable + `resources` Pinia store
|
||||
Semantics (generalize `homeStatus.ts` + `useFleetData.ts`):
|
||||
- Keyed resource: `{ data, loadState: idle|loading|ready|error|refreshing, fetchedAt, error }`.
|
||||
- **Hydrate synchronously** from memory (Pinia, survives navigation) → sessionStorage
|
||||
snapshot (survives reload) → then revalidate in background.
|
||||
- Sticky-ready: once `ready`, never regress to `loading`
|
||||
(`loadState = loadState==='ready' ? 'ready' : 'loading'` — the `homeStatus.ts:80` idiom);
|
||||
keep-last-known-value on error with a stale badge (age from `fetchedAt`).
|
||||
- TTL per resource; `revalidateOnFocus` + on WS push (debounced, the
|
||||
`Home.vue:539-542` pattern); explicit `invalidate(key)` for mutations.
|
||||
- Optimistic mutation helper: apply → RPC → rollback on error (generalize
|
||||
`TransportPrefsCard.vue:112-127`).
|
||||
|
||||
### B2 — rpc-client upgrades (`src/api/rpc-client.ts`)
|
||||
- `AbortSignal` in `RPCOptions` (today the AbortController at `:87` is timeout-only)
|
||||
→ abort-on-unmount for fan-outs.
|
||||
- In-flight dedup keyed `method+JSON(params)` — collapses duplicate concurrent calls.
|
||||
- Per-call `maxRetries` override; set `maxRetries: 1` for `content.browse-peer` /
|
||||
`preview-peer` (retry×3 on a 30s timeout is why one slow peer = 90s spinner).
|
||||
|
||||
### B3 — Cloud page conversion (worst offender, the marquee win)
|
||||
- Move `sectionCounts`, `peerNodes`, `myFiles`, `peerFiles`, `paidItems` out of
|
||||
`Cloud.vue` component state (`:403,:476,:582,:689,:427`) into the cached store —
|
||||
instant render on revisit, background refresh.
|
||||
- **Incremental per-peer fan-in**: render each peer's card as its
|
||||
`content.browse-peer` resolves (today `Promise.allSettled` at `:708-747` blocks on
|
||||
the slowest peer). Per-peer states: cached/fresh/loading/unreachable.
|
||||
- **Surface `transport` per peer** (already in the response, discarded at `:716-721`):
|
||||
FIPS/Tor badge + latency — this is also the fleet-wide FIPS-uptime dashboard the
|
||||
user asked for, for free.
|
||||
- Skeleton cards (revive `SkeletonCard.vue`, copy `FileGrid.vue:3-19` shimmer) instead
|
||||
of spinners for counts/folders/peer grids.
|
||||
- Stop `CloudFolder.vue:307-319` calling `cloudStore.reset()` on every folder entry —
|
||||
cache per-path listings, navigate renders cache + revalidates.
|
||||
- `PeerFiles.vue`: persist catalog + preview cache in the store; cap the
|
||||
`preview-peer` fan-out (`:832-841`, currently unbounded) with a small concurrency
|
||||
queue + abort-on-unmount.
|
||||
|
||||
### B4 — roll out to remaining offenders (in audit order)
|
||||
PeerFiles → Web5 wallet/ecash/LND slices → Monitoring → Lightning channels
|
||||
(`LightningChannelsPanel.vue:650`) → Federation (already has `{showLoader:false}` —
|
||||
just adopt the store) → Server → Credentials/OpenWrtGateway/ContainerApps.
|
||||
`Apps.vue`/`Marketplace.vue`/`Fleet.vue` are already good; don't touch.
|
||||
|
||||
### B5 — freshness via the existing push channel
|
||||
`/ws/db` firehose + `sync.ts` JSON-patch already exist. Wire `useCachedResource`
|
||||
revalidation to relevant WS pushes (debounced 800ms), keep the 30s staleness
|
||||
reconciliation as backstop. No new backend needed for v1; a per-topic subscribe can
|
||||
come later.
|
||||
|
||||
### Part B verification (on nodes)
|
||||
- Navigate Cloud → Apps → Cloud: peer files render instantly from cache (0 spinner),
|
||||
refresh indicator while revalidating, updated data lands without layout jump.
|
||||
- One unreachable peer: its card shows stale/unreachable state; other peers render
|
||||
immediately (no 30s all-or-nothing).
|
||||
- Kill backend mid-view: stale data stays visible with age badge; recovery
|
||||
revalidates automatically.
|
||||
- Hard reload: sessionStorage hydrate paints before first RPC completes.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing for the next release
|
||||
|
||||
1. **A0 now** (fleet triage + transient nft rules + .228 daemon upgrade + baseline).
|
||||
2. **A1 + A2** land together (P0 fixes + telemetry) → deploy to .116/.198 →
|
||||
Phase A4 checks on the pair → framework-pt → full fleet.
|
||||
3. **B1 + B2 + B3** (composable + rpc-client + Cloud) in parallel with A-testing —
|
||||
frontend-only, verifiable against .116 dev (`reference_neode_ui_dev_testing`).
|
||||
4. **A3** after telemetry baseline exists; **B4/B5** ride the same or next OTA.
|
||||
5. Gate: Phase A4 checklist green + Part B verification on-device + existing
|
||||
single-node gate stays green → tag/OTA per ship ritual.
|
||||
|
||||
## Success criteria
|
||||
- `content.browse-peer` transport = fips for ≥99% of calls between healthy 0.4.1
|
||||
nodes over 24h (measured by the new counters), Tor reserved for genuinely
|
||||
FIPS-unreachable peers (.116-WiFi-class networks).
|
||||
- Cloud revisit paints in <100ms from cache; fresh data within one revalidate.
|
||||
- Fallback counters visible in `fips.status` so regressions are caught on the
|
||||
dashboard, not by users.
|
||||
@ -4,16 +4,6 @@ export interface RPCOptions {
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
timeout?: number
|
||||
/** Abort the call (and any pending retries) from the outside — pass a
|
||||
* component-scoped controller's signal so fan-outs stop on unmount. */
|
||||
signal?: AbortSignal
|
||||
/** Per-call retry budget (default 3). Use 1 for calls whose caller has its
|
||||
* own timeout/fallback UX — retry×3 on a slow peer is how one unreachable
|
||||
* node turns a 30s timeout into a 90s spinner. */
|
||||
maxRetries?: number
|
||||
/** Collapse concurrent identical calls (same method + params) into one
|
||||
* request. Opt-in: only safe for reads. */
|
||||
dedup?: boolean
|
||||
}
|
||||
|
||||
export interface RPCResponse<T> {
|
||||
@ -84,35 +74,18 @@ function getCsrfToken(): string | null {
|
||||
class RPCClient {
|
||||
private static _sessionExpiredRedirecting = false
|
||||
private baseUrl: string
|
||||
/** In-flight dedup map for `dedup: true` calls, keyed method+params. */
|
||||
private inflight = new Map<string, Promise<unknown>>()
|
||||
|
||||
constructor(baseUrl: string = '/rpc/v1') {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
async call<T>(options: RPCOptions): Promise<T> {
|
||||
if (options.dedup) {
|
||||
const key = `${options.method}:${JSON.stringify(options.params ?? {})}`
|
||||
const existing = this.inflight.get(key)
|
||||
if (existing) return existing as Promise<T>
|
||||
const p = this.callInner<T>(options).finally(() => this.inflight.delete(key))
|
||||
this.inflight.set(key, p)
|
||||
return p
|
||||
}
|
||||
return this.callInner<T>(options)
|
||||
}
|
||||
|
||||
private async callInner<T>(options: RPCOptions): Promise<T> {
|
||||
const { method, params = {}, timeout = 15000, signal: external } = options
|
||||
const maxRetries = Math.max(1, options.maxRetries ?? 3)
|
||||
const { method, params = {}, timeout = 15000 } = options
|
||||
const maxRetries = 3
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
if (external?.aborted) throw new Error('Aborted')
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
const onExternalAbort = () => controller.abort()
|
||||
external?.addEventListener('abort', onExternalAbort, { once: true })
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@ -132,7 +105,6 @@ class RPCClient {
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
external?.removeEventListener('abort', onExternalAbort)
|
||||
|
||||
if (!response.ok) {
|
||||
// Session expired — debounced redirect to login
|
||||
@ -195,11 +167,8 @@ class RPCClient {
|
||||
return data.result as T
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
external?.removeEventListener('abort', onExternalAbort)
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
// Caller-initiated abort is final — never retried.
|
||||
if (external?.aborted) throw new Error('Aborted')
|
||||
const timeoutErr = new Error('Request timeout')
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = 600 * (attempt + 1)
|
||||
|
||||
@ -377,9 +377,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import { useTxExplorer } from '@/composables/useTxExplorer'
|
||||
|
||||
defineProps<{ compact?: boolean }>()
|
||||
@ -463,41 +462,11 @@ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: n
|
||||
{ key: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
// Cached: revisits paint the channel lists instantly and revalidate behind
|
||||
// them. Open and closed history are separate entries so a closed-history
|
||||
// failure keeps its last list without touching the main channel view.
|
||||
interface ChannelsData { channels: Channel[]; total_inbound: number; total_outbound: number }
|
||||
const channelsRes = useCachedResource<ChannelsData>({
|
||||
key: 'lnd.channels',
|
||||
fetcher: async (signal) => {
|
||||
const result = await rpcClient.call<ChannelsData>({
|
||||
method: 'lnd.listchannels', timeout: 15000, signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return {
|
||||
channels: result?.channels || [],
|
||||
total_inbound: result?.total_inbound || 0,
|
||||
total_outbound: result?.total_outbound || 0,
|
||||
}
|
||||
},
|
||||
})
|
||||
const closedRes = useCachedResource<ClosedChannel[]>({
|
||||
key: 'lnd.closed-channels',
|
||||
fetcher: async (signal) => {
|
||||
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
|
||||
method: 'lnd.closedchannels', timeout: 15000, signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return closed?.channels || []
|
||||
},
|
||||
})
|
||||
const loading = computed(() =>
|
||||
channelsRes.loadState.value === 'loading' || channelsRes.loadState.value === 'refreshing')
|
||||
const error = computed(() => channelsRes.error.value)
|
||||
const channels = computed(() => channelsRes.data.value?.channels ?? [])
|
||||
const closedChannels = computed(() => closedRes.data.value ?? [])
|
||||
const summary = computed(() => ({
|
||||
total_inbound: channelsRes.data.value?.total_inbound ?? 0,
|
||||
total_outbound: channelsRes.data.value?.total_outbound ?? 0,
|
||||
}))
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const channels = ref<Channel[]>([])
|
||||
const closedChannels = ref<ClosedChannel[]>([])
|
||||
const summary = ref({ total_inbound: 0, total_outbound: 0 })
|
||||
|
||||
// Olympus by ZEUS — the LSP node behind the Zeus mobile wallet.
|
||||
// Channel limits: min 150,000 / max 1,500,000 sats.
|
||||
@ -569,10 +538,37 @@ function capacityPercent(amount: number, capacity: number): number {
|
||||
return Math.round((amount / capacity) * 100)
|
||||
}
|
||||
|
||||
function loadChannels(): Promise<void> {
|
||||
const main = channelsRes.refresh()
|
||||
void closedRes.refresh()
|
||||
return main
|
||||
async function loadChannels() {
|
||||
const hadChannels = channels.value.length > 0
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await rpcClient.call<{ channels: Channel[]; total_inbound: number; total_outbound: number }>({
|
||||
method: 'lnd.listchannels',
|
||||
timeout: 15000,
|
||||
})
|
||||
channels.value = result.channels || []
|
||||
summary.value = {
|
||||
total_inbound: result.total_inbound || 0,
|
||||
total_outbound: result.total_outbound || 0,
|
||||
}
|
||||
// Closed history is a separate RPC — a failure here keeps the previous
|
||||
// list rather than blanking the main channel view.
|
||||
try {
|
||||
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
|
||||
method: 'lnd.closedchannels',
|
||||
timeout: 15000,
|
||||
})
|
||||
closedChannels.value = closed.channels || []
|
||||
} catch {
|
||||
/* keep previous closed list */
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load channels'
|
||||
if (!hadChannels) channels.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
|
||||
@ -651,5 +647,7 @@ async function closeChannel() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadChannels)
|
||||
|
||||
defineExpose({ channels, loadChannels })
|
||||
</script>
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
// Stale-while-revalidate resource hook over the shared resources store.
|
||||
//
|
||||
// Usage:
|
||||
// const files = useCachedResource<CloudFile[]>({
|
||||
// key: 'cloud.my-files',
|
||||
// fetcher: (signal) => rpcClient.call({ method: 'content.list', signal, dedup: true }),
|
||||
// ttlMs: 30_000,
|
||||
// })
|
||||
// // template: files.data renders instantly on revisit (cache), while
|
||||
// // files.loadState === 'refreshing' drives a subtle refresh indicator.
|
||||
//
|
||||
// Behavior:
|
||||
// - Synchronous hydrate: memory (survives navigation) → sessionStorage
|
||||
// snapshot (survives reload) → fetch.
|
||||
// - Sticky-ready: never regresses ready → loading; refreshes are
|
||||
// 'refreshing' so content stays on screen.
|
||||
// - Stale-while-revalidate: on mount, cached data is shown immediately and a
|
||||
// background refresh runs only if the TTL has lapsed (or never fetched).
|
||||
// - Keep-last-value on error, with `isStale`/`ageMs` for badges.
|
||||
// - revalidateOnFocus: refreshes when the tab regains focus and the data is
|
||||
// stale (debounced by TTL, so focus-flapping is free).
|
||||
// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when
|
||||
// the last subscribed component unmounts.
|
||||
|
||||
import { computed, getCurrentScope, onScopeDispose, type ComputedRef } from 'vue'
|
||||
import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
|
||||
|
||||
export interface CachedResourceOptions<T> {
|
||||
/** Cache key. Include identifying params, e.g. `peer-files:${onion}`. */
|
||||
key: string
|
||||
/** Fetch fresh data. Receives an abort signal tied to component lifetime. */
|
||||
fetcher: (signal: AbortSignal) => Promise<T>
|
||||
/** Data older than this triggers a background revalidate (default 30s). */
|
||||
ttlMs?: number
|
||||
/** Snapshot to sessionStorage so reloads paint instantly (default true).
|
||||
* Disable for large payloads. */
|
||||
persist?: boolean
|
||||
/** Revalidate (if stale) when the window regains focus (default true). */
|
||||
revalidateOnFocus?: boolean
|
||||
/** Fetch on first use (default true). Set false for lazy resources. */
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
export interface CachedResource<T> {
|
||||
entry: ResourceEntry<T>
|
||||
/** Convenience computed views over the entry. */
|
||||
data: ComputedRef<T | null>
|
||||
loadState: ComputedRef<ResourceLoadState>
|
||||
error: ComputedRef<string | null>
|
||||
/** True when data exists but is older than the TTL (drive an age badge). */
|
||||
isStale: ComputedRef<boolean>
|
||||
ageMs: ComputedRef<number | null>
|
||||
/** Force a refresh now (deduped with any in-flight one). */
|
||||
refresh: () => Promise<void>
|
||||
/** Mark stale + debounce-refresh all mounted users of this key. */
|
||||
invalidate: () => void
|
||||
/** Optimistically update cached data; returns rollback for RPC failure. */
|
||||
optimistic: (update: (current: T | null) => T) => () => void
|
||||
}
|
||||
|
||||
export function useCachedResource<T>(opts: CachedResourceOptions<T>): CachedResource<T> {
|
||||
const store = useResourcesStore()
|
||||
const ttlMs = opts.ttlMs ?? 30_000
|
||||
const persist = opts.persist ?? true
|
||||
const entry = store.entry<T>(opts.key, persist)
|
||||
|
||||
const aborter = new AbortController()
|
||||
const fetcher = () => opts.fetcher(aborter.signal)
|
||||
const refresh = () => store.refresh(opts.key, fetcher, { persist })
|
||||
|
||||
const stale = () => entry.fetchedAt === null || Date.now() - entry.fetchedAt > ttlMs
|
||||
const refreshIfStale = () => {
|
||||
if (stale()) void refresh()
|
||||
}
|
||||
|
||||
// Register as a live revalidator so invalidate(key) reaches us.
|
||||
const unsubscribe = store.subscribe(opts.key, () => void refresh())
|
||||
|
||||
const onFocus = () => refreshIfStale()
|
||||
if (opts.revalidateOnFocus ?? true) {
|
||||
window.addEventListener('focus', onFocus)
|
||||
}
|
||||
|
||||
// Tied to the owning effect scope (component setup or manual scope);
|
||||
// outside any scope (tests, module init) there's nothing to dispose.
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(() => {
|
||||
unsubscribe()
|
||||
window.removeEventListener('focus', onFocus)
|
||||
aborter.abort()
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.immediate ?? true) refreshIfStale()
|
||||
|
||||
return {
|
||||
entry,
|
||||
data: computed(() => entry.data),
|
||||
loadState: computed(() => entry.loadState),
|
||||
error: computed(() => entry.error),
|
||||
isStale: computed(() => entry.data !== null && stale()),
|
||||
ageMs: computed(() => (entry.fetchedAt === null ? null : Date.now() - entry.fetchedAt)),
|
||||
refresh,
|
||||
invalidate: () => store.invalidate(opts.key),
|
||||
optimistic: (update) => store.optimistic<T>(opts.key, update),
|
||||
}
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
import { useResourcesStore } from '../resources'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
|
||||
describe('resources store — stale-while-revalidate semantics', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
sessionStorage.clear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('first fetch goes idle → loading → ready with data', async () => {
|
||||
const store = useResourcesStore()
|
||||
const e = store.entry<string>('k1')
|
||||
expect(e.loadState).toBe('idle')
|
||||
const p = store.refresh('k1', async () => 'hello')
|
||||
expect(e.loadState).toBe('loading')
|
||||
await p
|
||||
expect(e.loadState).toBe('ready')
|
||||
expect(e.data).toBe('hello')
|
||||
expect(e.fetchedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('sticky-ready: refresh never regresses ready → loading', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k2', async () => 1)
|
||||
const e = store.entry<number>('k2')
|
||||
const p = store.refresh('k2', async () => 2)
|
||||
expect(e.loadState).toBe('refreshing')
|
||||
await p
|
||||
expect(e.loadState).toBe('ready')
|
||||
expect(e.data).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps last-known data on refresh error (ready + error set)', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k3', async () => 'good')
|
||||
const e = store.entry<string>('k3')
|
||||
await store.refresh('k3', async () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
expect(e.data).toBe('good')
|
||||
expect(e.loadState).toBe('ready')
|
||||
expect(e.error).toBe('boom')
|
||||
})
|
||||
|
||||
it('errors with no prior data land in error state', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k4', async () => {
|
||||
throw new Error('down')
|
||||
})
|
||||
const e = store.entry('k4')
|
||||
expect(e.loadState).toBe('error')
|
||||
expect(e.data).toBeNull()
|
||||
})
|
||||
|
||||
it('dedups concurrent refreshes for the same key', async () => {
|
||||
const store = useResourcesStore()
|
||||
const fetcher = vi.fn(async () => 'once')
|
||||
const p1 = store.refresh('k5', fetcher)
|
||||
const p2 = store.refresh('k5', fetcher)
|
||||
await Promise.all([p1, p2])
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hydrates a new entry from the sessionStorage snapshot', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k6', async () => ({ n: 42 }))
|
||||
// Fresh pinia = fresh memory cache, same sessionStorage.
|
||||
setActivePinia(createPinia())
|
||||
const store2 = useResourcesStore()
|
||||
const e = store2.entry<{ n: number }>('k6')
|
||||
expect(e.loadState).toBe('ready')
|
||||
expect(e.data).toEqual({ n: 42 })
|
||||
})
|
||||
|
||||
it('optimistic update applies immediately and rollback restores', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k7', async () => ['a'])
|
||||
const e = store.entry<string[]>('k7')
|
||||
const rollback = store.optimistic<string[]>('k7', (cur) => [...(cur ?? []), 'b'])
|
||||
expect(e.data).toEqual(['a', 'b'])
|
||||
rollback()
|
||||
expect(e.data).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('invalidate marks stale and debounce-runs subscribers', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k8', async () => 1)
|
||||
const revalidate = vi.fn()
|
||||
store.subscribe('k8', revalidate)
|
||||
store.invalidate('k8')
|
||||
expect(store.entry('k8').fetchedAt).toBeNull()
|
||||
expect(revalidate).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(900)
|
||||
expect(revalidate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCachedResource composable', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('fetches immediately when stale and exposes reactive views', async () => {
|
||||
const fetcher = vi.fn(async () => 'data')
|
||||
const r = useCachedResource<string>({ key: 'c1', fetcher, revalidateOnFocus: false })
|
||||
await r.refresh()
|
||||
expect(fetcher).toHaveBeenCalled()
|
||||
expect(r.data.value).toBe('data')
|
||||
expect(r.loadState.value).toBe('ready')
|
||||
expect(r.isStale.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refetch within TTL (instant render from cache)', async () => {
|
||||
const fetcher = vi.fn(async () => 'v1')
|
||||
const r1 = useCachedResource<string>({ key: 'c2', fetcher, ttlMs: 60_000, revalidateOnFocus: false })
|
||||
await r1.refresh()
|
||||
// Second component using the same key inside the TTL: no new fetch.
|
||||
const fetcher2 = vi.fn(async () => 'v2')
|
||||
const r2 = useCachedResource<string>({ key: 'c2', fetcher: fetcher2, ttlMs: 60_000, revalidateOnFocus: false })
|
||||
expect(r2.data.value).toBe('v1')
|
||||
expect(fetcher2).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -8,11 +8,6 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const authenticated = ref(false)
|
||||
// Per-path listing cache: re-entering a folder paints the last listing
|
||||
// immediately (no spinner) while the fresh listing loads behind it.
|
||||
const pathCache = new Map<string, FileBrowserItem[]>()
|
||||
// Last-wins guard for overlapping navigations (fast folder hopping).
|
||||
let navSeq = 0
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const parts = currentPath.value.split('/').filter(Boolean)
|
||||
@ -41,22 +36,7 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
}
|
||||
|
||||
async function navigate(path: string): Promise<void> {
|
||||
const seq = ++navSeq
|
||||
const apply = (p: string, result: FileBrowserItem[]) => {
|
||||
pathCache.set(p, result)
|
||||
if (seq !== navSeq) return // a newer navigation superseded this one
|
||||
items.value = result
|
||||
currentPath.value = p
|
||||
}
|
||||
// Stale-while-revalidate: show the cached listing for this path
|
||||
// immediately (no spinner), then refresh it underneath.
|
||||
const cached = pathCache.get(path)
|
||||
if (cached) {
|
||||
items.value = cached
|
||||
currentPath.value = path
|
||||
} else {
|
||||
loading.value = true
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (!authenticated.value) {
|
||||
@ -67,7 +47,9 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
}
|
||||
}
|
||||
try {
|
||||
apply(path, await fileBrowserClient.listDirectory(path))
|
||||
const result = await fileBrowserClient.listDirectory(path)
|
||||
items.value = result
|
||||
currentPath.value = path
|
||||
} catch {
|
||||
// Directory may not exist — try to create it, then retry
|
||||
if (path !== '/') {
|
||||
@ -75,20 +57,23 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
|
||||
const dirName = path.substring(path.lastIndexOf('/') + 1)
|
||||
await fileBrowserClient.createFolder(parentPath, dirName)
|
||||
apply(path, await fileBrowserClient.listDirectory(path))
|
||||
const result = await fileBrowserClient.listDirectory(path)
|
||||
items.value = result
|
||||
currentPath.value = path
|
||||
} catch {
|
||||
// Fall back to root
|
||||
apply('/', await fileBrowserClient.listDirectory('/'))
|
||||
const result = await fileBrowserClient.listDirectory('/')
|
||||
items.value = result
|
||||
currentPath.value = '/'
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to list root directory')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Keep showing the cached listing on a failed revalidate.
|
||||
if (!cached) error.value = e instanceof Error ? e.message : 'Failed to load files'
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load files'
|
||||
} finally {
|
||||
if (seq === navSeq) loading.value = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,7 +112,6 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
items.value = []
|
||||
loading.value = false
|
||||
error.value = null
|
||||
pathCache.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -1,166 +0,0 @@
|
||||
// Shared cache for RPC-backed page data (the "stale-while-revalidate" layer).
|
||||
//
|
||||
// Pages used to fetch-on-mount with a spinner on every navigation — Dashboard
|
||||
// keys its router-view by route.path, so each visit unmounted and refetched
|
||||
// everything. This store is the single place resource state lives instead:
|
||||
// keyed entries survive navigation (Pinia) and reloads (sessionStorage
|
||||
// snapshot), and `useCachedResource` renders them instantly while
|
||||
// revalidating in the background.
|
||||
//
|
||||
// Semantics (generalized from homeStatus.ts / useFleetData.ts, the proven
|
||||
// hand-rolled versions):
|
||||
// - sticky-ready: once a key is 'ready' it never regresses to 'loading';
|
||||
// refreshes show as 'refreshing' so the UI keeps the data visible.
|
||||
// - keep-last-known-value on error: a failed revalidate leaves data in place
|
||||
// (with `error` set and `fetchedAt` untouched → age badge shows staleness).
|
||||
// - in-flight dedup per key: concurrent refreshes collapse into one fetch.
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export type ResourceLoadState = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error'
|
||||
|
||||
export interface ResourceEntry<T = unknown> {
|
||||
data: T | null
|
||||
loadState: ResourceLoadState
|
||||
/** Epoch ms of the last SUCCESSFUL fetch (drives TTL + stale badges). */
|
||||
fetchedAt: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const SNAPSHOT_PREFIX = 'resource:'
|
||||
|
||||
function readSnapshot<T>(key: string): { data: T; fetchedAt: number } | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SNAPSHOT_PREFIX + key)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed.fetchedAt === 'number' && 'data' in parsed) return parsed
|
||||
} catch {
|
||||
/* corrupt/absent snapshot — fall through to a fresh fetch */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function writeSnapshot(key: string, data: unknown, fetchedAt: number): void {
|
||||
try {
|
||||
sessionStorage.setItem(SNAPSHOT_PREFIX + key, JSON.stringify({ data, fetchedAt }))
|
||||
} catch {
|
||||
/* quota exceeded or unserializable — memory cache still works */
|
||||
}
|
||||
}
|
||||
|
||||
export const useResourcesStore = defineStore('resources', () => {
|
||||
const entries = reactive(new Map<string, ResourceEntry>())
|
||||
// Non-reactive bookkeeping: in-flight fetches + active revalidators.
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const revalidators = new Map<string, Set<() => void>>()
|
||||
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/** Get (or create) the reactive entry for a key, hydrating from the
|
||||
* sessionStorage snapshot on first sight so revisits after a reload paint
|
||||
* before any RPC completes. Pass `persist: false` to skip snapshots. */
|
||||
function entry<T>(key: string, persist = true): ResourceEntry<T> {
|
||||
let e = entries.get(key)
|
||||
if (!e) {
|
||||
const snap = persist ? readSnapshot<T>(key) : null
|
||||
e = reactive<ResourceEntry>({
|
||||
data: snap ? snap.data : null,
|
||||
loadState: snap ? 'ready' : 'idle',
|
||||
fetchedAt: snap ? snap.fetchedAt : null,
|
||||
error: null,
|
||||
})
|
||||
entries.set(key, e)
|
||||
}
|
||||
return e as ResourceEntry<T>
|
||||
}
|
||||
|
||||
/** Run `fetcher` for `key` with sticky-ready + keep-last-value semantics.
|
||||
* Concurrent calls for the same key share one in-flight fetch. */
|
||||
function refresh<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
opts: { persist?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const existing = inflight.get(key)
|
||||
if (existing) return existing
|
||||
const e = entry<T>(key, opts.persist ?? true)
|
||||
e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading'
|
||||
const p = (async () => {
|
||||
try {
|
||||
const data = await fetcher()
|
||||
e.data = data
|
||||
e.error = null
|
||||
e.fetchedAt = Date.now()
|
||||
e.loadState = 'ready'
|
||||
if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt)
|
||||
} catch (err) {
|
||||
e.error = err instanceof Error ? err.message : String(err)
|
||||
// Keep last-known data visible; only 'error' when we have nothing.
|
||||
e.loadState = e.data !== null ? 'ready' : 'error'
|
||||
} finally {
|
||||
inflight.delete(key)
|
||||
}
|
||||
})()
|
||||
inflight.set(key, p)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Mark a key stale and (debounced) re-run every mounted subscriber's
|
||||
* fetcher. Call after a mutation or on a relevant WS push. */
|
||||
function invalidate(key: string, opts: { debounceMs?: number } = {}): void {
|
||||
const e = entries.get(key)
|
||||
if (e) e.fetchedAt = null
|
||||
const subs = revalidators.get(key)
|
||||
if (!subs || subs.size === 0) return
|
||||
const t = invalidateTimers.get(key)
|
||||
if (t) clearTimeout(t)
|
||||
invalidateTimers.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
invalidateTimers.delete(key)
|
||||
for (const fn of subs) fn()
|
||||
}, opts.debounceMs ?? 800),
|
||||
)
|
||||
}
|
||||
|
||||
/** Register a live revalidator for a key (used by useCachedResource);
|
||||
* returns an unsubscribe fn. */
|
||||
function subscribe(key: string, revalidate: () => void): () => void {
|
||||
let subs = revalidators.get(key)
|
||||
if (!subs) {
|
||||
subs = new Set()
|
||||
revalidators.set(key, subs)
|
||||
}
|
||||
subs.add(revalidate)
|
||||
return () => {
|
||||
subs.delete(revalidate)
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistically apply `update` to the cached value; returns a rollback.
|
||||
* Pattern: rollback on RPC failure (generalized TransportPrefsCard). */
|
||||
function optimistic<T>(key: string, update: (current: T | null) => T): () => void {
|
||||
const e = entry<T>(key)
|
||||
const before = e.data
|
||||
const beforeState = e.loadState
|
||||
e.data = update(before)
|
||||
if (e.loadState === 'idle' || e.loadState === 'error') e.loadState = 'ready'
|
||||
return () => {
|
||||
e.data = before
|
||||
e.loadState = beforeState
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a key entirely (memory + snapshot). */
|
||||
function evict(key: string): void {
|
||||
entries.delete(key)
|
||||
try {
|
||||
sessionStorage.removeItem(SNAPSHOT_PREFIX + key)
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, entry, refresh, invalidate, subscribe, optimistic, evict }
|
||||
})
|
||||
@ -2,38 +2,9 @@
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { DataModel, PatchOperation } from '../types/api'
|
||||
import type { DataModel } from '../types/api'
|
||||
import { wsClient, applyDataPatch } from '../api/websocket'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useResourcesStore } from './resources'
|
||||
|
||||
/** Unescape one JSON-pointer segment (RFC 6901: ~1 → '/', ~0 → '~'). */
|
||||
function pointerSegment(path: string, prefix: string): string {
|
||||
const seg = path.slice(prefix.length).split('/')[0] ?? ''
|
||||
return seg.replace(/~1/g, '/').replace(/~0/g, '~')
|
||||
}
|
||||
|
||||
/** B5: bridge /ws/db pushes into the cached-resource layer. Each patch op
|
||||
* maps to the resource keys whose backing data it changes; invalidate()
|
||||
* debounces (800ms) and only refetches keys with mounted subscribers, so a
|
||||
* patch storm costs one revalidation per key. The 30s staleness
|
||||
* reconciliation stays as the backstop for anything unmapped. */
|
||||
function invalidateResourcesForPatch(patch: PatchOperation[]): void {
|
||||
const resources = useResourcesStore()
|
||||
for (const op of patch) {
|
||||
const path = op.path ?? ''
|
||||
if (path.startsWith('/peer-health/')) {
|
||||
// A peer flipping reachability changes both its browse result and the
|
||||
// federation node list's online state.
|
||||
const onion = pointerSegment(path, '/peer-health/')
|
||||
if (onion) resources.invalidate(`cloud.peer-browse:${onion}`)
|
||||
resources.invalidate('federation.nodes')
|
||||
} else if (path.startsWith('/package-data/')) {
|
||||
// App installs/uninstalls add or remove their tor services.
|
||||
resources.invalidate('server.tor-services')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useSyncStore = defineStore('sync', () => {
|
||||
// State
|
||||
@ -137,7 +108,6 @@ export const useSyncStore = defineStore('sync', () => {
|
||||
try {
|
||||
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
|
||||
data.value = applyDataPatch(data.value, update.patch)
|
||||
invalidateResourcesForPatch(update.patch)
|
||||
// Mark as connected once we receive any valid patch
|
||||
if (!isConnected.value) {
|
||||
isConnected.value = true
|
||||
|
||||
@ -194,7 +194,7 @@
|
||||
Open Federation
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-else-if="filteredPeerFiles.length === 0 && peerFilesPending === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||
<div v-else-if="filteredPeerFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
@ -216,13 +216,6 @@
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="peerFilesPending > 0" class="text-[11px] text-white/35 text-center mt-3 flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Still fetching from {{ peerFilesPending }} peer{{ peerFilesPending === 1 ? '' : 's' }}…
|
||||
</p>
|
||||
<p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3">
|
||||
{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable — showing what answered.
|
||||
</p>
|
||||
@ -308,23 +301,12 @@
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
|
||||
{{ peer.trust_level }}
|
||||
</span>
|
||||
<!-- Live transport badge — which route actually served the last
|
||||
browse (FIPS = direct mesh, fast; Tor = fallback, slow). -->
|
||||
<span
|
||||
v-if="peerTransport(peer.onion)"
|
||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
|
||||
:class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300'"
|
||||
:title="`Last browse served via ${peerTransport(peer.onion)!.transport.toUpperCase()}`"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-400' : 'bg-amber-400'"></span>
|
||||
{{ peerTransport(peer.onion)!.transport.toUpperCase() }} · {{ (peerTransport(peer.onion)!.latencyMs / 1000).toFixed(1) }}s
|
||||
</span>
|
||||
<span v-else class="text-white/30">Peer Node</span>
|
||||
<span class="text-white/30">Peer Node</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="(peersLoading || peersRefreshing) && peerNodes.length > 0"
|
||||
v-if="peersLoading && peerNodes.length > 0"
|
||||
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
@ -406,8 +388,6 @@ import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useCloudStore } from '../stores/cloud'
|
||||
import { useResourcesStore } from '../stores/resources'
|
||||
import { useCachedResource } from '../composables/useCachedResource'
|
||||
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { getFileCategory } from '../composables/useFileType'
|
||||
@ -419,19 +399,9 @@ import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const cloudStore = useCloudStore()
|
||||
const resources = useResourcesStore()
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
// Section counts — cached: revisits render the last-known counts instantly
|
||||
// and refresh in the background (sticky-ready never regresses to "Loading").
|
||||
const countsResource = useCachedResource<Record<string, number>>({
|
||||
key: 'cloud.section-counts',
|
||||
fetcher: fetchCounts,
|
||||
ttlMs: 30_000,
|
||||
immediate: false, // gated on fileBrowserRunning; kicked from onMounted/watch
|
||||
})
|
||||
const sectionCounts = computed(() => countsResource.entry.data ?? {})
|
||||
const countsLoading = computed(() => countsResource.entry.loadState === 'loading')
|
||||
const sectionCounts = ref<Record<string, number>>({})
|
||||
const countsLoading = ref(false)
|
||||
|
||||
// ── Tabs / categories / search state ────────────────────────────────────────
|
||||
type TabId = 'folders' | 'mine' | 'peers' | 'paid'
|
||||
@ -454,18 +424,14 @@ const activeTab = ref<TabId>('folders')
|
||||
|
||||
// ── Paid Files tab ──────────────────────────────────────────────────────────
|
||||
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
|
||||
const paidResource = useCachedResource<PaidItem[]>({
|
||||
key: 'cloud.paid-items',
|
||||
fetcher: async (signal) => {
|
||||
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list', signal, dedup: true })
|
||||
return (res.items || []).slice().reverse()
|
||||
},
|
||||
immediate: false, // loaded when the Paid tab is opened
|
||||
})
|
||||
const paidItems = computed(() => paidResource.entry.data ?? [])
|
||||
const paidLoading = computed(() => paidResource.entry.loadState === 'loading')
|
||||
function loadPaidItems() {
|
||||
return paidResource.refresh()
|
||||
const paidItems = ref<PaidItem[]>([])
|
||||
const paidLoading = ref(false)
|
||||
async function loadPaidItems() {
|
||||
paidLoading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' })
|
||||
paidItems.value = (res.items || []).slice().reverse()
|
||||
} catch { paidItems.value = [] } finally { paidLoading.value = false }
|
||||
}
|
||||
async function viewPaidItem(it: PaidItem) {
|
||||
try {
|
||||
@ -507,21 +473,8 @@ interface PeerNode {
|
||||
trust_level: string
|
||||
}
|
||||
|
||||
// Federation peers — cached so the Folders tab's peer cards paint instantly
|
||||
// on revisit while the list revalidates behind them.
|
||||
const peersResource = useCachedResource<PeerNode[]>({
|
||||
key: 'cloud.peer-nodes',
|
||||
fetcher: async (signal) => {
|
||||
const result = await rpcClient.federationListNodes()
|
||||
void signal
|
||||
return result?.nodes ?? []
|
||||
},
|
||||
ttlMs: 30_000,
|
||||
immediate: false, // kicked from onMounted (keeps the legacy load order)
|
||||
})
|
||||
const peerNodes = computed(() => peersResource.entry.data ?? [])
|
||||
const peersLoading = computed(() => peersResource.entry.loadState === 'loading')
|
||||
const peersRefreshing = computed(() => peersResource.entry.loadState === 'refreshing')
|
||||
const peerNodes = ref<PeerNode[]>([])
|
||||
const peersLoading = ref(true)
|
||||
const loadError = ref('')
|
||||
|
||||
const APP_ALIASES: Record<string, string[]> = {
|
||||
@ -626,51 +579,42 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
// ── My Files (flat list of every own file across the sections) ──────────────
|
||||
// Cached: revisiting the tab renders the last walk instantly and re-walks in
|
||||
// the background only when stale.
|
||||
const myFilesResource = useCachedResource<FileBrowserItem[]>({
|
||||
key: 'cloud.my-files',
|
||||
fetcher: fetchMyFiles,
|
||||
ttlMs: 60_000,
|
||||
immediate: false, // loaded when the My Files tab (or search) needs it
|
||||
})
|
||||
const myFiles = computed(() => myFilesResource.entry.data ?? [])
|
||||
const myFilesLoading = computed(() => myFilesResource.entry.loadState === 'loading')
|
||||
const myFiles = ref<FileBrowserItem[]>([])
|
||||
const myFilesLoading = ref(false)
|
||||
const myFilesLoaded = ref(false)
|
||||
|
||||
/** Depth-limited walk of the section folders; flat file list, capped. */
|
||||
async function fetchMyFiles(): Promise<FileBrowserItem[]> {
|
||||
if (!fileBrowserRunning.value) return []
|
||||
const ok = await cloudStore.init()
|
||||
if (!ok) return []
|
||||
const out: FileBrowserItem[] = []
|
||||
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
|
||||
if (sectionId === 'files') continue // '/' would double-visit the sections
|
||||
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
|
||||
while (queue.length > 0 && out.length < 500) {
|
||||
const { path, depth } = queue.shift()!
|
||||
let items: FileBrowserItem[]
|
||||
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
|
||||
for (const item of items) {
|
||||
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
|
||||
if (item.isDir) {
|
||||
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
|
||||
} else {
|
||||
out.push({ ...item, path: itemPath })
|
||||
async function loadMyFiles(force = false) {
|
||||
if (myFilesLoading.value || (myFilesLoaded.value && !force)) return
|
||||
if (!fileBrowserRunning.value) { myFilesLoaded.value = true; return }
|
||||
myFilesLoading.value = true
|
||||
try {
|
||||
const ok = await cloudStore.init()
|
||||
if (!ok) return
|
||||
const out: FileBrowserItem[] = []
|
||||
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
|
||||
if (sectionId === 'files') continue // '/' would double-visit the sections
|
||||
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
|
||||
while (queue.length > 0 && out.length < 500) {
|
||||
const { path, depth } = queue.shift()!
|
||||
let items: FileBrowserItem[]
|
||||
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
|
||||
for (const item of items) {
|
||||
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
|
||||
if (item.isDir) {
|
||||
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
|
||||
} else {
|
||||
out.push({ ...item, path: itemPath })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.name.localeCompare(b.name))
|
||||
myFiles.value = out
|
||||
myFilesLoaded.value = true
|
||||
} finally {
|
||||
myFilesLoading.value = false
|
||||
}
|
||||
out.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return out
|
||||
}
|
||||
|
||||
/** Load if never fetched or stale; `force` always re-walks. */
|
||||
function loadMyFiles(force = false): Promise<void> {
|
||||
if (force) return myFilesResource.refresh()
|
||||
if (myFilesResource.entry.data === null || myFilesResource.isStale.value) {
|
||||
return myFilesResource.refresh()
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const filteredMyFiles = computed(() =>
|
||||
@ -724,8 +668,7 @@ function handlePreview(path: string, context: FileBrowserItem[]) {
|
||||
async function handleDelete(path: string) {
|
||||
try {
|
||||
await cloudStore.deleteItem(path)
|
||||
// Delete confirmed — update the cache in place (no rollback needed).
|
||||
myFilesResource.optimistic((cur) => (cur ?? []).filter(f => f.path !== path))
|
||||
myFiles.value = myFiles.value.filter(f => f.path !== path)
|
||||
searchResults.value = searchResults.value.filter(r => r.item?.path !== path)
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof Error ? e.message : 'Delete failed'
|
||||
@ -743,6 +686,11 @@ interface PeerFileEntry {
|
||||
peerOnion: string
|
||||
}
|
||||
|
||||
const peerFiles = ref<PeerFileEntry[]>([])
|
||||
const peerFilesLoading = ref(false)
|
||||
const peerFilesLoaded = ref(false)
|
||||
const peerFilesErrors = ref(0)
|
||||
|
||||
interface CatalogItem {
|
||||
id: string
|
||||
filename: string
|
||||
@ -756,96 +704,46 @@ function priceOf(access: CatalogItem['access']): number {
|
||||
return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0
|
||||
}
|
||||
|
||||
// Per-peer browse results live as individual cached entries so (a) each
|
||||
// peer's card/rows render the moment THAT peer answers — no more blocking on
|
||||
// the slowest peer via Promise.allSettled — and (b) revisits paint from
|
||||
// cache. The response's `transport` (fips/tor) + measured latency ride
|
||||
// along, giving every peer a live transport badge.
|
||||
interface PeerBrowse {
|
||||
items: CatalogItem[]
|
||||
transport: string | null
|
||||
latencyMs: number
|
||||
}
|
||||
|
||||
const peerBrowseKey = (onion: string) => `cloud.peer-browse:${onion}`
|
||||
|
||||
function browsePeer(peer: PeerNode): Promise<void> {
|
||||
return resources.refresh<PeerBrowse>(peerBrowseKey(peer.onion), async () => {
|
||||
const t0 = Date.now()
|
||||
const res = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
|
||||
method: 'content.browse-peer',
|
||||
params: { onion: peer.onion },
|
||||
timeout: 30000,
|
||||
// One slow/unreachable peer must cost its timeout ONCE, not ×3 —
|
||||
// the retry loop is why one dead peer meant a 90s spinner.
|
||||
maxRetries: 1,
|
||||
dedup: true,
|
||||
})
|
||||
return { items: res?.items ?? [], transport: res?.transport ?? null, latencyMs: Date.now() - t0 }
|
||||
})
|
||||
}
|
||||
|
||||
function peerBrowseEntry(onion: string) {
|
||||
return resources.entry<PeerBrowse>(peerBrowseKey(onion))
|
||||
}
|
||||
|
||||
/** Transport badge data for a peer (null until its first browse resolves). */
|
||||
function peerTransport(onion: string): { transport: string; latencyMs: number } | null {
|
||||
const e = peerBrowseEntry(onion)
|
||||
if (!e.data?.transport) return null
|
||||
return { transport: e.data.transport, latencyMs: e.data.latencyMs }
|
||||
}
|
||||
|
||||
/** Aggregated peer files, incrementally updated as each peer resolves. */
|
||||
const peerFiles = computed<PeerFileEntry[]>(() => {
|
||||
const merged: PeerFileEntry[] = []
|
||||
for (const peer of peerNodes.value) {
|
||||
const e = peerBrowseEntry(peer.onion)
|
||||
if (!e.data) continue
|
||||
const peerName = peer.name || peerDisplayName(peer.did)
|
||||
for (const item of e.data.items) {
|
||||
merged.push({
|
||||
key: `${peer.onion}:${item.id}`,
|
||||
filename: item.filename,
|
||||
sizeBytes: item.size_bytes,
|
||||
priceSats: priceOf(item.access),
|
||||
category: categoryOf(item.mime_type || item.filename),
|
||||
peerName,
|
||||
peerOnion: peer.onion,
|
||||
})
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||
return merged
|
||||
})
|
||||
|
||||
/** Peers still on their first in-flight browse (nothing cached yet). */
|
||||
const peerFilesPending = computed(() =>
|
||||
peerNodes.value.filter(p => {
|
||||
const s = peerBrowseEntry(p.onion).loadState
|
||||
return s === 'loading' || s === 'idle'
|
||||
}).length,
|
||||
)
|
||||
/** Peers whose browse failed with no cached data to show. */
|
||||
const peerFilesErrors = computed(() =>
|
||||
peerNodes.value.filter(p => peerBrowseEntry(p.onion).loadState === 'error').length,
|
||||
)
|
||||
/** All-or-nothing spinner ONLY when nothing has ever been cached. */
|
||||
const peerFilesLoading = computed(() =>
|
||||
peerNodes.value.length > 0 && peerFiles.value.length === 0 && peerFilesPending.value > 0 && peerFilesErrors.value < peerNodes.value.length,
|
||||
)
|
||||
|
||||
/** Fan out content.browse-peer; each peer renders as it resolves. */
|
||||
/** Fan out content.browse-peer over every federation node; tolerate stragglers. */
|
||||
async function loadPeerFiles(force = false) {
|
||||
if (peerNodes.value.length === 0) await loadPeers()
|
||||
const targets = peerNodes.value.filter(p => {
|
||||
if (force) return true
|
||||
const e = peerBrowseEntry(p.onion)
|
||||
const stale = e.fetchedAt === null || Date.now() - e.fetchedAt > 30_000
|
||||
return e.loadState === 'idle' || e.loadState === 'error' ? true : stale
|
||||
})
|
||||
// Fire-and-collect: the computed aggregation updates per resolution.
|
||||
await Promise.allSettled(targets.map(p => browsePeer(p)))
|
||||
if (peerFilesLoading.value || (peerFilesLoaded.value && !force)) return
|
||||
peerFilesLoading.value = true
|
||||
peerFilesErrors.value = 0
|
||||
try {
|
||||
if (peerNodes.value.length === 0) await loadPeers()
|
||||
const results = await Promise.allSettled(
|
||||
peerNodes.value.map(async (peer) => {
|
||||
const res = await rpcClient.call<{ items?: CatalogItem[] }>({
|
||||
method: 'content.browse-peer',
|
||||
params: { onion: peer.onion },
|
||||
timeout: 30000,
|
||||
})
|
||||
return { peer, items: res?.items ?? [] }
|
||||
}),
|
||||
)
|
||||
const merged: PeerFileEntry[] = []
|
||||
for (const r of results) {
|
||||
if (r.status !== 'fulfilled') { peerFilesErrors.value++; continue }
|
||||
const { peer, items } = r.value
|
||||
const peerName = peer.name || peerDisplayName(peer.did)
|
||||
for (const item of items) {
|
||||
merged.push({
|
||||
key: `${peer.onion}:${item.id}`,
|
||||
filename: item.filename,
|
||||
sizeBytes: item.size_bytes,
|
||||
priceSats: priceOf(item.access),
|
||||
category: categoryOf(item.mime_type || item.filename),
|
||||
peerName,
|
||||
peerOnion: peer.onion,
|
||||
})
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||
peerFiles.value = merged
|
||||
peerFilesLoaded.value = true
|
||||
} finally {
|
||||
peerFilesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPeerFiles = computed(() =>
|
||||
@ -923,50 +821,47 @@ const searchMineItems = computed(() =>
|
||||
)
|
||||
|
||||
// ── Existing counts / peers loading ──────────────────────────────────────────
|
||||
async function fetchCounts(): Promise<Record<string, number>> {
|
||||
if (!fileBrowserRunning.value) return {}
|
||||
const ok = await fileBrowserClient.login()
|
||||
if (!ok) throw new Error('File Browser login failed')
|
||||
const counts: Record<string, number> = {}
|
||||
for (const section of contentSections) {
|
||||
const path = SECTION_PATHS[section.id]
|
||||
if (!path) continue
|
||||
try {
|
||||
counts[section.id] = (await fileBrowserClient.listDirectory(path)).length
|
||||
} catch {
|
||||
counts[section.id] = 0
|
||||
async function loadCounts() {
|
||||
if (!fileBrowserRunning.value) return
|
||||
countsLoading.value = true
|
||||
try {
|
||||
const ok = await fileBrowserClient.login()
|
||||
if (!ok) return
|
||||
for (const section of contentSections) {
|
||||
const path = SECTION_PATHS[section.id]
|
||||
if (!path) continue
|
||||
try {
|
||||
const items = await fileBrowserClient.listDirectory(path)
|
||||
sectionCounts.value[section.id] = items.length
|
||||
} catch {
|
||||
sectionCounts.value[section.id] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
function loadCounts() {
|
||||
if (countsResource.entry.data === null || countsResource.isStale.value) {
|
||||
void countsResource.refresh()
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof Error ? e.message : 'Failed to load file counts'
|
||||
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed', e)
|
||||
} finally {
|
||||
countsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
loadCounts()
|
||||
await loadPeers()
|
||||
// Warm the per-peer browse cache in the background: peer cards get their
|
||||
// FIPS/Tor badge and the Peer Files tab is instant. Staleness-gated, so
|
||||
// quick revisits don't refetch.
|
||||
void loadPeerFiles()
|
||||
})
|
||||
|
||||
// File Browser can finish its startup scan after we mount — pick counts up
|
||||
// the moment it becomes available instead of showing a permanent blank.
|
||||
watch(fileBrowserRunning, (running) => {
|
||||
if (running) loadCounts()
|
||||
loadPeers()
|
||||
})
|
||||
|
||||
async function loadPeers() {
|
||||
await peersResource.refresh()
|
||||
// Surface refresh failures in the banner — the cached peer list stays
|
||||
// visible either way (keep-last-known-value).
|
||||
const e = peersResource.entry
|
||||
if (e.error) loadError.value = e.error
|
||||
const hadPeers = peerNodes.value.length > 0
|
||||
peersLoading.value = true
|
||||
try {
|
||||
const result = await rpcClient.federationListNodes()
|
||||
peerNodes.value = result?.nodes ?? []
|
||||
} catch (e) {
|
||||
if (!hadPeers) peerNodes.value = []
|
||||
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
|
||||
} finally {
|
||||
peersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function peerDisplayName(did: string): string {
|
||||
|
||||
@ -306,12 +306,12 @@ const backLabel = computed(() => {
|
||||
return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder'
|
||||
})
|
||||
|
||||
// Initialize native file browser when entering a native-UI section.
|
||||
// No reset() here: navigate() serves the per-path cache instantly and
|
||||
// revalidates underneath — resetting wiped the listing and forced a
|
||||
// spinner on every folder entry.
|
||||
// Initialize native file browser when entering a native-UI section
|
||||
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
|
||||
if (native && sec) {
|
||||
if (cloudStore.currentPath !== path) {
|
||||
cloudStore.reset()
|
||||
}
|
||||
const ok = await cloudStore.init()
|
||||
if (ok) {
|
||||
await cloudStore.navigate(path)
|
||||
|
||||
@ -199,9 +199,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
interface Identity {
|
||||
@ -229,30 +228,9 @@ interface Credential {
|
||||
status: string
|
||||
}
|
||||
|
||||
// Cached: revisits paint identities/credentials instantly and revalidate
|
||||
// behind them (errors keep the last-known lists).
|
||||
const identitiesRes = useCachedResource<Identity[]>({
|
||||
key: 'credentials.identities',
|
||||
fetcher: async (signal) => {
|
||||
const result = await rpcClient.call<{ identities: Identity[] }>({
|
||||
method: 'identity.list', params: {}, signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return result?.identities || []
|
||||
},
|
||||
})
|
||||
const credentialsRes = useCachedResource<Credential[]>({
|
||||
key: 'credentials.list',
|
||||
fetcher: async (signal) => {
|
||||
const result = await rpcClient.call<{ credentials: Credential[] }>({
|
||||
method: 'identity.list-credentials', params: {}, signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return result?.credentials || []
|
||||
},
|
||||
})
|
||||
const identities = computed(() => identitiesRes.data.value ?? [])
|
||||
const credentials = computed(() => credentialsRes.data.value ?? [])
|
||||
const loadingCreds = computed(() =>
|
||||
credentialsRes.loadState.value === 'loading' || credentialsRes.loadState.value === 'refreshing')
|
||||
const identities = ref<Identity[]>([])
|
||||
const credentials = ref<Credential[]>([])
|
||||
const loadingCreds = ref(false)
|
||||
const selectedCredential = ref<Credential | null>(null)
|
||||
const credCopied = ref(false)
|
||||
const revoking = ref(false)
|
||||
@ -302,10 +280,31 @@ function formatClaims(subject: Record<string, unknown>): string {
|
||||
return JSON.stringify(claims, null, 2)
|
||||
}
|
||||
|
||||
async function loadIdentities() {
|
||||
try {
|
||||
const result = await rpcClient.call<{ identities: Identity[] }>({
|
||||
method: 'identity.list',
|
||||
params: {},
|
||||
})
|
||||
identities.value = result.identities || []
|
||||
} catch (e) {
|
||||
identities.value = []
|
||||
if (import.meta.env.DEV) console.warn('Failed to load identities:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCredentials() {
|
||||
await credentialsRes.refresh()
|
||||
if (credentialsRes.error.value) {
|
||||
showToast(`Failed to load credentials: ${credentialsRes.error.value}`, 'error')
|
||||
loadingCreds.value = true
|
||||
try {
|
||||
const result = await rpcClient.call<{ credentials: Credential[] }>({
|
||||
method: 'identity.list-credentials',
|
||||
params: {},
|
||||
})
|
||||
credentials.value = result.credentials || []
|
||||
} catch (e) {
|
||||
showToast(`Failed to load credentials: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error')
|
||||
} finally {
|
||||
loadingCreds.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -405,8 +404,9 @@ async function copyCredentialJson() {
|
||||
setTimeout(() => { credCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
// Both resources fetch themselves on first use (skipping the fetch entirely
|
||||
// when the cached value is fresh).
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadIdentities(), loadCredentials()])
|
||||
})
|
||||
|
||||
defineExpose({ credentials, loadCredentials })
|
||||
</script>
|
||||
|
||||
@ -229,7 +229,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import { useTransportStore } from '@/stores/transport'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useSyncStore } from '@/stores/sync'
|
||||
@ -251,15 +250,8 @@ const transportStore = useTransportStore()
|
||||
const appStore = useAppStore()
|
||||
const syncStore = useSyncStore()
|
||||
|
||||
// Cached: revisits paint the node list instantly; the 5s poll and mutation
|
||||
// refreshes revalidate behind it. `loading` is initial-load only (background
|
||||
// refreshes keep content on screen — the old showLoader:false semantics).
|
||||
const nodesRes = useCachedResource<FederatedNode[]>({
|
||||
key: 'federation.nodes',
|
||||
fetcher: async () => (await rpcClient.federationListNodes()).nodes,
|
||||
})
|
||||
const nodes = computed(() => nodesRes.data.value ?? [])
|
||||
const loading = computed(() => nodesRes.loadState.value === 'loading')
|
||||
const nodes = ref<FederatedNode[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const selectedNode = ref<FederatedNode | null>(null)
|
||||
const inviteType = ref<'trusted' | 'observer'>('trusted')
|
||||
@ -328,12 +320,7 @@ const mapLinks = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const dwnStatusRes = useCachedResource<DwnStatus>({
|
||||
key: 'federation.dwn-status',
|
||||
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),
|
||||
immediate: false,
|
||||
})
|
||||
const dwnStatus = computed(() => dwnStatusRes.data.value)
|
||||
const dwnStatus = ref<DwnStatus | null>(null)
|
||||
const dwnSyncing = ref(false)
|
||||
|
||||
const dwnSyncDotClass = computed(() => {
|
||||
@ -513,13 +500,25 @@ function isOnlineCheck(node: FederatedNode): boolean {
|
||||
return lastSeen > tenMinutesAgo
|
||||
}
|
||||
|
||||
/** Explicit reload (mutations, retry): surfaces a load failure in the error
|
||||
* banner. The background poll calls nodesRes.refresh() directly and stays
|
||||
* silent, like the old surfaceErrors:false path. */
|
||||
async function loadNodes() {
|
||||
await nodesRes.refresh()
|
||||
if (nodesRes.error.value) error.value = nodesRes.error.value
|
||||
else error.value = ''
|
||||
return loadNodesWithOptions()
|
||||
}
|
||||
|
||||
async function loadNodesWithOptions(options: { showLoader?: boolean; surfaceErrors?: boolean } = {}) {
|
||||
const showLoader = options.showLoader ?? nodes.value.length === 0
|
||||
const surfaceErrors = options.surfaceErrors ?? true
|
||||
try {
|
||||
if (showLoader) loading.value = true
|
||||
const result = await rpcClient.federationListNodes()
|
||||
nodes.value = result.nodes
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
if (surfaceErrors) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
|
||||
}
|
||||
} finally {
|
||||
if (showLoader) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleGenerateInvite(type: 'trusted' | 'observer') {
|
||||
@ -611,8 +610,13 @@ async function deployApp(did: string, appId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function loadDwnStatus() {
|
||||
return dwnStatusRes.refresh()
|
||||
async function loadDwnStatus() {
|
||||
try {
|
||||
const result = await rpcClient.call<DwnStatus>({ method: 'dwn.status' })
|
||||
dwnStatus.value = result
|
||||
} catch {
|
||||
dwnStatus.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerDwnSync() {
|
||||
@ -677,6 +681,7 @@ async function rotateDid(password: string) {
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
loadNodesWithOptions({ showLoader: true })
|
||||
loadDwnStatus()
|
||||
loadDiscoveryState()
|
||||
loadPendingRequests()
|
||||
@ -689,7 +694,7 @@ onMounted(async () => {
|
||||
// Self DID not available
|
||||
}
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
void nodesRes.refresh()
|
||||
loadNodesWithOptions({ showLoader: false, surfaceErrors: false })
|
||||
loadPendingRequests()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
@ -218,7 +218,6 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import LineChart from '@/components/LineChart.vue'
|
||||
@ -292,51 +291,11 @@ const backTarget = computed(() => (cameFromHome.value ? '/dashboard' : '/dashboa
|
||||
const backLabel = computed(() => (cameFromHome.value ? t('common.back') : 'Web5'))
|
||||
const homeStatus = useHomeStatusStore()
|
||||
|
||||
// Cached: revisits paint the last snapshot/chart/alerts instantly and the 5s
|
||||
// poll revalidates behind them; errors keep the last-known values.
|
||||
const currentRes = useCachedResource<MetricSnapshot>({
|
||||
key: 'monitoring.current',
|
||||
fetcher: async (signal) => {
|
||||
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
|
||||
method: 'monitoring.current', signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
if (!data || !('system' in data)) throw new Error('metrics not ready')
|
||||
return data
|
||||
},
|
||||
})
|
||||
const historyRes = useCachedResource<MetricSnapshot[]>({
|
||||
key: 'monitoring.history.minute60',
|
||||
fetcher: async (signal) => {
|
||||
const data = await rpcClient.call<HistoryResponse>({
|
||||
method: 'monitoring.history', params: { resolution: 'minute', count: 60 },
|
||||
signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return data?.data ?? []
|
||||
},
|
||||
})
|
||||
const alertsRes = useCachedResource<FiredAlert[]>({
|
||||
key: 'monitoring.alerts',
|
||||
fetcher: async (signal) => {
|
||||
const data = await rpcClient.call<{ alerts: FiredAlert[] }>({
|
||||
method: 'monitoring.alerts', params: { count: 50 }, signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return (data?.alerts ?? []).reverse()
|
||||
},
|
||||
})
|
||||
const alertRulesRes = useCachedResource<AlertRule[]>({
|
||||
key: 'monitoring.alert-rules',
|
||||
fetcher: async (signal) => {
|
||||
const data = await rpcClient.call<{ rules: AlertRule[] }>({
|
||||
method: 'monitoring.alert-rules', signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return data?.rules ?? []
|
||||
},
|
||||
})
|
||||
const current = computed(() => currentRes.data.value)
|
||||
const history = computed(() => historyRes.data.value ?? [])
|
||||
const containers = computed<ContainerMetrics[]>(() => currentRes.data.value?.containers ?? [])
|
||||
const alerts = computed(() => alertsRes.data.value ?? [])
|
||||
const alertRules = computed(() => alertRulesRes.data.value ?? [])
|
||||
const current = ref<MetricSnapshot | null>(null)
|
||||
const history = ref<MetricSnapshot[]>([])
|
||||
const containers = ref<ContainerMetrics[]>([])
|
||||
const alerts = ref<FiredAlert[]>([])
|
||||
const alertRules = ref<AlertRule[]>([])
|
||||
const showAlertConfig = ref(false)
|
||||
const chartWidth = ref(380)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
@ -505,10 +464,66 @@ async function exportMetrics(format: 'csv' | 'json') {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCurrent() {
|
||||
try {
|
||||
await homeStatus.refreshSystemStats()
|
||||
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
|
||||
method: 'monitoring.current',
|
||||
})
|
||||
if (data && 'system' in data) {
|
||||
current.value = data
|
||||
containers.value = data.containers ?? []
|
||||
}
|
||||
} catch {
|
||||
// Silently retry on next poll
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHistory() {
|
||||
try {
|
||||
const data = await rpcClient.call<HistoryResponse>({
|
||||
method: 'monitoring.history',
|
||||
params: { resolution: 'minute', count: 60 },
|
||||
})
|
||||
if (data?.data) {
|
||||
history.value = data.data
|
||||
}
|
||||
} catch {
|
||||
// Silently retry on next poll
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAlerts() {
|
||||
try {
|
||||
const data = await rpcClient.call<{ alerts: FiredAlert[] }>({
|
||||
method: 'monitoring.alerts',
|
||||
params: { count: 50 },
|
||||
})
|
||||
if (data?.alerts) {
|
||||
alerts.value = data.alerts.reverse()
|
||||
}
|
||||
} catch {
|
||||
// Silently retry on next poll
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAlertRules() {
|
||||
try {
|
||||
const data = await rpcClient.call<{ rules: AlertRule[] }>({
|
||||
method: 'monitoring.alert-rules',
|
||||
})
|
||||
if (data?.rules) {
|
||||
alertRules.value = data.rules
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAlertRule(kind: string, enabled: boolean) {
|
||||
try {
|
||||
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, enabled } })
|
||||
await alertRulesRes.refresh()
|
||||
await fetchAlertRules()
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
@ -519,7 +534,7 @@ async function updateThreshold(kind: string, value: string) {
|
||||
if (isNaN(threshold) || threshold <= 0) return
|
||||
try {
|
||||
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, threshold } })
|
||||
await alertRulesRes.refresh()
|
||||
await fetchAlertRules()
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
@ -528,7 +543,7 @@ async function updateThreshold(kind: string, value: string) {
|
||||
async function acknowledgeAlert(id: string) {
|
||||
try {
|
||||
await rpcClient.call({ method: 'monitoring.acknowledge-alert', params: { id } })
|
||||
await alertsRes.refresh()
|
||||
await fetchAlerts()
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
@ -541,18 +556,18 @@ function updateChartWidth() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
updateChartWidth()
|
||||
window.addEventListener('resize', updateChartWidth)
|
||||
|
||||
// The cached resources fetch themselves on first use; the poll keeps the
|
||||
// live view fresh (refreshes dedup in the store).
|
||||
void homeStatus.refreshSystemStats()
|
||||
pollTimer = setInterval(() => {
|
||||
void homeStatus.refreshSystemStats()
|
||||
void currentRes.refresh()
|
||||
void historyRes.refresh()
|
||||
void alertsRes.refresh()
|
||||
await Promise.all([fetchCurrent(), fetchHistory(), fetchAlerts(), fetchAlertRules()])
|
||||
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
await Promise.all([fetchCurrent(), fetchHistory(), fetchAlerts()])
|
||||
} catch {
|
||||
// Background poll — ignore transient errors
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
|
||||
@ -594,11 +594,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, reactive, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import QRCode from 'qrcode'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useResourcesStore } from '@/stores/resources'
|
||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||
import { pipSupported, togglePip } from '@/utils/pip'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
@ -626,33 +625,16 @@ interface CatalogItem {
|
||||
access: string | { paid: { price_sats: number } }
|
||||
}
|
||||
|
||||
const resources = useResourcesStore()
|
||||
const loading = ref(true)
|
||||
const currentPeer = ref<PeerNode | null>(null)
|
||||
const catalogError = ref('')
|
||||
const catalogItems = ref<CatalogItem[]>([])
|
||||
const downloading = ref<string | null>(null)
|
||||
const playing = ref<string | null>(null)
|
||||
const purchaseError = ref<string | null>(null)
|
||||
|
||||
// The catalog is the SAME cached entry Cloud.vue's per-peer fan-in fills
|
||||
// (`cloud.peer-browse:<onion>`): arriving here from the Cloud page paints
|
||||
// the file list instantly from cache and revalidates behind it.
|
||||
interface PeerBrowse {
|
||||
items: CatalogItem[]
|
||||
transport: string | null
|
||||
latencyMs: number
|
||||
}
|
||||
const peerOnion = computed(() => props.peerId || currentPeer.value?.onion || '')
|
||||
function browseEntry() {
|
||||
return resources.entry<PeerBrowse>(`cloud.peer-browse:${peerOnion.value}`)
|
||||
}
|
||||
const catalogItems = computed(() => browseEntry().data?.items ?? [])
|
||||
const catalogError = computed(() => browseEntry().error ?? '')
|
||||
const loading = computed(() => {
|
||||
const s = browseEntry().loadState
|
||||
return s === 'loading' || s === 'refreshing' || (s === 'idle' && !!peerOnion.value)
|
||||
})
|
||||
// Transport actually used to reach this peer (returned by content.browse-peer)
|
||||
// so we can show a FIPS/Tor pill instead of always assuming Tor (B21).
|
||||
const transport = computed(() => browseEntry().data?.transport ?? null)
|
||||
const transport = ref<string | null>(null)
|
||||
const transportPill = computed(() => {
|
||||
switch (transport.value) {
|
||||
case 'fips':
|
||||
@ -825,62 +807,44 @@ onMounted(async () => {
|
||||
loadCatalog(),
|
||||
loadOwned(),
|
||||
])
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
// No peerId → peerOnion is empty and `loading` stays false on its own.
|
||||
})
|
||||
|
||||
function loadCatalog(): Promise<void> {
|
||||
const onion = peerOnion.value
|
||||
if (!onion) return Promise.resolve()
|
||||
return resources.refresh<PeerBrowse>(`cloud.peer-browse:${onion}`, async () => {
|
||||
const t0 = Date.now()
|
||||
async function loadCatalog() {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!onion) return
|
||||
const hadItems = catalogItems.value.length > 0
|
||||
loading.value = true
|
||||
catalogError.value = ''
|
||||
try {
|
||||
const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
|
||||
method: 'content.browse-peer',
|
||||
params: { onion },
|
||||
timeout: 30000,
|
||||
// The caller has its own timeout UX; retry×3 turned one slow peer
|
||||
// into a 90s spinner.
|
||||
maxRetries: 1,
|
||||
dedup: true,
|
||||
})
|
||||
return { items: result?.items ?? [], transport: result?.transport ?? null, latencyMs: Date.now() - t0 }
|
||||
})
|
||||
}
|
||||
|
||||
// Load visual previews for image and video items when catalog loads.
|
||||
// Audio files don't need visual thumbnails — they show a waveform icon.
|
||||
// The fan-out is capped (3 concurrent) and aborts on unmount — it used to
|
||||
// fire one 30s RPC per media item all at once, unbounded.
|
||||
const previewAborter = new AbortController()
|
||||
onUnmounted(() => previewAborter.abort())
|
||||
const previewQueued = new Set<string>()
|
||||
let previewQueue: CatalogItem[] = []
|
||||
let previewWorkers = 0
|
||||
const PREVIEW_CONCURRENCY = 3
|
||||
|
||||
function pumpPreviews(onion: string) {
|
||||
while (previewWorkers < PREVIEW_CONCURRENCY && previewQueue.length > 0) {
|
||||
const item = previewQueue.shift()!
|
||||
previewWorkers++
|
||||
void loadPreview(onion, item).finally(() => {
|
||||
previewWorkers--
|
||||
pumpPreviews(onion)
|
||||
})
|
||||
catalogItems.value = result?.items ?? []
|
||||
transport.value = result?.transport ?? null
|
||||
} catch (e: unknown) {
|
||||
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
|
||||
if (!hadItems) catalogItems.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(catalogItems, (items) => {
|
||||
const onion = peerOnion.value
|
||||
// Load visual previews for image and video items when catalog loads
|
||||
// Audio files don't need visual thumbnails — they show a waveform icon
|
||||
watch(catalogItems, async (items) => {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!onion) return
|
||||
for (const item of items) {
|
||||
const isVisual = item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')
|
||||
if (isVisual && !previewUrls[item.id] && !previewQueued.has(item.id)) {
|
||||
previewQueued.add(item.id)
|
||||
previewQueue.push(item)
|
||||
if ((item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')) && !previewUrls[item.id]) {
|
||||
loadPreview(onion, item)
|
||||
}
|
||||
}
|
||||
pumpPreviews(onion)
|
||||
}, { immediate: true })
|
||||
})
|
||||
|
||||
async function loadPreview(onion: string, item: CatalogItem) {
|
||||
try {
|
||||
@ -888,8 +852,6 @@ async function loadPreview(onion: string, item: CatalogItem) {
|
||||
method: 'content.preview-peer',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 30000,
|
||||
maxRetries: 1,
|
||||
signal: previewAborter.signal,
|
||||
})
|
||||
if (result?.data) {
|
||||
const mime = result.content_type || item.mime_type
|
||||
|
||||
@ -407,7 +407,6 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource, type CachedResource } from '@/composables/useCachedResource'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import QuickActionsCard from './server/QuickActionsCard.vue'
|
||||
import TorServicesCard from './server/TorServicesCard.vue'
|
||||
@ -435,51 +434,18 @@ const torStatusColor = computed(() => {
|
||||
const autoSyncEnabled = ref(true)
|
||||
const logCount = ref(0)
|
||||
|
||||
// Network data — a cached aggregate over four RPCs (allSettled: a failing
|
||||
// one keeps that slice's previous values). Revisits paint instantly.
|
||||
interface NetworkData {
|
||||
wifiCount: string; wifiSsid: string | null; torConnected: boolean; forwardCount: string
|
||||
vpnConnected: boolean; vpnProvider: string; vpnIp: string; wgIp: string; wgPubkey: string
|
||||
vpnHostname: string; vpnPeers: number
|
||||
dnsProvider: string; dnsServers: string[]; dnsDoH: boolean
|
||||
}
|
||||
const defaultNetworkData = (): NetworkData => ({
|
||||
wifiCount: 'N/A', wifiSsid: null, torConnected: false, forwardCount: 'N/A',
|
||||
// Network data
|
||||
const networkLoading = ref(true)
|
||||
const networkRefreshing = ref(false)
|
||||
const networkHasLoaded = ref(false)
|
||||
const networkData = ref({
|
||||
wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A',
|
||||
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
|
||||
dnsProvider: 'system', dnsServers: [], dnsDoH: false,
|
||||
dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false,
|
||||
})
|
||||
// immediate:false — the fetcher merges onto the previous value via
|
||||
// networkRes, so it must not run during this initializer (onMounted loads
|
||||
// it). The explicit annotation breaks the self-referential inference cycle.
|
||||
const networkRes: CachedResource<NetworkData> = useCachedResource<NetworkData>({
|
||||
key: 'server.network-summary',
|
||||
immediate: false,
|
||||
fetcher: async () => {
|
||||
const next = { ...(networkRes.data.value ?? defaultNetworkData()) }
|
||||
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
|
||||
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
|
||||
rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }),
|
||||
rpcClient.vpnStatus(),
|
||||
rpcClient.dnsStatus(),
|
||||
])
|
||||
if (diagRes.status === 'fulfilled') { next.torConnected = diagRes.value.tor_connected; next.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A'; next.wifiSsid = (diagRes.value as { wifi_ssid?: string | null }).wifi_ssid ?? null }
|
||||
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; next.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
|
||||
if (vpnRes.status === 'fulfilled') { next.vpnConnected = vpnRes.value.connected; next.vpnProvider = vpnRes.value.provider ?? ''; next.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); next.wgIp = vpnRes.value.wg_ip ?? ''; next.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
|
||||
if (dnsRes.status === 'fulfilled') { next.dnsProvider = dnsRes.value.provider; next.dnsServers = dnsRes.value.resolv_conf_servers ?? []; next.dnsDoH = dnsRes.value.doh_enabled }
|
||||
return next
|
||||
},
|
||||
})
|
||||
const networkData = computed(() => networkRes.data.value ?? defaultNetworkData())
|
||||
const networkLoading = computed(() => networkRes.loadState.value === 'loading')
|
||||
const networkRefreshing = computed(() => networkRes.loadState.value === 'refreshing')
|
||||
|
||||
// FIPS status row for the Local Network card. Full FIPS card lives below.
|
||||
const fipsSummaryRes = useCachedResource<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({
|
||||
key: 'server.fips-summary',
|
||||
immediate: false,
|
||||
fetcher: (signal) => rpcClient.call({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
|
||||
})
|
||||
const fipsSummary = computed(() => fipsSummaryRes.data.value)
|
||||
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
|
||||
const fipsRowLabel = computed(() => {
|
||||
const s = fipsSummary.value
|
||||
if (!s) return '…'
|
||||
@ -501,12 +467,32 @@ const fipsRowTextClass = computed(() => {
|
||||
if (s.anchor_connected === false) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
function loadFipsSummary() {
|
||||
return fipsSummaryRes.refresh()
|
||||
async function loadFipsSummary() {
|
||||
try {
|
||||
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' })
|
||||
} catch { /* backend too old */ }
|
||||
}
|
||||
|
||||
function loadNetworkData() {
|
||||
return networkRes.refresh()
|
||||
async function loadNetworkData() {
|
||||
const initialLoad = !networkHasLoaded.value
|
||||
networkLoading.value = initialLoad
|
||||
networkRefreshing.value = !initialLoad
|
||||
try {
|
||||
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
|
||||
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
|
||||
rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }),
|
||||
rpcClient.vpnStatus(),
|
||||
rpcClient.dnsStatus(),
|
||||
])
|
||||
if (diagRes.status === 'fulfilled') { networkData.value.torConnected = diagRes.value.tor_connected; networkData.value.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A'; networkData.value.wifiSsid = (diagRes.value as { wifi_ssid?: string | null }).wifi_ssid ?? null }
|
||||
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
|
||||
if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
|
||||
if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled }
|
||||
} catch { /* keep existing/default values */ } finally {
|
||||
networkHasLoaded.value = true
|
||||
networkLoading.value = false
|
||||
networkRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// VPN peer management
|
||||
@ -521,18 +507,13 @@ const sanitizedPeerQrSvg = computed(() =>
|
||||
)
|
||||
const peerError = ref('')
|
||||
const copiedConfig = ref(false)
|
||||
const vpnPeersRes = useCachedResource<{ name: string; ip: string; type?: string; npub?: string }[]>({
|
||||
key: 'server.vpn-peers',
|
||||
immediate: false,
|
||||
fetcher: async (signal) => {
|
||||
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers', signal, dedup: true, maxRetries: 1 })
|
||||
return res.peers || []
|
||||
},
|
||||
})
|
||||
const vpnPeers = computed(() => vpnPeersRes.data.value ?? [])
|
||||
const vpnPeers = ref<{ name: string; ip: string; type?: string; npub?: string }[]>([])
|
||||
|
||||
function loadVpnPeers() {
|
||||
return vpnPeersRes.refresh()
|
||||
async function loadVpnPeers() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers' })
|
||||
vpnPeers.value = res.peers || []
|
||||
} catch { /* no peers */ }
|
||||
}
|
||||
|
||||
async function createPeer() {
|
||||
@ -576,7 +557,7 @@ async function removePeer(name: string) {
|
||||
removingPeer.value = name
|
||||
try {
|
||||
await rpcClient.call({ method: 'vpn.remove-peer', params: { name } })
|
||||
vpnPeersRes.optimistic(cur => (cur ?? []).filter(p => p.name !== name))
|
||||
vpnPeers.value = vpnPeers.value.filter(p => p.name !== name)
|
||||
} catch { /* ignore */ }
|
||||
finally { removingPeer.value = '' }
|
||||
}
|
||||
@ -602,17 +583,10 @@ async function copyPeerConfig() {
|
||||
interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] }
|
||||
interface WifiNetwork { ssid: string; signal: number; security: string }
|
||||
|
||||
const interfacesRes = useCachedResource<NetworkInterface[]>({
|
||||
key: 'server.interfaces',
|
||||
immediate: false,
|
||||
fetcher: async (signal) => {
|
||||
const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces', signal, dedup: true, maxRetries: 1 })
|
||||
return res.interfaces
|
||||
},
|
||||
})
|
||||
const interfacesLoading = computed(() => interfacesRes.loadState.value === 'loading')
|
||||
const interfacesRefreshing = computed(() => interfacesRes.loadState.value === 'refreshing')
|
||||
const allInterfaces = computed(() => interfacesRes.data.value ?? [])
|
||||
const interfacesLoading = ref(true)
|
||||
const interfacesRefreshing = ref(false)
|
||||
const interfacesHaveLoaded = ref(false)
|
||||
const allInterfaces = ref<NetworkInterface[]>([])
|
||||
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
|
||||
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
|
||||
|
||||
@ -663,19 +637,19 @@ async function applyDnsConfig(customServers: string) {
|
||||
const res = await rpcClient.configureDns(params)
|
||||
// Never trust the response shape: an undefined `servers` used to reach the
|
||||
// dnsDisplayLabel computed and crash the whole page render on `.length`.
|
||||
// Write-through to the cached aggregate (the RPC already succeeded).
|
||||
networkRes.optimistic(cur => ({
|
||||
...(cur ?? defaultNetworkData()),
|
||||
dnsProvider: res?.provider ?? provider,
|
||||
dnsServers: Array.isArray(res?.servers) ? res.servers : (params.servers ?? []),
|
||||
dnsDoH: !!res?.doh_enabled,
|
||||
}))
|
||||
networkData.value.dnsProvider = res?.provider ?? provider
|
||||
networkData.value.dnsServers = Array.isArray(res?.servers) ? res.servers : (params.servers ?? [])
|
||||
networkData.value.dnsDoH = !!res?.doh_enabled
|
||||
showDnsModal.value = false
|
||||
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
|
||||
}
|
||||
|
||||
function loadInterfaces() {
|
||||
return interfacesRes.refresh()
|
||||
async function loadInterfaces() {
|
||||
const initialLoad = !interfacesHaveLoaded.value
|
||||
const hadInterfaces = allInterfaces.value.length > 0
|
||||
interfacesLoading.value = initialLoad
|
||||
interfacesRefreshing.value = !initialLoad
|
||||
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
|
||||
}
|
||||
|
||||
async function toggleWifiRadio(iface: NetworkInterface) {
|
||||
@ -757,18 +731,9 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
// Tor Services
|
||||
const torServicesRes = useCachedResource<{ services: TorServiceInfo[]; tor_running: boolean }>({
|
||||
key: 'server.tor-services',
|
||||
immediate: false,
|
||||
fetcher: async (signal) => {
|
||||
const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services', signal, dedup: true, maxRetries: 1 })
|
||||
return { services: res.services || [], tor_running: res.tor_running ?? false }
|
||||
},
|
||||
})
|
||||
const torServices = computed(() => torServicesRes.data.value?.services ?? [])
|
||||
const torServicesLoading = computed(() =>
|
||||
torServicesRes.loadState.value === 'loading' || torServicesRes.loadState.value === 'refreshing')
|
||||
const torDaemonRunning = computed(() => torServicesRes.data.value?.tor_running ?? false)
|
||||
const torServices = ref<TorServiceInfo[]>([])
|
||||
const torServicesLoading = ref(false)
|
||||
const torDaemonRunning = ref(false)
|
||||
const torRestarting = ref(false)
|
||||
const torRotating = ref<string | false>(false)
|
||||
const torDeleting = ref<string | false>(false)
|
||||
@ -785,8 +750,11 @@ const availableAppsForTor = computed(() => {
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
})
|
||||
|
||||
function loadTorServices() {
|
||||
return torServicesRes.refresh()
|
||||
async function loadTorServices() {
|
||||
const hadServices = torServices.value.length > 0
|
||||
torServicesLoading.value = true
|
||||
try { const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torDaemonRunning.value = res.tor_running ?? false }
|
||||
catch { if (!hadServices) { torServices.value = []; torDaemonRunning.value = false } } finally { torServicesLoading.value = false }
|
||||
}
|
||||
|
||||
async function copyTorAddress(address: string) {
|
||||
@ -830,18 +798,14 @@ async function createService(name: string, port: number | null) {
|
||||
|
||||
onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() })
|
||||
|
||||
// Poll VPN status every 15s so IP updates after pairing (write-through to
|
||||
// the cached aggregate without refetching the other three RPCs)
|
||||
// Poll VPN status every 15s so IP updates after pairing
|
||||
const vpnPollInterval = setInterval(async () => {
|
||||
try {
|
||||
const vpnRes = await rpcClient.vpnStatus()
|
||||
networkRes.optimistic(cur => ({
|
||||
...(cur ?? defaultNetworkData()),
|
||||
vpnConnected: vpnRes.connected,
|
||||
vpnProvider: vpnRes.provider ?? '',
|
||||
vpnIp: (vpnRes.ip_address ?? '').replace(/\/\d+$/, ''),
|
||||
wgIp: vpnRes.wg_ip ?? '',
|
||||
}))
|
||||
networkData.value.vpnConnected = vpnRes.connected
|
||||
networkData.value.vpnProvider = vpnRes.provider ?? ''
|
||||
networkData.value.vpnIp = (vpnRes.ip_address ?? '').replace(/\/\d+$/, '')
|
||||
networkData.value.wgIp = vpnRes.wg_ip ?? ''
|
||||
} catch { /* ignore */ }
|
||||
}, 15000)
|
||||
onUnmounted(() => clearInterval(vpnPollInterval))
|
||||
@ -865,11 +829,8 @@ async function restartServices() {
|
||||
|
||||
async function checkTorStatus() {
|
||||
checkingTor.value = true; torStatusLabel.value = 'checking'
|
||||
try {
|
||||
await torServicesRes.refresh()
|
||||
if (torServicesRes.error.value) torStatusLabel.value = 'stopped'
|
||||
else torStatusLabel.value = torServices.value.some(s => s.onion_address) ? 'running' : 'stopped'
|
||||
} finally { checkingTor.value = false }
|
||||
try { const res = await rpcClient.call<{ services: TorServiceInfo[] }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torStatusLabel.value = torServices.value.some(s => s.onion_address) ? 'running' : 'stopped' }
|
||||
catch { torStatusLabel.value = 'stopped' } finally { checkingTor.value = false }
|
||||
}
|
||||
|
||||
const logsToast = ref('')
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia } from 'pinia'
|
||||
import Credentials from '../Credentials.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
@ -43,8 +42,6 @@ describe('Credentials', () => {
|
||||
|
||||
const wrapper = mount(Credentials, {
|
||||
global: {
|
||||
// The cached-resource layer pulls the Pinia resources store in setup.
|
||||
plugins: [createPinia()],
|
||||
mocks: {
|
||||
$router: { push: vi.fn() },
|
||||
},
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia } from 'pinia'
|
||||
import PeerFiles from '../PeerFiles.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
@ -56,8 +55,6 @@ describe('PeerFiles', () => {
|
||||
const wrapper = mount(PeerFiles, {
|
||||
props: { peerId: 'peer.onion' },
|
||||
global: {
|
||||
// The shared peer-browse cache lives in the Pinia resources store.
|
||||
plugins: [createPinia()],
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia } from 'pinia'
|
||||
import Server from '../Server.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
@ -30,8 +29,6 @@ function deferred<T>() {
|
||||
function mountServer(options: { renderTorServices?: boolean } = {}) {
|
||||
return mount(Server, {
|
||||
global: {
|
||||
// The cached-resource layer pulls the Pinia resources store in setup.
|
||||
plugins: [createPinia()],
|
||||
stubs: {
|
||||
QuickActionsCard: true,
|
||||
TorServicesCard: options.renderTorServices ? false : true,
|
||||
|
||||
@ -121,7 +121,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import { safeClipboardWrite } from '@/views/web5/utils'
|
||||
import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue'
|
||||
|
||||
@ -137,14 +136,7 @@ interface FipsStatus {
|
||||
anchor_connected?: boolean
|
||||
}
|
||||
|
||||
// Shares `server.fips-summary` with the Local Network card's FIPS row, so
|
||||
// both paint from the same cache instantly on revisit and never disagree.
|
||||
const statusRes = useCachedResource<FipsStatus>({
|
||||
key: 'server.fips-summary',
|
||||
ttlMs: 15_000,
|
||||
fetcher: (signal) => rpcClient.call<FipsStatus>({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
|
||||
})
|
||||
const status = computed<FipsStatus>(() => statusRes.data.value ?? {
|
||||
const status = ref<FipsStatus>({
|
||||
installed: false,
|
||||
version: null,
|
||||
service_state: 'unknown',
|
||||
@ -207,11 +199,18 @@ function flash(msg: string, isError = false) {
|
||||
setTimeout(() => { statusMessage.value = '' }, 6000)
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.status' })
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('fips.status failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function installAndActivate() {
|
||||
installing.value = true
|
||||
try {
|
||||
const next = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
|
||||
statusRes.optimistic(() => next) // confirmed server state, not a guess
|
||||
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
|
||||
flash('FIPS started')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
@ -236,7 +235,7 @@ async function reconnectAnchor() {
|
||||
}>({ method: 'fips.reconnect', timeout: 60_000 })
|
||||
// Update the card with the post-reconnect status returned by the
|
||||
// backend — avoids an extra status fetch race.
|
||||
statusRes.optimistic((cur) => ({ ...(cur ?? status.value), ...res.after }))
|
||||
status.value = { ...status.value, ...res.after }
|
||||
if (res.recovered) {
|
||||
flash('Anchor reconnected.')
|
||||
} else if (res.likely_cause === 'connected') {
|
||||
@ -259,10 +258,8 @@ async function reconnectAnchor() {
|
||||
// stuck showing whatever anchor state existed at mount time forever.
|
||||
let statusInterval: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => {
|
||||
statusInterval = setInterval(() => {
|
||||
if (document.hidden) return
|
||||
void statusRes.refresh()
|
||||
}, 15000)
|
||||
loadStatus()
|
||||
statusInterval = setInterval(loadStatus, 15000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (statusInterval) clearInterval(statusInterval)
|
||||
|
||||
@ -88,9 +88,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
|
||||
defineProps<{ closable?: boolean }>()
|
||||
defineEmits<{ (e: 'close'): void }>()
|
||||
@ -108,16 +107,7 @@ interface ApplyResult {
|
||||
message: string
|
||||
}
|
||||
|
||||
const anchorsRes = useCachedResource<SeedAnchor[]>({
|
||||
key: 'server.fips-seed-anchors',
|
||||
fetcher: async (signal) => {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({
|
||||
method: 'fips.list-seed-anchors', signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return res.seed_anchors
|
||||
},
|
||||
})
|
||||
const anchors = computed(() => anchorsRes.data.value ?? [])
|
||||
const anchors = ref<SeedAnchor[]>([])
|
||||
const adding = ref(false)
|
||||
const applying = ref(false)
|
||||
const statusMessage = ref('')
|
||||
@ -136,6 +126,15 @@ function flash(msg: string, isError = false) {
|
||||
setTimeout(() => { statusMessage.value = '' }, 6000)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({ method: 'fips.list-seed-anchors' })
|
||||
anchors.value = res.seed_anchors
|
||||
} catch (e: unknown) {
|
||||
if (import.meta.env.DEV) console.warn('fips.list-seed-anchors failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function addAnchor() {
|
||||
if (!draft.npub.trim() || !draft.address.trim()) return
|
||||
adding.value = true
|
||||
@ -149,7 +148,7 @@ async function addAnchor() {
|
||||
label: draft.label.trim(),
|
||||
},
|
||||
})
|
||||
anchorsRes.optimistic(() => res.seed_anchors) // authoritative post-add list
|
||||
anchors.value = res.seed_anchors
|
||||
draft.npub = ''
|
||||
draft.address = ''
|
||||
draft.label = ''
|
||||
@ -169,7 +168,7 @@ async function removeAnchor(npub: string) {
|
||||
method: 'fips.remove-seed-anchor',
|
||||
params: { npub },
|
||||
})
|
||||
anchorsRes.optimistic(() => res.seed_anchors)
|
||||
anchors.value = res.seed_anchors
|
||||
flash('Anchor removed.')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
@ -190,4 +189,6 @@ async function applyAll() {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useResourcesStore } from '@/stores/resources'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@ -74,15 +73,8 @@ interface ScannedNetwork {
|
||||
encryption: string
|
||||
}
|
||||
|
||||
const status = computed(() => statusEntry().data)
|
||||
// Router status is cached in the shared resources store so revisits paint
|
||||
// the last-known state instantly while a fresh read runs behind it.
|
||||
const resources = useResourcesStore()
|
||||
const statusEntry = () => resources.entry<RouterStatus>('server.openwrt-status')
|
||||
const loading = computed(() => {
|
||||
const s = statusEntry().loadState
|
||||
return s === 'loading' || s === 'refreshing' || (s === 'idle' && statusEntry().data === null)
|
||||
})
|
||||
const status = ref<RouterStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const host = ref('')
|
||||
const sshUser = ref('root')
|
||||
@ -123,25 +115,25 @@ const dhcpLimit = ref(150)
|
||||
const masqEnabled = ref(true)
|
||||
|
||||
async function load(params?: Record<string, string>) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
await resources.refresh<RouterStatus>('server.openwrt-status', () =>
|
||||
rpcClient.call<RouterStatus>({
|
||||
try {
|
||||
status.value = await rpcClient.call<RouterStatus>({
|
||||
method: 'openwrt.get-status',
|
||||
params: params ?? {},
|
||||
timeout: 30000,
|
||||
dedup: true,
|
||||
maxRetries: 1,
|
||||
}))
|
||||
const err = statusEntry().error
|
||||
if (err) {
|
||||
if (err.includes('No router configured')) {
|
||||
showConnectForm.value = true
|
||||
} else {
|
||||
error.value = err
|
||||
}
|
||||
} else {
|
||||
})
|
||||
showConnectForm.value = false
|
||||
if (params) connectedParams.value = params
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
if (msg.includes('No router configured')) {
|
||||
showConnectForm.value = true
|
||||
} else {
|
||||
error.value = msg
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -93,9 +93,8 @@ let web5AnimationDone = false
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import { safeClipboardWrite } from './utils'
|
||||
import type { ProfitsData, HwWalletDevice } from './types'
|
||||
import type { ProfitsData, WalletTransaction, HwWalletDevice } from './types'
|
||||
|
||||
import Web5QuickActions from './Web5QuickActions.vue'
|
||||
// import Web5Wallet from './Web5Wallet.vue' // hidden for now
|
||||
@ -137,13 +136,7 @@ function showToast(text: string) {
|
||||
}
|
||||
|
||||
// --- Networking Profits ---
|
||||
const profitsRes = useCachedResource<ProfitsData>({
|
||||
key: 'web5.networking-profits',
|
||||
fetcher: (signal) => rpcClient.call<ProfitsData>({ method: 'wallet.networking-profits', signal, dedup: true, maxRetries: 1 }),
|
||||
})
|
||||
const profitsBreakdown = computed<ProfitsData | null>(() =>
|
||||
profitsRes.data.value
|
||||
?? (profitsRes.error.value ? { total_sats: 0, content_sales_sats: 0, routing_fees_sats: 0 } : null))
|
||||
const profitsBreakdown = ref<ProfitsData | null>(null)
|
||||
const networkingProfitsDisplay = computed(() => {
|
||||
if (!profitsBreakdown.value) return '...'
|
||||
const sats = profitsBreakdown.value.total_sats
|
||||
@ -153,6 +146,15 @@ const networkingProfitsDisplay = computed(() => {
|
||||
return `\u20BF${btc.toFixed(8).replace(/0+$/, '').replace(/\.$/, '')}`
|
||||
})
|
||||
|
||||
async function loadNetworkingProfits() {
|
||||
try {
|
||||
const res = await rpcClient.call<ProfitsData>({ method: 'wallet.networking-profits' })
|
||||
profitsBreakdown.value = res
|
||||
} catch {
|
||||
profitsBreakdown.value = { total_sats: 0, content_sales_sats: 0, routing_fees_sats: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// --- DID State ---
|
||||
const storedDid = ref<string | null>(null)
|
||||
try {
|
||||
@ -288,35 +290,64 @@ async function copyDidDocument() {
|
||||
}
|
||||
|
||||
// --- Wallet / LND Balances ---
|
||||
// Cached: balances/transactions paint instantly on revisit and revalidate
|
||||
// behind the cached value; errors keep the last-known data.
|
||||
const lndInfoRes = useCachedResource<{
|
||||
balance_sats: number
|
||||
channel_balance_sats: number
|
||||
synced_to_chain: boolean
|
||||
}>({
|
||||
key: 'web5.lnd-info',
|
||||
fetcher: (signal) => rpcClient.call({ method: 'lnd.getinfo', signal, dedup: true, maxRetries: 1 }),
|
||||
})
|
||||
// connectWallet() can still "disconnect" the (hidden) wallet card UI-side.
|
||||
const walletManuallyDisconnected = ref(false)
|
||||
const walletConnected = computed(() =>
|
||||
!walletManuallyDisconnected.value && lndInfoRes.data.value !== null && !lndInfoRes.error.value)
|
||||
const walletConnected = ref(false)
|
||||
const connectingWallet = ref(false)
|
||||
// Ecash/transaction/balance display lives in the hidden wallet card — when it
|
||||
// returns, add cached resources for wallet.ecash-balance / lnd.gettransactions
|
||||
// here rather than reviving the old eager loaders.
|
||||
const lndOnchainBalance = ref(0)
|
||||
const lndChannelBalance = ref(0)
|
||||
const walletError = ref('')
|
||||
const ecashBalance = ref(0)
|
||||
|
||||
// Transactions — wallet card hidden, but loadTransactions still called for QuickActions walletConnected state
|
||||
const walletTransactions = ref<WalletTransaction[]>([])
|
||||
|
||||
// Hardware wallets
|
||||
const detectedHwWallets = ref<HwWalletDevice[]>([])
|
||||
|
||||
async function loadLndBalances() {
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
balance_sats: number
|
||||
channel_balance_sats: number
|
||||
synced_to_chain: boolean
|
||||
}>({ method: 'lnd.getinfo' })
|
||||
lndOnchainBalance.value = res.balance_sats || 0
|
||||
lndChannelBalance.value = res.channel_balance_sats || 0
|
||||
walletConnected.value = true
|
||||
walletError.value = ''
|
||||
} catch (e) {
|
||||
walletConnected.value = false
|
||||
lndOnchainBalance.value = 0
|
||||
lndChannelBalance.value = 0
|
||||
walletError.value = e instanceof Error ? e.message : 'Failed to load wallet balances'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEcashBalance() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ balance_sats: number; token_count: number }>({ method: 'wallet.ecash-balance' })
|
||||
ecashBalance.value = res.balance_sats ?? 0
|
||||
} catch {
|
||||
// Keep last-known balance on a transient failure rather than flashing 0.
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTransactions() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
|
||||
walletTransactions.value = res.transactions || []
|
||||
walletError.value = ''
|
||||
} catch (e) {
|
||||
walletTransactions.value = []
|
||||
walletError.value = e instanceof Error ? e.message : 'Failed to load transactions'
|
||||
}
|
||||
}
|
||||
|
||||
async function connectWallet() {
|
||||
if (walletConnected.value) {
|
||||
walletManuallyDisconnected.value = true
|
||||
walletConnected.value = false
|
||||
} else {
|
||||
connectingWallet.value = true
|
||||
walletManuallyDisconnected.value = false
|
||||
await lndInfoRes.refresh()
|
||||
await loadLndBalances()
|
||||
connectingWallet.value = false
|
||||
}
|
||||
}
|
||||
@ -330,8 +361,13 @@ async function detectHardwareWallets() {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh wallet data every 30s while mounted (B5 will move this to
|
||||
// WS-push invalidation; the store dedups overlapping refreshes).
|
||||
// function reloadBalances() { // wallet hidden
|
||||
// loadLndBalances()
|
||||
// loadEcashBalance()
|
||||
// loadTransactions()
|
||||
// }
|
||||
|
||||
// Auto-refresh wallet data every 30s
|
||||
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
@ -356,15 +392,20 @@ onMounted(() => {
|
||||
// credentialsRef.value?.loadCredentials() // hidden for now
|
||||
// sharedContentRef.value?.loadContentItems() // hidden for now
|
||||
|
||||
// Wallet/profits resources fetch themselves on first use (and skip the
|
||||
// fetch entirely when the cached value is still fresh).
|
||||
// Load local state data
|
||||
loadEcashBalance()
|
||||
loadNetworkingProfits()
|
||||
loadLndBalances()
|
||||
loadTransactions()
|
||||
detectHardwareWallets()
|
||||
|
||||
// Shared content loaded by the component itself via expose
|
||||
// The SharedContent component manages its own loadContentItems
|
||||
|
||||
walletRefreshInterval = setInterval(() => {
|
||||
void lndInfoRes.refresh()
|
||||
loadLndBalances()
|
||||
loadTransactions()
|
||||
loadEcashBalance()
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user