Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/StatusBadge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const statusMap: Record<string, { dot: string; label: string; color: string }> =
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' },
Expand Down
7 changes: 6 additions & 1 deletion components/nodes/NodeDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
</div>
<div>
<span class="text-autonomi-muted">Version</span>
<p>{{ node.version }}</p>
<p>
{{ node.version }}<span
v-if="node.pending_version"
class="ml-1 text-autonomi-blue"
>→ {{ node.pending_version }}</span>
</p>
</div>
<div>
<span class="text-autonomi-muted">PID</span>
Expand Down
7 changes: 6 additions & 1 deletion components/nodes/NodeDetailPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@
<div class="grid grid-cols-2 gap-x-8 gap-y-3 text-sm lg:grid-cols-4">
<div>
<p class="text-[10px] uppercase tracking-wider text-autonomi-muted">Version</p>
<p class="text-autonomi-text">{{ node.version }}</p>
<p class="text-autonomi-text">
{{ node.version }}<span
v-if="node.pending_version"
class="ml-1 text-autonomi-blue"
>→ {{ node.pending_version }}</span>
</p>
</div>
<div>
<p class="text-[10px] uppercase tracking-wider text-autonomi-muted">PID</p>
Expand Down
11 changes: 9 additions & 2 deletions components/nodes/NodeTile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<div class="absolute left-3 top-3" :aria-label="`Status: ${node.status}`" role="img">
<span class="relative flex h-2.5 w-2.5">
<span
v-if="node.status === 'running' || node.status === 'adding'"
v-if="node.status === 'running' || node.status === 'adding' || node.status === 'upgrade_scheduled'"
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-40"
:class="dotBgClass"
/>
Expand All @@ -22,7 +22,12 @@
<!-- Node name / ID -->
<div class="mb-3 mt-1 pl-5">
<p class="text-sm font-medium text-autonomi-text">{{ node.name || `Node ${node.id}` }}</p>
<p v-if="node.version" class="text-[10px] text-autonomi-muted">v{{ node.version }}</p>
<p v-if="node.version" class="text-[10px] text-autonomi-muted">
v{{ node.version }}<span
v-if="node.pending_version"
class="ml-1 text-autonomi-blue"
>→ v{{ node.pending_version }}</span>
</p>
</div>

<!-- Stats grid -->
Expand Down Expand Up @@ -63,6 +68,8 @@ const statusColors: Record<string, { dot: string; bg: string }> = {
adding: { dot: 'bg-autonomi-warning', bg: 'bg-autonomi-warning' },
errored: { dot: 'bg-autonomi-error', bg: 'bg-autonomi-error' },
stopped: { dot: 'border-2 border-autonomi-muted bg-transparent', bg: '' },
// Auto-upgrade in progress: node is still running old binary until it exits.
upgrade_scheduled: { dot: 'bg-autonomi-blue', bg: 'bg-autonomi-blue' },
}

const dotClass = computed(() => statusColors[props.node.status]?.dot ?? 'bg-autonomi-muted')
Expand Down
8 changes: 8 additions & 0 deletions pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@
<p class="text-sm text-autonomi-muted">Starting node daemon...</p>
</div>

<!-- Restarting state — takes priority over disconnected so the UI doesn't
flicker the "Cannot connect" panel during an intentional restart. -->
<div v-else-if="nodesStore.restarting" class="flex flex-col items-center justify-center py-20">
<div class="mb-3 h-6 w-6 animate-spin rounded-full border-2 border-autonomi-blue border-t-transparent" />
<p class="text-sm text-autonomi-muted">Restarting node daemon...</p>
<p class="mt-1 text-xs text-autonomi-muted">Your nodes keep running.</p>
</div>

<!-- Disconnected state -->
<div v-else-if="!nodesStore.daemonConnected" class="flex flex-col items-center justify-center py-20">
<div class="rounded-full border border-autonomi-error/30 bg-autonomi-error/5 p-4">
Expand Down
34 changes: 34 additions & 0 deletions pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,26 @@
</button>
<div v-if="showAdvanced" class="mt-2 space-y-4">

<!-- Node daemon -->
<div class="rounded-lg border border-autonomi-border p-4">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-medium">Node daemon</h3>
<p class="text-xs text-autonomi-muted">
Restart the background service that supervises your nodes. Your
running nodes keep running — only the supervisor is swapped.
</p>
</div>
<button
:disabled="daemonRestarting"
class="ml-3 shrink-0 rounded-md border border-autonomi-border px-2.5 py-1 text-xs text-autonomi-muted hover:text-autonomi-text disabled:opacity-50"
@click="restartDaemon"
>
{{ daemonRestarting ? 'Restarting…' : 'Restart daemon' }}
</button>
</div>
</div>

<!-- Upload concurrency -->
<div class="rounded-lg border border-autonomi-border p-4">
<div class="flex items-center justify-between gap-3">
Expand Down Expand Up @@ -477,6 +497,20 @@ const editingDaemon = ref(false)
const daemonInput = ref('')
const daemonInputRef = ref<HTMLInputElement | null>(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('')
Expand Down
101 changes: 95 additions & 6 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,9 +370,89 @@ async fn disconnect_daemon_sse(state: tauri::State<'_, Arc<SseState>>) -> 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<String, String> {
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<String, String> {
// Check if already running via port file
Expand All @@ -387,12 +467,20 @@ async fn ensure_daemon_running() -> Result<String, String> {
.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"));
}
}
}

Expand Down Expand Up @@ -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,
Expand Down
58 changes: 56 additions & 2 deletions stores/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
}

Expand All @@ -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<typeof setInterval> | null,
Expand Down Expand Up @@ -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()
}
Expand All @@ -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<string>('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) {
Expand Down Expand Up @@ -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
}
},

Expand Down
20 changes: 19 additions & 1 deletion utils/daemon-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ──
Expand Down
Loading