Compare commits
19
Commits
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.129-alpha (2026-08-10)
|
||||
|
||||
- **Every app is now supervised the same way — the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.
|
||||
- **Leftover companion screens are cleaned up again — driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed — it had been guessing "installed" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — "I couldn't check" is never treated as "nothing is installed" — and a helper must be orphaned for a sustained period before it is touched.
|
||||
- **A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right — Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.
|
||||
- **The federation map became a real map.** The network view is now a 3D orbital scene of your federation — nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro — the scene updates in place.
|
||||
- **An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.
|
||||
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle).
|
||||
|
||||
## v1.7.128-alpha (2026-08-10)
|
||||
|
||||
- **The discovery list stops showing ghosts.** Every reinstall of a node mints a new discovery identity, and the old identity's announcement could never be removed from the public relays — nothing holds its key anymore — so the "Discoverable nodes" list slowly filled with entries that led nowhere. Announcements now expire: your node re-announces itself twice a day, each announcement carries a 48-hour expiry that relays honour, anything older than that is ignored when reading, and switching discovery off — or factory-resetting the node — actively overwrites the announcement before it can become a ghost. Old ghosts from earlier versions stop being shown immediately and age off the relays on their own.
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "grafana/grafana:10.2.0",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0",
|
||||
"repoUrl": "https://github.com/grafana/grafana",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
description: Analytics and monitoring platform. Visualize metrics and create dashboards.
|
||||
|
||||
container:
|
||||
image: grafana/grafana:10.2.0
|
||||
image: source.archipelago-foundation.org/lfg2025/grafana:10.2.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
data_uid: "472:472"
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.127-alpha"
|
||||
version = "1.7.128-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.128-alpha"
|
||||
version = "1.7.129-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -446,7 +446,7 @@ async fn proxy_to_app(
|
||||
.to_string();
|
||||
let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::<hyper::Uri>() {
|
||||
Ok(uri) => uri,
|
||||
Err(_) => return bad_gateway(),
|
||||
Err(_) => return app_down_page(app),
|
||||
};
|
||||
|
||||
let (mut parts, body) = req.into_parts();
|
||||
@@ -501,7 +501,7 @@ async fn proxy_to_app(
|
||||
let client = hyper::Client::new();
|
||||
let mut upstream_resp = match client.request(upstream_req).await {
|
||||
Ok(resp) => resp,
|
||||
Err(_) => return bad_gateway(),
|
||||
Err(_) => return app_down_page(app),
|
||||
};
|
||||
if upstream_resp.status() == StatusCode::SWITCHING_PROTOCOLS {
|
||||
if let Some(client_upgrade) = client_upgrade {
|
||||
@@ -521,7 +521,7 @@ async fn proxy_to_app(
|
||||
let client = hyper::Client::new();
|
||||
match client.request(Request::from_parts(parts, body)).await {
|
||||
Ok(resp) => resp,
|
||||
Err(_) => bad_gateway(),
|
||||
Err(_) => app_down_page(app),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,11 +583,32 @@ fn redirect_to_app() -> Response<Body> {
|
||||
.expect("static response builds")
|
||||
}
|
||||
|
||||
fn bad_gateway() -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.body(Body::from("app is not responding"))
|
||||
.expect("static response builds")
|
||||
/// Served when the app behind the gate does not answer on loopback.
|
||||
///
|
||||
/// A real page rather than the bare string `app is not responding`: the gate
|
||||
/// answers on the app's own port, so this text IS the app as far as the
|
||||
/// operator can tell, and the raw string read as the node itself being broken
|
||||
/// (reported against Gitea on a fleet node, 2026-08-10 — the actual fault was
|
||||
/// a ghost container crash-looping the app). Name the app, say the node is
|
||||
/// fine, and retry on our own: an app that is restarting comes back without
|
||||
/// the user knowing to reload. Status stays 502 so machine clients still see
|
||||
/// an upstream failure rather than a success with HTML in it.
|
||||
fn app_down_page(app: &GatedPort) -> Response<Body> {
|
||||
let body = format!(
|
||||
r#"{icon}
|
||||
<h1>{name} is not responding</h1>
|
||||
<p class="sub">The app is not answering right now — it may be stopped or still
|
||||
starting. This page retries automatically. If it does not recover, open the
|
||||
dashboard and check {name} under My Apps.</p>"#,
|
||||
icon = icon_markup(app),
|
||||
name = esc(&app.app_name),
|
||||
);
|
||||
let mut resp = page("App not responding", app, &body, StatusCode::BAD_GATEWAY);
|
||||
// Header-based refresh, not <meta> or script: page()'s CSP allows no
|
||||
// script, and the header keeps the retry out of the document entirely.
|
||||
resp.headers_mut()
|
||||
.insert("Refresh", header::HeaderValue::from_static("5"));
|
||||
resp
|
||||
}
|
||||
|
||||
fn not_found() -> Response<Body> {
|
||||
@@ -1065,6 +1086,23 @@ mod tests {
|
||||
assert!(csp.contains("form-action 'self'"));
|
||||
}
|
||||
|
||||
/// A dead upstream must render as a page that names the app and retries,
|
||||
/// not the bare string "app is not responding" — that string standing
|
||||
/// alone on the app's own port read as the node being broken (Gitea on a
|
||||
/// fleet node, 2026-08-10). The 502 status must survive so machine
|
||||
/// clients still see an upstream failure.
|
||||
#[tokio::test]
|
||||
async fn a_dead_app_gets_a_named_retrying_page_not_a_bare_string() {
|
||||
let resp = app_down_page(&app());
|
||||
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
|
||||
assert_eq!(resp.headers()["Refresh"], "5");
|
||||
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
assert!(html.contains("Strfry Relay is not responding"));
|
||||
assert!(html.contains("<html"), "must be a page, not a bare string");
|
||||
}
|
||||
|
||||
/// The login page must render entirely from the gate's own origin: the
|
||||
/// CSP allows no external host, so a background or logo that 404s leaves
|
||||
/// a black page rather than the dashboard's art.
|
||||
|
||||
@@ -107,6 +107,7 @@ impl BootReconciler {
|
||||
let companion_handle = if self.companion_stage {
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let interval = self.interval;
|
||||
let data_dir = orchestrator.data_dir().to_path_buf();
|
||||
Some(tokio::spawn(async move {
|
||||
let mut failure_rounds: u32 = 0;
|
||||
loop {
|
||||
@@ -128,34 +129,48 @@ impl BootReconciler {
|
||||
continue;
|
||||
};
|
||||
let failures = crate::container::companion::reconcile(&installed).await;
|
||||
// `reap_orphans` is deliberately NOT called here. It is
|
||||
// implemented and tested, and it must stay unwired until a
|
||||
// DURABLE record of "this app is installed" exists.
|
||||
// Reaper, RE-WIRED 2026-08-10 — driven by the DURABLE
|
||||
// installed-apps registry, never by runtime inference.
|
||||
//
|
||||
// Proven harmful on archi-dev-box 2026-08-08: it removed
|
||||
// archy-bitcoin-ui (36 minutes of no Bitcoin UI, until the
|
||||
// operator reinstalled the backend) and archy-lnd-ui, both
|
||||
// for apps that ARE installed. It was not a logic error —
|
||||
// it did exactly what it was told. The inputs lied: the
|
||||
// backends' containers were missing because of the
|
||||
// clean-exit vanishing bug, and both had already aged out
|
||||
// of running-containers.json, which only ever records what
|
||||
// is CURRENTLY RUNNING. So container-presence and
|
||||
// installation-evidence, the two independent signals the
|
||||
// reaper trusts, were false at the same time and for the
|
||||
// same underlying reason.
|
||||
// History: this call was unwired on 2026-08-08 after it
|
||||
// removed archy-bitcoin-ui and archy-lnd-ui for apps that
|
||||
// WERE installed. Not a logic error — the inputs lied:
|
||||
// `installed_app_ids` infers installation from runtime
|
||||
// state (containers present + running-containers.json),
|
||||
// and the clean-exit vanishing bug falsified both signals
|
||||
// at once. The unwire commit set the re-wire bar: a
|
||||
// durable record of "this app is installed".
|
||||
//
|
||||
// Reaping turns one lost app into two, which is strictly
|
||||
// worse than the orphan it cleans up. Leaving an orphan
|
||||
// costs a stale UI tile; reaping a live app's companion
|
||||
// costs the operator a working screen. Until "installed"
|
||||
// can be answered without inferring it from runtime state,
|
||||
// absence is not evidence of uninstallation.
|
||||
// That record now exists — installed-apps.json, written on
|
||||
// install, cleared on deliberate uninstall, backfilled at
|
||||
// boot from demonstrably-present containers, and immune to
|
||||
// container absence by construction (89b03c47 holds
|
||||
// entries while a container is gone). A vanished backend
|
||||
// no longer looks uninstalled, so the failure mode that
|
||||
// burned archi-dev-box cannot recur through this path.
|
||||
//
|
||||
// The provisioning half above is the actual fix for
|
||||
// "fedimint installs but does not work" and stands on its
|
||||
// own: a companion is never stood up for an app nobody
|
||||
// installed, so no NEW orphans are created.
|
||||
// `None` = the registry could not be read (missing or
|
||||
// corrupt) — which is "I could not look", NOT "nothing is
|
||||
// installed". The reaper stays idle in that case; the
|
||||
// runtime-derived `installed` set above is deliberately
|
||||
// NOT used as a fallback (it is exactly the input class
|
||||
// that caused the 2026-08-08 incident). ORPHAN_GRACE still
|
||||
// applies on top: a companion must be orphaned for the
|
||||
// full grace period before it is touched.
|
||||
if let Some(durable) =
|
||||
crate::crash_recovery::load_installed_apps_if_recorded(&data_dir).await
|
||||
{
|
||||
let durable: Vec<String> = durable.into_iter().collect();
|
||||
for (companion, err) in
|
||||
crate::container::companion::reap_orphans(&durable).await
|
||||
{
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
error = %err,
|
||||
"companion reap failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
for (companion, err) in &failures {
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
|
||||
@@ -682,8 +682,12 @@ fn due_after_grace(
|
||||
|
||||
/// Stop and remove any companion whose backend app is not installed.
|
||||
///
|
||||
/// ⚠️ NOT WIRED, ON PURPOSE. Do not call this from the reconciler until a
|
||||
/// DURABLE record of "this app is installed" exists to drive it.
|
||||
/// ⚠️ WIRED (2026-08-10) to exactly one caller — the boot reconciler's
|
||||
/// companion loop — and ONLY behind the durable installed-apps registry
|
||||
/// (`crash_recovery::load_installed_apps_if_recorded`). That satisfies the
|
||||
/// bar the 2026-08-08 unwire set: a DURABLE record of "this app is
|
||||
/// installed" drives it, never runtime inference. Do not add callers fed
|
||||
/// from runtime state; the history below is why.
|
||||
///
|
||||
/// It ran on archi-dev-box on 2026-08-08 and removed two companions whose
|
||||
/// backends were installed — archy-bitcoin-ui (36 minutes of no Bitcoin UI)
|
||||
|
||||
@@ -1039,7 +1039,9 @@ async fn repair_manifest_host_ports_after_stability(
|
||||
container = %name,
|
||||
"host listener disappeared after startup; restarting container"
|
||||
);
|
||||
if uses_pasta_network(manifest) {
|
||||
if uses_pasta_network(manifest) && !quadlet::unit_exists(name).await {
|
||||
// Legacy (pre-quadlet) pasta app: no unit owns it, so a transient
|
||||
// scope keeps its networking's cgroup independent of the daemon.
|
||||
podman_user_scope(&["restart", name])
|
||||
.await
|
||||
.with_context(|| format!("podman restart {name}"))?;
|
||||
@@ -1085,9 +1087,16 @@ async fn start_container_scoped_if_pasta(
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
if uses_pasta_network(manifest) {
|
||||
// Rootless pasta/conmon inherit the cgroup of the process that starts
|
||||
// them. Starting through archipelago.service lets backend restarts kill
|
||||
// app networking; a transient user scope keeps app daemons independent.
|
||||
// Quadlet-managed pasta app: the unit owns the cgroup and the
|
||||
// container is rendered --rm — bare `podman start` would fight
|
||||
// systemd over it. Restart-through-the-unit starts a stopped one.
|
||||
if quadlet::unit_exists(name).await {
|
||||
return quadlet::restart_service(&format!("{name}.service")).await;
|
||||
}
|
||||
// Legacy pasta app: rootless pasta/conmon inherit the cgroup of the
|
||||
// process that starts them. Starting through archipelago.service lets
|
||||
// backend restarts kill app networking; a transient user scope keeps
|
||||
// app daemons independent.
|
||||
podman_user_scope(&["start", name]).await
|
||||
} else {
|
||||
runtime.start_container(name).await
|
||||
@@ -1100,6 +1109,9 @@ async fn restart_container_scoped_if_pasta(
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
if uses_pasta_network(manifest) {
|
||||
if quadlet::unit_exists(name).await {
|
||||
return quadlet::restart_service(&format!("{name}.service")).await;
|
||||
}
|
||||
podman_user_scope(&["restart", name]).await
|
||||
} else {
|
||||
let _ = runtime.stop_container(name).await;
|
||||
@@ -1503,6 +1515,10 @@ impl ProdContainerOrchestrator {
|
||||
self.data_dir = data_dir;
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> &std::path::Path {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_lnd_paths(&mut self, paths: lnd::EnsurePaths) {
|
||||
self.lnd_paths = paths;
|
||||
@@ -2263,7 +2279,15 @@ impl ProdContainerOrchestrator {
|
||||
// after proving the container exists. Boot reconciliation must
|
||||
// not create every catalog app just because a Quadlet unit is
|
||||
// absent.
|
||||
if self.use_quadlet_backends && !uses_pasta_network(&resolved_manifest) {
|
||||
//
|
||||
// Pasta apps included since 2026-08-10: the old exclusion
|
||||
// paired with the transient-scope machinery (daemon-started
|
||||
// pasta died with the daemon's cgroup). A quadlet unit gives
|
||||
// pasta the same independence with systemd supervision on top
|
||||
// — Restart=always + RestartSec=10, which also spaces restarts
|
||||
// past pasta's port teardown. The scoped start/restart helpers
|
||||
// now defer to the unit whenever one exists.
|
||||
if self.use_quadlet_backends {
|
||||
if let Some(action) = self.migrate_to_quadlet_if_needed(lm, &name).await? {
|
||||
return Ok(action);
|
||||
}
|
||||
@@ -2535,10 +2559,7 @@ impl ProdContainerOrchestrator {
|
||||
// lost the container record after a crash/reboot. Sync the unit
|
||||
// bytes first (clears stale Notify=healthy/nc probes), then ask
|
||||
// user systemd to start the generated service.
|
||||
if self.use_quadlet_backends
|
||||
&& !uses_pasta_network(&resolved_manifest)
|
||||
&& self.quadlet_unit_exists(&name).await?
|
||||
{
|
||||
if self.use_quadlet_backends && self.quadlet_unit_exists(&name).await? {
|
||||
self.prepare_for_start(&resolved_manifest).await?;
|
||||
self.sync_quadlet_unit(lm, &name).await?;
|
||||
self.ensure_resolved_source_available(lm).await?;
|
||||
@@ -2721,11 +2742,13 @@ impl ProdContainerOrchestrator {
|
||||
self.prepare_for_start(&resolved_manifest).await?;
|
||||
self.ensure_container_network(&resolved_manifest).await?;
|
||||
|
||||
if self.use_quadlet_backends && !uses_pasta_network(&resolved_manifest) {
|
||||
if self.use_quadlet_backends {
|
||||
// Phase 3.2 path: declarative .container unit + systemctl.
|
||||
// Containers parented under user.slice instead of
|
||||
// archipelago.service's cgroup → no FM3 cascade SIGKILL on
|
||||
// archipelago restart.
|
||||
// archipelago restart. Pasta apps included since 2026-08-10 —
|
||||
// the unit gives them the same cgroup independence the transient
|
||||
// scopes provided, plus Restart=always supervision.
|
||||
self.install_via_quadlet(&resolved_manifest, &name).await?;
|
||||
} else {
|
||||
self.remove_quadlet_unit_if_present(&name).await?;
|
||||
|
||||
@@ -661,6 +661,19 @@ fn parse_memory_mib(raw: &str) -> Option<u32> {
|
||||
num_part.trim().parse::<u32>().ok()?.checked_mul(mul)
|
||||
}
|
||||
|
||||
/// Does a quadlet `.container` unit exist for this container name?
|
||||
/// Errors count as "unknown" and return false — callers use this to decide
|
||||
/// whether systemd owns the container, and claiming ownership on an
|
||||
/// unreadable answer would route lifecycle ops around a live unit.
|
||||
pub async fn unit_exists(name: &str) -> bool {
|
||||
let Ok(dir) = unit_dir().await else {
|
||||
return false;
|
||||
};
|
||||
tokio::fs::try_exists(dir.join(format!("{name}.container")))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
|
||||
pub async fn unit_dir() -> Result<PathBuf> {
|
||||
let home = std::env::var_os("HOME")
|
||||
|
||||
@@ -204,6 +204,19 @@ pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<S
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `load_installed_apps`, but keeps "no record" distinguishable from
|
||||
/// "empty record". The companion reaper must only ever run on `Some`:
|
||||
/// "I could not look" and "nothing is installed" both come back as an empty
|
||||
/// set from the lossy loader, yet they demand opposite behaviour — the
|
||||
/// distinction has to survive to the caller (see `reap_orphans`' contract).
|
||||
pub async fn load_installed_apps_if_recorded(
|
||||
data_dir: &Path,
|
||||
) -> Option<std::collections::HashSet<String>> {
|
||||
let path = data_dir.join(INSTALLED_APPS_FILE);
|
||||
let content = fs::read_to_string(&path).await.ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
|
||||
let path = data_dir.join(INSTALLED_APPS_FILE);
|
||||
if let Ok(json) = serde_json::to_string_pretty(installed) {
|
||||
@@ -1193,6 +1206,31 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn if_recorded_distinguishes_no_record_from_empty_record() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// No file: the reaper must see "could not look", never "empty".
|
||||
assert!(load_installed_apps_if_recorded(tmp.path()).await.is_none());
|
||||
// Corrupt file: same — refuse to answer rather than guess.
|
||||
tokio::fs::write(tmp.path().join(INSTALLED_APPS_FILE), "{ not json")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(load_installed_apps_if_recorded(tmp.path()).await.is_none());
|
||||
// A real (even empty) record answers.
|
||||
tokio::fs::write(tmp.path().join(INSTALLED_APPS_FILE), "[]")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
load_installed_apps_if_recorded(tmp.path()).await,
|
||||
Some(std::collections::HashSet::new())
|
||||
);
|
||||
mark_installed(tmp.path(), "bitcoin-knots").await;
|
||||
assert!(load_installed_apps_if_recorded(tmp.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("bitcoin-knots"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_record_survives_and_forgets_on_uninstall() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
Generated
+9
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.128-alpha",
|
||||
"version": "1.7.129-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.128-alpha",
|
||||
"version": "1.7.129-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
@@ -16,6 +16,7 @@
|
||||
"dompurify": "^3.3.3",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"gsap": "^3.15.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.4",
|
||||
"qr-scanner": "^1.4.2",
|
||||
@@ -7293,6 +7294,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
|
||||
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.128-alpha",
|
||||
"version": "1.7.129-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
@@ -33,6 +33,7 @@
|
||||
"dompurify": "^3.3.3",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"gsap": "^3.15.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.4",
|
||||
"qr-scanner": "^1.4.2",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// live D3 force simulation does not hold for this codebase — a full grep for
|
||||
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
|
||||
// Mesh.vue's component tree (or MeshMap.vue's); the only D3 force simulation
|
||||
// in the codebase belongs to NetworkMap.vue (Federation.vue's graph, out of
|
||||
// in the codebase belongs to NetworkMap3D.vue (Federation.vue's graph, out of
|
||||
// this plan's scope). This file therefore only covers the Leaflet map's
|
||||
// activate/deactivate lifecycle — the D3-specific truths from the plan are
|
||||
// vacuously satisfied (there is nothing to leak).
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="network-map-container">
|
||||
<svg ref="svgRef" class="w-full h-full"></svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
interface MapNode {
|
||||
did: string
|
||||
label: string
|
||||
trust_level: 'trusted' | 'observer' | 'untrusted'
|
||||
online: boolean
|
||||
app_count: number
|
||||
is_self: boolean
|
||||
}
|
||||
|
||||
interface MapLink {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: MapNode[]
|
||||
links: MapLink[]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const svgRef = ref<SVGSVGElement>()
|
||||
|
||||
type SimNode = MapNode & d3.SimulationNodeDatum
|
||||
type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; target: string | SimNode }
|
||||
|
||||
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const graphSignature = computed(() => JSON.stringify({
|
||||
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
|
||||
links: props.links.map(l => [l.source, l.target]),
|
||||
}))
|
||||
|
||||
function trustColor(level: string): string {
|
||||
switch (level) {
|
||||
case 'trusted': return '#4ade80'
|
||||
case 'observer': return '#fb923c'
|
||||
case 'untrusted': return '#ef4444'
|
||||
default: return '#9ca3af'
|
||||
}
|
||||
}
|
||||
|
||||
function nodeRadius(n: MapNode): number {
|
||||
return n.is_self ? 18 : Math.max(10, Math.min(16, 8 + n.app_count * 0.5))
|
||||
}
|
||||
|
||||
function render() {
|
||||
simulation?.stop()
|
||||
const svg = d3.select(svgRef.value!)
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
const container = containerRef.value!
|
||||
const width = container.clientWidth
|
||||
const height = container.clientHeight
|
||||
|
||||
svg.attr('viewBox', `0 0 ${width} ${height}`)
|
||||
|
||||
const simNodes: SimNode[] = props.nodes.map(n => ({ ...n }))
|
||||
const simLinks: SimLink[] = props.links.map(l => ({ ...l }))
|
||||
|
||||
// Center the self-node
|
||||
const selfNode = simNodes.find(n => n.is_self)
|
||||
if (selfNode) {
|
||||
selfNode.fx = width / 2
|
||||
selfNode.fy = height / 2
|
||||
}
|
||||
|
||||
simulation = d3.forceSimulation(simNodes)
|
||||
.force('link', d3.forceLink<SimNode, SimLink>(simLinks).id(d => d.did).distance(120))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide<SimNode>().radius(d => nodeRadius(d) + 5))
|
||||
|
||||
const g = svg.append('g')
|
||||
|
||||
// Links
|
||||
const link = g.append('g')
|
||||
.selectAll('line')
|
||||
.data(simLinks)
|
||||
.join('line')
|
||||
.attr('stroke', (d: SimLink) => {
|
||||
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
|
||||
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
|
||||
return (src as MapNode)?.online && (tgt as MapNode)?.online ? '#4ade8060' : '#6b728050'
|
||||
})
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke-dasharray', (d: SimLink) => {
|
||||
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
|
||||
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
|
||||
return (src as MapNode)?.online && (tgt as MapNode)?.online ? 'none' : '6 4'
|
||||
})
|
||||
|
||||
// Node groups
|
||||
const node = g.append('g')
|
||||
.selectAll<SVGGElement, SimNode>('g')
|
||||
.data(simNodes)
|
||||
.join('g')
|
||||
.attr('cursor', 'pointer')
|
||||
.call(d3.drag<SVGGElement, SimNode>()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) simulation!.alphaTarget(0.3).restart()
|
||||
d.fx = d.x
|
||||
d.fy = d.y
|
||||
})
|
||||
.on('drag', (event, d) => {
|
||||
d.fx = event.x
|
||||
d.fy = event.y
|
||||
})
|
||||
.on('end', (event, d) => {
|
||||
if (!event.active) simulation!.alphaTarget(0)
|
||||
if (!d.is_self) { d.fx = null; d.fy = null }
|
||||
})
|
||||
)
|
||||
|
||||
// Node circles
|
||||
node.append('circle')
|
||||
.attr('r', d => nodeRadius(d))
|
||||
.attr('fill', d => trustColor(d.trust_level))
|
||||
.attr('fill-opacity', d => d.online ? 0.8 : 0.3)
|
||||
.attr('stroke', d => d.is_self ? '#fb923c' : trustColor(d.trust_level))
|
||||
.attr('stroke-width', d => d.is_self ? 3 : 1.5)
|
||||
.attr('stroke-opacity', d => d.online ? 1 : 0.4)
|
||||
|
||||
// Node labels
|
||||
node.append('text')
|
||||
.text(d => d.label)
|
||||
.attr('dy', d => nodeRadius(d) + 14)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', 'rgba(255,255,255,0.7)')
|
||||
.attr('font-size', '11px')
|
||||
.attr('font-family', "'Avenir Next', sans-serif")
|
||||
|
||||
// Tooltip
|
||||
node.append('title')
|
||||
.text(d => `${d.did}\nApps: ${d.app_count}\n${d.online ? 'Online' : 'Offline'}`)
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => (d.source as SimNode).x!)
|
||||
.attr('y1', d => (d.source as SimNode).y!)
|
||||
.attr('x2', d => (d.target as SimNode).x!)
|
||||
.attr('y2', d => (d.target as SimNode).y!)
|
||||
|
||||
node.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
resizeObserver = new ResizeObserver(() => render())
|
||||
if (containerRef.value) resizeObserver.observe(containerRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
simulation?.stop()
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
watch(graphSignature, () => render())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-container {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
min-height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3149,3 +3149,50 @@ select {
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Federation 3D node map — fill-to-bottom layout
|
||||
When the map stage is on screen, the dashboard scroll panel switches from
|
||||
a scrolling document to a column that hands all remaining height to the
|
||||
map, killing the big bottom margin on every form factor. List view (no
|
||||
.node-map-stage in the DOM) is untouched, and browsers without :has()
|
||||
gracefully fall back to the old scrolling behaviour via the stage's
|
||||
min-height.
|
||||
========================================================================= */
|
||||
.dashboard-scroll-panel:has(.node-map-stage) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Desktop: trim the 6rem .mobile-scroll-pad breathing room to a slim edge */
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* The routed view stretches; DashboardRouterView tags it .view-container
|
||||
(with Tailwind's flex-none, which this outranks on specificity). */
|
||||
.dashboard-scroll-panel:has(.node-map-stage) > .view-container {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The wrapper's bottom scroll spacer is dead weight in a filled column */
|
||||
.dashboard-scroll-panel:has(.node-map-stage) > div[aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile/tablet: fill down to the tab bar (+ audio player / safe area),
|
||||
not under it — the bar is viewport-fixed and would cover the map. */
|
||||
@media (max-width: 920px) {
|
||||
.dashboard-scroll-panel:has(.node-map-stage) {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pages with the floating mobile back button (.mobile-scroll-pad-back) keep
|
||||
its full clearance under the filled map so the stage never slides beneath
|
||||
the button. */
|
||||
@media (max-width: 920px) {
|
||||
.dashboard-scroll-panel.mobile-scroll-pad-back:has(.node-map-stage) {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Design-system-aware GSAP setup — the single place animation code pulls
|
||||
* timing, easing, and colour tokens from, so every GSAP-driven surface moves
|
||||
* (and is coloured) like the rest of the glass UI instead of inventing its
|
||||
* own physics per component.
|
||||
*
|
||||
* Usage: `import { gsap, motionTokens, prefersReducedMotion } from '@/utils/motion'`
|
||||
* — never `import gsap from 'gsap'` directly, or the shared defaults are lost.
|
||||
*/
|
||||
import { gsap } from 'gsap'
|
||||
|
||||
/** Colour tokens mirrored from style.css / tailwind.config.js. The UI is
|
||||
* dark-only (style.css pins `color-scheme: dark`), so these are constants,
|
||||
* not theme-dependent lookups. */
|
||||
export const motionTokens = {
|
||||
color: {
|
||||
/** Brand accent — the orange used for focus glows and highlights
|
||||
* (tailwind orange-400, e.g. `.glass-button:focus-visible`). */
|
||||
accent: '#fb923c',
|
||||
/** Trust-level palette — matches NodeList / trust badges. */
|
||||
trusted: '#4ade80',
|
||||
observer: '#fb923c',
|
||||
untrusted: '#ef4444',
|
||||
neutral: '#9ca3af',
|
||||
/** Pending/attention — inbound peer requests awaiting a decision. */
|
||||
pending: '#facc15',
|
||||
/** Text/line opacities on the dark glass ground. */
|
||||
textPrimary: 'rgba(255, 255, 255, 0.95)',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.7)',
|
||||
textFaint: 'rgba(255, 255, 255, 0.45)',
|
||||
line: 'rgba(255, 255, 255, 0.18)',
|
||||
lineFaint: 'rgba(255, 255, 255, 0.08)',
|
||||
glassDark: 'rgba(0, 0, 0, 0.35)',
|
||||
glassDarker: 'rgba(0, 0, 0, 0.6)',
|
||||
},
|
||||
/** Durations (seconds) — align with the CSS transitions already shipped
|
||||
* (modal 0.3s, press feedback 0.1s). */
|
||||
duration: {
|
||||
fast: 0.18,
|
||||
base: 0.3,
|
||||
slow: 0.6,
|
||||
/** Scene-setting intros (map fly-in, hero moments). */
|
||||
cinematic: 1.4,
|
||||
},
|
||||
ease: {
|
||||
/** Default UI ease — matches the snappy glass feel. */
|
||||
out: 'power3.out',
|
||||
inOut: 'power2.inOut',
|
||||
/** Playful overshoot for elements "arriving" (node pop-ins). */
|
||||
arrive: 'back.out(1.6)',
|
||||
/** Springy attention pulse. */
|
||||
pulse: 'sine.inOut',
|
||||
},
|
||||
} as const
|
||||
|
||||
// Shared defaults: any tween that doesn't say otherwise moves like the rest
|
||||
// of the design system.
|
||||
gsap.defaults({
|
||||
ease: motionTokens.ease.out,
|
||||
duration: motionTokens.duration.base,
|
||||
})
|
||||
|
||||
/** Live reduced-motion check. Query at animation-build time (not module
|
||||
* scope) so OS-level toggles apply without a reload. Callers should skip
|
||||
* intros / idle loops and jump to end state when this is true. */
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined'
|
||||
&& window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
|
||||
}
|
||||
|
||||
export { gsap }
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<!-- Map view: no pb-6 — the .dashboard-scroll-panel:has(.node-map-stage)
|
||||
rules turn this view into a column that hands remaining height to the
|
||||
map, so bottom padding would just re-create the dead margin. -->
|
||||
<div :class="mapActive ? undefined : 'pb-6'">
|
||||
<FederationHeader
|
||||
:self-did="selfDid"
|
||||
:server-name="appStore.serverName"
|
||||
@@ -16,7 +19,9 @@
|
||||
/>
|
||||
|
||||
<!-- View Tabs (same style as Home Dashboard/Setup tabs; full-width on mobile) -->
|
||||
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto">
|
||||
<!-- md:self-start: in map view the root is a flex column, and stretch
|
||||
alignment would otherwise pull the pill full-width on desktop -->
|
||||
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto md:self-start">
|
||||
<button
|
||||
v-for="tab in viewTabs"
|
||||
:key="tab.id"
|
||||
@@ -30,9 +35,25 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Network Map View -->
|
||||
<div v-if="activeView === 'map' && nodes.length > 0" class="mb-6">
|
||||
<NetworkMap :nodes="mapNodes" :links="mapLinks" />
|
||||
<!-- Mobile DID card: below the tabs per UX; hidden on the map tab where
|
||||
vertical space belongs to the map (desktop keeps the header card) -->
|
||||
<DidCardMobile
|
||||
v-if="!mapActive"
|
||||
:self-did="selfDid"
|
||||
:server-name="appStore.serverName"
|
||||
@rotate="showRotateModal = true"
|
||||
/>
|
||||
|
||||
<!-- Network Map View — fills all remaining height to the bottom edge -->
|
||||
<div v-if="mapActive" class="flex-1 min-h-0">
|
||||
<NetworkMap3D
|
||||
:nodes="mapNodes"
|
||||
:links="mapLinks"
|
||||
:requests="mapRequests"
|
||||
@select="onMapSelect"
|
||||
@approve="approvePending"
|
||||
@reject="rejectPending"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="activeView === 'list'">
|
||||
@@ -243,8 +264,9 @@ import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import { useTransportStore } from '@/stores/transport'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useSyncStore } from '@/stores/sync'
|
||||
import NetworkMap from '@/components/federation/NetworkMap.vue'
|
||||
import NetworkMap3D from '@/components/federation/NetworkMap3D.vue'
|
||||
import FederationHeader from './federation/FederationHeader.vue'
|
||||
import DidCardMobile from './federation/DidCardMobile.vue'
|
||||
import RotateDidModal from './federation/RotateDidModal.vue'
|
||||
import QuickActions from './federation/QuickActions.vue'
|
||||
import NodeList from './federation/NodeList.vue'
|
||||
@@ -308,7 +330,22 @@ function setView(id: ViewId) {
|
||||
localStorage.setItem('federation-view', id)
|
||||
}
|
||||
|
||||
const selfDid = ref('')
|
||||
const mapActive = computed(() => activeView.value === 'map' && nodes.value.length > 0)
|
||||
|
||||
/** Map click-through: tapping a peer opens the same detail modal as the list
|
||||
* view. Tapping the self node is a no-op (its actions live in the header). */
|
||||
function onMapSelect(did: string) {
|
||||
const node = nodes.value.find(n => n.did === did)
|
||||
if (node) selectedNode.value = node
|
||||
}
|
||||
|
||||
/** Seeded from the cached DID so the map's centre node (and its links) exist
|
||||
* on the very first frame; the authoritative fetch in onMounted refreshes it
|
||||
* and re-caches. Without this the intro raced the RPC and often played with
|
||||
* no centre. */
|
||||
const selfDid = ref<string>((() => {
|
||||
try { return localStorage.getItem('neode_did') || '' } catch { return '' }
|
||||
})())
|
||||
|
||||
const mapNodes = computed(() => {
|
||||
const result = []
|
||||
@@ -343,6 +380,16 @@ const mapLinks = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
/** Inbound pending requests for the map — blinking yellow nodes the user can
|
||||
* accept/reject in place (same RPCs as the pending panel). */
|
||||
const mapRequests = computed(() => pendingRequests.value
|
||||
.filter(r => !r.outbound && r.state === 'pending')
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
label: r.from_name || `${r.from_nostr_npub.slice(0, 12)}…`,
|
||||
message: r.message,
|
||||
})))
|
||||
|
||||
const dwnStatusRes = useCachedResource<DwnStatus>({
|
||||
key: 'federation.dwn-status',
|
||||
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),
|
||||
@@ -778,6 +825,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const result = await rpcClient.getNodeDid()
|
||||
selfDid.value = result.did
|
||||
try { localStorage.setItem('neode_did', result.did) } catch { /* private mode */ }
|
||||
} catch {
|
||||
// Self DID not available
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// live D3 force simulation does not hold for this codebase — a full grep for
|
||||
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
|
||||
// Mesh.vue's component tree; the only D3 force simulation belongs to
|
||||
// NetworkMap.vue (Federation.vue's graph, out of this plan's scope). This
|
||||
// NetworkMap3D.vue (Federation.vue's graph, out of this plan's scope). This
|
||||
// file therefore only covers the six cached fetch groups (Task 1) and the
|
||||
// Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the
|
||||
// D3-specific truths are vacuously satisfied (there is nothing to leak).
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<!-- Mobile-only DID copy/rotate card. Lives BELOW the view tabs in
|
||||
Federation.vue (not in the header) and is hidden by the parent on the
|
||||
Network Map tab, where vertical space belongs to the map. -->
|
||||
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mb-6 flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
|
||||
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
|
||||
</div>
|
||||
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { shortDid } from './utils'
|
||||
import { safeClipboardWrite } from '../web5/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
selfDid: string
|
||||
serverName: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
rotate: []
|
||||
}>()
|
||||
|
||||
const didCopied = ref(false)
|
||||
const shortDidDisplay = computed(() => shortDid(props.selfDid))
|
||||
|
||||
function handleCopy() {
|
||||
if (props.selfDid) {
|
||||
safeClipboardWrite(props.selfDid)
|
||||
didCopied.value = true
|
||||
setTimeout(() => { didCopied.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -18,15 +18,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: DID below title -->
|
||||
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mt-3 flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
|
||||
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
|
||||
</div>
|
||||
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
|
||||
</div>
|
||||
<!-- Mobile DID card moved to DidCardMobile.vue, rendered by
|
||||
Federation.vue below the view tabs (hidden on the map tab). -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -362,6 +362,21 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.129-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.129-alpha</span>
|
||||
<span class="text-xs text-white/40">August 10, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>**Every app is now supervised the same way — the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.</p>
|
||||
<p>**Leftover companion screens are cleaned up again — driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed — it had been guessing "installed" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — "I couldn't check" is never treated as "nothing is installed" — and a helper must be orphaned for a sustained period before it is touched.</p>
|
||||
<p>**A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right — Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.</p>
|
||||
<p>**The federation map became a real map.** The network view is now a 3D orbital scene of your federation — nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro — the scene updates in place.</p>
|
||||
<p>**An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.</p>
|
||||
<p>Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle).</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.128-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+19
-19
@@ -1,32 +1,32 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**The discovery list stops showing ghosts.** Every reinstall of a node mints a new discovery identity, and the old identity's announcement could never be removed from the public relays — nothing holds its key anymore — so the \"Discoverable nodes\" list slowly filled with entries that led nowhere. Announcements now expire: your node re-announces itself twice a day, each announcement carries a 48-hour expiry that relays honour, anything older than that is ignored when reading, and switching discovery off — or factory-resetting the node — actively overwrites the announcement before it can become a ghost. Old ghosts from earlier versions stop being shown immediately and age off the relays on their own.",
|
||||
"**You can name your node when you make it discoverable.** Turning discovery on now asks for an optional display name — it travels inside the public announcement, so other nodes' discovery lists show \"Dorian's basement node\" instead of a bare npub. The name is public by construction, capped at 32 characters, and blank is fine: you list as npub only. Toggling discovery off and on remembers the name; you can clear it the same way you set it.",
|
||||
"**The discoverability panel now shows what the network actually sees: your node's npub.** It previously showed your Tor address — which is precisely the thing the announcement never contains (your address stays private until you approve a peer). The npub, the identity other nodes discover you by and send peering requests to, is now displayed there with a copy button.",
|
||||
"**The seed screen stops flashing while the node starts.** During first boot, the lock icon and \"server starting\" text blinked in and out every few seconds while the node came up — each silent retry briefly emptied the screen. The waiting state now holds steady, with its elapsed timer, until the node answers.",
|
||||
"**A node that already has an identity now explains itself on the seed screen.** Reaching seed creation on a provisioned node used to surface a developer message about \"the authenticated system.factory-reset\". It now says what you can actually do: sign in normally, or factory-reset the node from Settings to start it over.",
|
||||
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the changes were verified by operator UAT on a live node."
|
||||
"**Every app is now supervised the same way — the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.",
|
||||
"**Leftover companion screens are cleaned up again — driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed — it had been guessing \"installed\" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — \"I couldn't check\" is never treated as \"nothing is installed\" — and a helper must be orphaned for a sustained period before it is touched.",
|
||||
"**A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right — Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.",
|
||||
"**The federation map became a real map.** The network view is now a 3D orbital scene of your federation — nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro — the scene updates in place.",
|
||||
"**An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.",
|
||||
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle)."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.128-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.128-alpha/archipelago",
|
||||
"current_version": "1.7.129-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.129-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.128-alpha",
|
||||
"sha256": "ffc97ab91717323b467d6a8bda62c98275d81796258f5c8f05385001df799b6c",
|
||||
"size_bytes": 59866672
|
||||
"new_version": "1.7.129-alpha",
|
||||
"sha256": "675e7dafc855d59b38c5a12d8b9405894677ed8701580227beca95ec2912e3f2",
|
||||
"size_bytes": 59531400
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.128-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.128-alpha/archipelago-frontend-1.7.128-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.128-alpha.tar.gz",
|
||||
"new_version": "1.7.128-alpha",
|
||||
"sha256": "b3137a67c02a7cb33333f3f0b6a68cd3ad5c8d35baba3d9e099f441642054e80",
|
||||
"size_bytes": 95429928
|
||||
"current_version": "1.7.129-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.129-alpha/archipelago-frontend-1.7.129-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.129-alpha.tar.gz",
|
||||
"new_version": "1.7.129-alpha",
|
||||
"sha256": "53af2743308f4ae6a627255aaa288706d331d567c99e1cb615684dc3abba534d",
|
||||
"size_bytes": 95452033
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-10",
|
||||
"signature": "eb8c684ef9ebe1046c9abbcdf5a698c37b11f5630fb67062b844ad00ece913e995d41062c9c3ca432b2a47bdbb8e35c38c8ecda60d1a3e82171c8d10d910b108",
|
||||
"signature": "902778710674486d5919e4abb1bf5540521c9ef55b50a44a9d64b750812738d51cc193f6675a9969b1c45ae86c2e9e44ab3d8daf8aa1d439799d7f7846e27d04",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.7.128-alpha"
|
||||
"version": "1.7.129-alpha"
|
||||
}
|
||||
|
||||
+19
-19
@@ -1,32 +1,32 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**The discovery list stops showing ghosts.** Every reinstall of a node mints a new discovery identity, and the old identity's announcement could never be removed from the public relays — nothing holds its key anymore — so the \"Discoverable nodes\" list slowly filled with entries that led nowhere. Announcements now expire: your node re-announces itself twice a day, each announcement carries a 48-hour expiry that relays honour, anything older than that is ignored when reading, and switching discovery off — or factory-resetting the node — actively overwrites the announcement before it can become a ghost. Old ghosts from earlier versions stop being shown immediately and age off the relays on their own.",
|
||||
"**You can name your node when you make it discoverable.** Turning discovery on now asks for an optional display name — it travels inside the public announcement, so other nodes' discovery lists show \"Dorian's basement node\" instead of a bare npub. The name is public by construction, capped at 32 characters, and blank is fine: you list as npub only. Toggling discovery off and on remembers the name; you can clear it the same way you set it.",
|
||||
"**The discoverability panel now shows what the network actually sees: your node's npub.** It previously showed your Tor address — which is precisely the thing the announcement never contains (your address stays private until you approve a peer). The npub, the identity other nodes discover you by and send peering requests to, is now displayed there with a copy button.",
|
||||
"**The seed screen stops flashing while the node starts.** During first boot, the lock icon and \"server starting\" text blinked in and out every few seconds while the node came up — each silent retry briefly emptied the screen. The waiting state now holds steady, with its elapsed timer, until the node answers.",
|
||||
"**A node that already has an identity now explains itself on the seed screen.** Reaching seed creation on a provisioned node used to surface a developer message about \"the authenticated system.factory-reset\". It now says what you can actually do: sign in normally, or factory-reset the node from Settings to start it over.",
|
||||
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the changes were verified by operator UAT on a live node."
|
||||
"**Every app is now supervised the same way — the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.",
|
||||
"**Leftover companion screens are cleaned up again — driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed — it had been guessing \"installed\" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — \"I couldn't check\" is never treated as \"nothing is installed\" — and a helper must be orphaned for a sustained period before it is touched.",
|
||||
"**A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right — Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.",
|
||||
"**The federation map became a real map.** The network view is now a 3D orbital scene of your federation — nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro — the scene updates in place.",
|
||||
"**An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.",
|
||||
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle)."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.128-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.128-alpha/archipelago",
|
||||
"current_version": "1.7.129-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.129-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.128-alpha",
|
||||
"sha256": "ffc97ab91717323b467d6a8bda62c98275d81796258f5c8f05385001df799b6c",
|
||||
"size_bytes": 59866672
|
||||
"new_version": "1.7.129-alpha",
|
||||
"sha256": "675e7dafc855d59b38c5a12d8b9405894677ed8701580227beca95ec2912e3f2",
|
||||
"size_bytes": 59531400
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.128-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.128-alpha/archipelago-frontend-1.7.128-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.128-alpha.tar.gz",
|
||||
"new_version": "1.7.128-alpha",
|
||||
"sha256": "b3137a67c02a7cb33333f3f0b6a68cd3ad5c8d35baba3d9e099f441642054e80",
|
||||
"size_bytes": 95429928
|
||||
"current_version": "1.7.129-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.129-alpha/archipelago-frontend-1.7.129-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.129-alpha.tar.gz",
|
||||
"new_version": "1.7.129-alpha",
|
||||
"sha256": "53af2743308f4ae6a627255aaa288706d331d567c99e1cb615684dc3abba534d",
|
||||
"size_bytes": 95452033
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-10",
|
||||
"signature": "eb8c684ef9ebe1046c9abbcdf5a698c37b11f5630fb67062b844ad00ece913e995d41062c9c3ca432b2a47bdbb8e35c38c8ecda60d1a3e82171c8d10d910b108",
|
||||
"signature": "902778710674486d5919e4abb1bf5540521c9ef55b50a44a9d64b750812738d51cc193f6675a9969b1c45ae86c2e9e44ab3d8daf8aa1d439799d7f7846e27d04",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.7.128-alpha"
|
||||
"version": "1.7.129-alpha"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user