diff --git a/components/StatusBadge.vue b/components/StatusBadge.vue index cfd1868..a212d64 100644 --- a/components/StatusBadge.vue +++ b/components/StatusBadge.vue @@ -16,6 +16,7 @@ const statusMap: Record = stopping: { dot: '◐', label: 'Stopping', color: 'text-autonomi-warning' }, adding: { dot: '◐', label: 'Adding...', color: 'text-autonomi-warning' }, errored: { dot: '●', label: 'Error', color: 'text-autonomi-error' }, + upgrade_scheduled: { dot: '◐', label: 'Upgrading', color: 'text-autonomi-blue' }, // File transfer statuses Pending: { dot: '○', label: 'Pending', color: 'text-autonomi-muted' }, Quoting: { dot: '◐', label: 'Quoting', color: 'text-autonomi-warning' }, diff --git a/components/nodes/NodeDetail.vue b/components/nodes/NodeDetail.vue index 4776cb2..2bcf17d 100644 --- a/components/nodes/NodeDetail.vue +++ b/components/nodes/NodeDetail.vue @@ -12,7 +12,12 @@
Version -

{{ node.version }}

+

+ {{ node.version }}→ {{ node.pending_version }} +

PID diff --git a/components/nodes/NodeDetailPanel.vue b/components/nodes/NodeDetailPanel.vue index 94fc0e4..d841617 100644 --- a/components/nodes/NodeDetailPanel.vue +++ b/components/nodes/NodeDetailPanel.vue @@ -19,7 +19,12 @@

Version

-

{{ node.version }}

+

+ {{ node.version }}→ {{ node.pending_version }} +

PID

diff --git a/components/nodes/NodeTile.vue b/components/nodes/NodeTile.vue index b5b48cb..83018e0 100644 --- a/components/nodes/NodeTile.vue +++ b/components/nodes/NodeTile.vue @@ -11,7 +11,7 @@ + +
+
+

Restarting node daemon...

+

Your nodes keep running.

+
+
diff --git a/pages/settings.vue b/pages/settings.vue index 5bc8e41..57becec 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -118,6 +118,26 @@
+ +
+
+
+

Node daemon

+

+ Restart the background service that supervises your nodes. Your + running nodes keep running — only the supervisor is swapped. +

+
+ +
+
+
@@ -477,6 +497,20 @@ const editingDaemon = ref(false) const daemonInput = ref('') const daemonInputRef = ref(null) +// Daemon control — delegate to the nodes store so the nodes page can show a +// "Restarting" panel instead of the default "Cannot connect" flicker during +// the brief window when the old daemon is down. +const daemonRestarting = computed(() => nodesStore.restarting) + +async function restartDaemon() { + try { + await nodesStore.restartDaemon() + toasts.add('Daemon restarted', 'info') + } catch (e: any) { + toasts.add(`Failed to restart daemon: ${e?.message ?? e}`, 'error') + } +} + // Direct wallet (private key) const editingDirectWallet = ref(false) const directWalletKeyInput = ref('') diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7747c62..3aa802d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -370,9 +370,89 @@ async fn disconnect_daemon_sse(state: tauri::State<'_, Arc>) -> Result Ok(()) } +/// True when the bundled sidecar binary was modified noticeably after the +/// current daemon's pid file — i.e. the user installed an app update since +/// the daemon last started, so the running daemon is from the previous +/// version. 5-second tolerance avoids false positives from filesystem +/// timestamp granularity and install-time clock skew. +fn is_daemon_stale() -> bool { + let Some(sidecar) = find_daemon_binary() else { + return false; + }; + let Ok(data_dir) = ant_core::config::data_dir() else { + return false; + }; + let sidecar_mtime = match std::fs::metadata(&sidecar).and_then(|m| m.modified()) { + Ok(t) => t, + Err(_) => return false, + }; + let pid_mtime = match std::fs::metadata(data_dir.join("daemon.pid")).and_then(|m| m.modified()) + { + Ok(t) => t, + Err(_) => return false, + }; + sidecar_mtime + .duration_since(pid_mtime) + .map(|d| d > std::time::Duration::from_secs(5)) + .unwrap_or(false) +} + +/// Stop the daemon if it's running, and wait for the port file / HTTP server +/// to go away so the caller can safely start a new daemon without a bind +/// conflict. No-op if the daemon isn't currently running. +async fn stop_daemon_internal() -> Result<(), String> { + use ant_core::node::daemon::client as daemon_client; + let config = ant_core::node::types::DaemonConfig::default(); + + if !daemon_client::info(&config).running { + return Ok(()); + } + + daemon_client::stop(&config) + .await + .map_err(|e| format!("Failed to stop daemon: {e}"))?; + + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_millis(500)) + .build() + .unwrap_or_default(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + match config::discover_daemon_url() { + None => return Ok(()), + Some(url) => { + if http_client + .get(format!("{url}/api/v1/status")) + .send() + .await + .is_err() + { + return Ok(()); + } + } + } + } + Err("Daemon did not shut down within 10 seconds".into()) +} + +/// Stop the running daemon (if any) and start a fresh one. Node processes +/// are intentionally decoupled from the daemon lifecycle +/// (`ant-core/src/node/process/spawn.rs` → `kill_on_drop(false)`), so they +/// continue running throughout the restart. +#[tauri::command] +async fn restart_daemon() -> Result { + stop_daemon_internal().await?; + ensure_daemon_running().await +} + /// Start the daemon if it's not already running. /// Uses a bundled sidecar binary (production) or PATH fallback (dev). /// Spawns detached so the daemon survives app close. +/// +/// If a running daemon is detected but the bundled sidecar is newer +/// (`is_daemon_stale`), the old daemon is stopped first so the app +/// transparently picks up the updated binary on relaunch. #[tauri::command] async fn ensure_daemon_running() -> Result { // Check if already running via port file @@ -387,12 +467,20 @@ async fn ensure_daemon_running() -> Result { .await .is_ok() { - return Ok(url); - } - // Port file exists but daemon is unresponsive — clean up stale files - if let Ok(data) = ant_core::config::data_dir() { - let _ = std::fs::remove_file(data.join("daemon.port")); - let _ = std::fs::remove_file(data.join("daemon.pid")); + if is_daemon_stale() { + tracing::info!( + "Running daemon predates the current app install; restarting to pick up the bundled sidecar" + ); + stop_daemon_internal().await?; + } else { + return Ok(url); + } + } else { + // Port file exists but daemon is unresponsive — clean up stale files + if let Ok(data) = ant_core::config::data_dir() { + let _ = std::fs::remove_file(data.join("daemon.port")); + let _ = std::fs::remove_file(data.join("daemon.pid")); + } } } @@ -647,6 +735,7 @@ pub fn run() { save_upload_history, discover_daemon_url, ensure_daemon_running, + restart_daemon, connect_daemon_sse, disconnect_daemon_sse, autonomi_ops::init_autonomi_client, diff --git a/stores/nodes.ts b/stores/nodes.ts index 11c5305..7f0e6d0 100644 --- a/stores/nodes.ts +++ b/stores/nodes.ts @@ -17,6 +17,9 @@ export interface NodeInfo { version: string pid?: number uptime_secs?: number + /** Version the supervisor detected on disk but hasn't booted yet. Populated + * during the `upgrade_scheduled` window, cleared on `node_upgraded`. */ + pending_version?: string // Fields from NodeConfig (available when daemon provides full info) rewards_address?: string data_dir?: string @@ -38,6 +41,7 @@ function summaryToNodeInfo(s: NodeStatusSummary): NodeInfo { version: s.version, pid: s.pid, uptime_secs: s.uptime_secs, + pending_version: s.pending_version, } } @@ -46,6 +50,11 @@ export const useNodesStore = defineStore('nodes', { nodes: [] as NodeInfo[], loading: false, initializing: true, + /** True while an explicit daemon restart is in flight. Suppresses the + * "Cannot connect" flash that would otherwise happen during the ~1 s + * gap between the old daemon shutting down and the new one accepting + * connections. */ + restarting: false, daemonConnected: false, daemonStatus: null as DaemonStatus | null, _pollTimer: null as ReturnType | null, @@ -120,8 +129,10 @@ export const useNodesStore = defineStore('nodes', { }) this.daemonConnected = true } catch { - if (this.daemonConnected) { - // Was connected, now lost — schedule reconnect + // Don't demote to "disconnected" during a known restart — the new + // daemon is seconds away and the UI renders a `restarting` panel in + // the meantime. + if (this.daemonConnected && !this.restarting) { this.daemonConnected = false this.scheduleReconnect() } @@ -130,6 +141,29 @@ export const useNodesStore = defineStore('nodes', { } }, + /** Trigger a daemon restart from the UI. Covers the connect-flicker that + * the manual `restart_daemon` command would otherwise produce: polling + * errors while the old daemon is dead don't flip `daemonConnected`, and + * `pages/index.vue` renders a dedicated "Restarting" panel for the + * duration. Node processes are untouched by the restart (spawn.rs sets + * kill_on_drop(false)), so the node list and counts stay stable. */ + async restartDaemon() { + this.restarting = true + try { + const url = await invoke('restart_daemon') + const settings = useSettingsStore() + if (url && url !== settings.daemonUrl) { + settings.daemonUrl = url + } + // Force a fresh fetch so the UI reflects the post-restart state + // immediately instead of waiting for the next poll tick. + await this.fetchDaemonStatus() + await this.fetchNodes() + } finally { + this.restarting = false + } + }, + /** Enrich nodes with storage usage from the LMDB payload file. */ async enrichNodeDetails() { for (const node of this.nodes) { @@ -286,6 +320,26 @@ export const useNodesStore = defineStore('nodes', { case 'node_restarting': if (node) node.status = 'starting' break + case 'upgrade_scheduled': + // Binary on disk has changed; node still running old version until + // it exits (--stop-on-upgrade). Surface the target version so the + // UI can show "v0.10.1 → v0.10.2 (upgrading)". + if (node) { + node.status = 'upgrade_scheduled' + node.pending_version = event.pending_version + } + break + case 'node_upgraded': + // Supervisor has respawned the node against the new binary. Update + // the live version and clear the pending marker. Status will land + // on 'running' via the accompanying node_started event; set it + // here too in case events arrive out of order. + if (node) { + if (event.new_version) node.version = event.new_version + node.pending_version = undefined + if (node.status === 'upgrade_scheduled') node.status = 'running' + } + break } }, diff --git a/utils/daemon-api.ts b/utils/daemon-api.ts index e6eb353..0e0c84a 100644 --- a/utils/daemon-api.ts +++ b/utils/daemon-api.ts @@ -4,7 +4,16 @@ import { useSettingsStore } from '~/stores/settings' // ── Types matching ant-core/src/node/types.rs ── -export type NodeStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'errored' +export type NodeStatus = + | 'stopped' + | 'starting' + | 'running' + | 'stopping' + | 'errored' + /** The node's on-disk binary has been replaced by an auto-upgrade, but the + * process has not yet restarted. The supervisor is waiting for the current + * process to exit before respawning it against the new binary. */ + | 'upgrade_scheduled' export interface NodeConfig { id: number @@ -36,6 +45,9 @@ export interface NodeStatusSummary { status: NodeStatus pid?: number uptime_secs?: number + /** Set only when `status === 'upgrade_scheduled'`: the new version that the + * replaced on-disk binary reports. Omitted otherwise. */ + pending_version?: string } export interface DaemonStatus { @@ -110,6 +122,12 @@ export interface NodeEvent { bytes?: number total?: number path?: string + /** UpgradeScheduled: the new version the supervisor detected on disk. */ + pending_version?: string + /** NodeUpgraded: version before the supervisor respawned against the new binary. */ + old_version?: string + /** NodeUpgraded: version the respawned process now reports. */ + new_version?: string } // ── API Client ──