P1: sidecar lifecycle and HTTP bridge
Backend - sidecar.rs supervises the bundled `mycelium` binary launched via pkexec; locates it in resource_dir or CARGO_MANIFEST_DIR/binaries matching $TAURI_ENV_TARGET_TRIPLE - ephemeral port via portpicker, key + config persisted in app_data_dir, kill_on_drop with explicit start_kill on stop - health-check loop calls /api/v1/admin until 2xx (timeout 20s); emits sidecar://ready and sidecar://exited - 500-line ring buffer of stdout/stderr surfaced via sidecar_logs command for the upcoming Settings page - elevation::is_auth_failure(126|127) maps pkexec cancel to a dedicated AppError variant - AppError uses thiserror, Serialize impl renders messages as plain strings for the JS side Frontend - typed `api` wrapper around invoke() in src/lib/api.ts - node store (Pinia) bootstraps on mount, listens on sidecar://ready and sidecar://exited - StartupOverlay covers the whole window for idle/starting/error phases; sidebar status dot + start/stop button - Status view renders subnet, pubkey, api endpoint and key path with one-click clipboard copy
This commit is contained in:
0
src-tauri/binaries/.keep
Normal file
0
src-tauri/binaries/.keep
Normal file
26
src-tauri/src/api/admin.rs
Normal file
26
src-tauri/src/api/admin.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use crate::api::MyceliumClient;
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct NodeInfo {
|
||||||
|
pub node_subnet: String,
|
||||||
|
pub node_pubkey: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MyceliumClient {
|
||||||
|
pub async fn node_info(&self) -> AppResult<NodeInfo> {
|
||||||
|
let resp = self.http().get(self.url("/admin")).send().await?;
|
||||||
|
Self::parse(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap liveness probe used by the supervisor's health-check loop.
|
||||||
|
/// Returns `true` if `/admin` answered 2xx.
|
||||||
|
pub async fn is_alive(&self) -> bool {
|
||||||
|
match self.http().get(self.url("/admin")).send().await {
|
||||||
|
Ok(r) => r.status().is_success(),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src-tauri/src/api/mod.rs
Normal file
68
src-tauri/src/api/mod.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
pub mod admin;
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use reqwest::{Client, Response};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Thin REST client for the mycelium daemon's HTTP API.
|
||||||
|
///
|
||||||
|
/// The base URL is set after the sidecar reports ready; clients are cheap
|
||||||
|
/// to clone (the inner `reqwest::Client` keeps a shared connection pool).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MyceliumClient {
|
||||||
|
base: String,
|
||||||
|
http: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MyceliumClient {
|
||||||
|
pub fn new(base: impl Into<String>) -> Self {
|
||||||
|
let http = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.expect("reqwest client build");
|
||||||
|
Self {
|
||||||
|
base: base.into(),
|
||||||
|
http,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_url(&self) -> &str {
|
||||||
|
&self.base
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn url(&self, path: &str) -> String {
|
||||||
|
format!("{}/api/v1{}", self.base, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn parse<T: DeserializeOwned>(resp: Response) -> AppResult<T> {
|
||||||
|
let status = resp.status();
|
||||||
|
if status.is_success() {
|
||||||
|
resp.json::<T>().await.map_err(AppError::from)
|
||||||
|
} else {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
Err(AppError::DaemonStatus {
|
||||||
|
status: status.as_u16(),
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // wired in P2 (peers add/remove)
|
||||||
|
pub(crate) async fn check_status(resp: Response) -> AppResult<()> {
|
||||||
|
let status = resp.status();
|
||||||
|
if status.is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
Err(AppError::DaemonStatus {
|
||||||
|
status: status.as_u16(),
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn http(&self) -> &Client {
|
||||||
|
&self.http
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src-tauri/src/commands.rs
Normal file
53
src-tauri/src/commands.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use crate::api::admin::NodeInfo;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::sidecar::SidecarConfig;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DaemonStatus {
|
||||||
|
pub running: bool,
|
||||||
|
pub api_url: Option<String>,
|
||||||
|
pub key_path: Option<String>,
|
||||||
|
pub config_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn daemon_status(state: State<'_, AppState>) -> DaemonStatus {
|
||||||
|
let sc = &state.sidecar;
|
||||||
|
DaemonStatus {
|
||||||
|
running: sc.is_running(),
|
||||||
|
api_url: sc.client().map(|c| c.base_url().to_string()),
|
||||||
|
key_path: sc.key_path().map(|p| p.display().to_string()),
|
||||||
|
config_path: sc.config_path().map(|p| p.display().to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_daemon(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
config: Option<SidecarConfig>,
|
||||||
|
) -> AppResult<DaemonStatus> {
|
||||||
|
let cfg = config.unwrap_or_default();
|
||||||
|
state.sidecar.start(&app, &cfg).await?;
|
||||||
|
Ok(daemon_status(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_daemon(state: State<'_, AppState>) -> AppResult<DaemonStatus> {
|
||||||
|
state.sidecar.stop().await;
|
||||||
|
Ok(daemon_status(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn node_info(state: State<'_, AppState>) -> AppResult<NodeInfo> {
|
||||||
|
let client = state.sidecar.client().ok_or(AppError::DaemonNotRunning)?;
|
||||||
|
client.node_info().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn sidecar_logs(state: State<'_, AppState>) -> Vec<String> {
|
||||||
|
state.sidecar.logs_snapshot()
|
||||||
|
}
|
||||||
26
src-tauri/src/elevation.rs
Normal file
26
src-tauri/src/elevation.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
/// Build a `tokio::process::Command` that runs `target` with elevated
|
||||||
|
/// privileges via `pkexec`. The caller is responsible for setting stdio,
|
||||||
|
/// kill_on_drop, etc.
|
||||||
|
pub fn elevated(target: &Path, args: &[String]) -> Command {
|
||||||
|
let mut cmd = Command::new("pkexec");
|
||||||
|
cmd.arg(target);
|
||||||
|
for a in args {
|
||||||
|
cmd.arg(a);
|
||||||
|
}
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// pkexec exit codes worth surfacing distinctly.
|
||||||
|
///
|
||||||
|
/// 126 — authorization could not be obtained (user cancelled the dialog,
|
||||||
|
/// no agent available, or polkit policy denied).
|
||||||
|
/// 127 — command was not found / not authorized.
|
||||||
|
///
|
||||||
|
/// Anything else is forwarded as-is and the supervisor will translate it
|
||||||
|
/// into `SidecarExited`.
|
||||||
|
pub fn is_auth_failure(code: i32) -> bool {
|
||||||
|
matches!(code, 126 | 127)
|
||||||
|
}
|
||||||
54
src-tauri/src/error.rs
Normal file
54
src-tauri/src/error.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("daemon not running")]
|
||||||
|
DaemonNotRunning,
|
||||||
|
|
||||||
|
#[error("daemon already running")]
|
||||||
|
DaemonAlreadyRunning,
|
||||||
|
|
||||||
|
#[error("daemon health-check timed out after {0}s")]
|
||||||
|
HealthCheckTimeout(u64),
|
||||||
|
|
||||||
|
#[error("could not locate mycelium sidecar binary (looked for {0:?})")]
|
||||||
|
SidecarNotFound(Vec<std::path::PathBuf>),
|
||||||
|
|
||||||
|
#[error("sidecar exited unexpectedly: {0}")]
|
||||||
|
SidecarExited(String),
|
||||||
|
|
||||||
|
#[error("pkexec authentication was cancelled or failed")]
|
||||||
|
ElevationCancelled,
|
||||||
|
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("http error: {0}")]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("daemon returned status {status}: {body}")]
|
||||||
|
DaemonStatus { status: u16, body: String },
|
||||||
|
|
||||||
|
#[error("tauri error: {0}")]
|
||||||
|
Tauri(#[from] tauri::Error),
|
||||||
|
|
||||||
|
#[error("tauri path error: {0}")]
|
||||||
|
TauriPath(String),
|
||||||
|
|
||||||
|
#[error("invalid argument: {0}")]
|
||||||
|
BadInput(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
|
|
||||||
|
// Serialize errors as plain strings for the JS side. invoke() rejects with
|
||||||
|
// the message; the front-end matches on substring or surfaces it raw.
|
||||||
|
impl Serialize for AppError {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
s.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
|
pub mod api;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod elevation;
|
||||||
|
pub mod error;
|
||||||
|
pub mod sidecar;
|
||||||
|
pub mod state;
|
||||||
|
|
||||||
|
use state::AppState;
|
||||||
|
use tauri::Manager;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -15,7 +24,17 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.invoke_handler(tauri::generate_handler![])
|
.setup(|app| {
|
||||||
|
app.manage(AppState::new());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::daemon_status,
|
||||||
|
commands::start_daemon,
|
||||||
|
commands::stop_daemon,
|
||||||
|
commands::node_info,
|
||||||
|
commands::sidecar_logs,
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
286
src-tauri/src/sidecar.rs
Normal file
286
src-tauri/src/sidecar.rs
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
use crate::api::MyceliumClient;
|
||||||
|
use crate::elevation;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Child;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
const HEALTH_CHECK_TIMEOUT_SECS: u64 = 20;
|
||||||
|
const HEALTH_CHECK_INTERVAL_MS: u64 = 400;
|
||||||
|
const LOG_RING_CAPACITY: usize = 500;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SidecarConfig {
|
||||||
|
pub peers: Vec<String>,
|
||||||
|
pub tun_name: Option<String>,
|
||||||
|
pub no_tun: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SidecarConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// A small set of well-known public peers from the mycelium README,
|
||||||
|
// used as bootstrap when the user hasn't configured their own.
|
||||||
|
peers: vec![
|
||||||
|
"tcp://188.40.132.242:9651".into(),
|
||||||
|
"quic://[2a01:4f8:212:fa6::2]:9651".into(),
|
||||||
|
],
|
||||||
|
tun_name: None,
|
||||||
|
no_tun: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the running mycelium child process plus a small in-memory log
|
||||||
|
/// buffer so the Settings page can show recent stderr/stdout without
|
||||||
|
/// reading from disk.
|
||||||
|
pub struct SidecarHandle {
|
||||||
|
child: Mutex<Option<Child>>,
|
||||||
|
api_url: Mutex<Option<String>>,
|
||||||
|
logs: Mutex<VecDeque<String>>,
|
||||||
|
config_path: Mutex<Option<PathBuf>>,
|
||||||
|
key_path: Mutex<Option<PathBuf>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SidecarHandle {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
child: Mutex::new(None),
|
||||||
|
api_url: Mutex::new(None),
|
||||||
|
logs: Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY)),
|
||||||
|
config_path: Mutex::new(None),
|
||||||
|
key_path: Mutex::new(None),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_running(&self) -> bool {
|
||||||
|
self.api_url.lock().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client(&self) -> Option<MyceliumClient> {
|
||||||
|
self.api_url
|
||||||
|
.lock()
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| MyceliumClient::new(u.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn logs_snapshot(&self) -> Vec<String> {
|
||||||
|
self.logs.lock().iter().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key_path(&self) -> Option<PathBuf> {
|
||||||
|
self.key_path.lock().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_path(&self) -> Option<PathBuf> {
|
||||||
|
self.config_path.lock().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_log(&self, line: String) {
|
||||||
|
let mut buf = self.logs.lock();
|
||||||
|
if buf.len() >= LOG_RING_CAPACITY {
|
||||||
|
buf.pop_front();
|
||||||
|
}
|
||||||
|
buf.push_back(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
app: &AppHandle,
|
||||||
|
config: &SidecarConfig,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
if self.is_running() {
|
||||||
|
return Err(AppError::DaemonAlreadyRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bin = locate_sidecar(app)?;
|
||||||
|
let port = portpicker::pick_unused_port()
|
||||||
|
.ok_or_else(|| AppError::Other("no free port available".into()))?;
|
||||||
|
|
||||||
|
let data_dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| AppError::TauriPath(e.to_string()))?;
|
||||||
|
std::fs::create_dir_all(&data_dir)?;
|
||||||
|
let key_path = data_dir.join("priv_key.bin");
|
||||||
|
let config_path = data_dir.join("mycelium.toml");
|
||||||
|
|
||||||
|
let mut args = vec![
|
||||||
|
"--api-addr".to_string(),
|
||||||
|
format!("127.0.0.1:{port}"),
|
||||||
|
"--key-file".to_string(),
|
||||||
|
key_path.display().to_string(),
|
||||||
|
];
|
||||||
|
if config_path.exists() {
|
||||||
|
args.push("--config-file".to_string());
|
||||||
|
args.push(config_path.display().to_string());
|
||||||
|
}
|
||||||
|
if config.no_tun {
|
||||||
|
args.push("--no-tun".to_string());
|
||||||
|
}
|
||||||
|
if let Some(name) = &config.tun_name {
|
||||||
|
args.push("--tun-name".to_string());
|
||||||
|
args.push(name.clone());
|
||||||
|
}
|
||||||
|
if !config.peers.is_empty() {
|
||||||
|
args.push("--peers".to_string());
|
||||||
|
for p in &config.peers {
|
||||||
|
args.push(p.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(?bin, port, "spawning mycelium sidecar via pkexec");
|
||||||
|
|
||||||
|
let mut cmd = elevation::elevated(&bin, &args);
|
||||||
|
cmd.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
|
||||||
|
let mut child = cmd.spawn()?;
|
||||||
|
let stdout = child.stdout.take();
|
||||||
|
let stderr = child.stderr.take();
|
||||||
|
|
||||||
|
// Stash before we await the health check, so a slow daemon
|
||||||
|
// doesn't leave us with a zombie process if anything panics.
|
||||||
|
let api_url = format!("http://127.0.0.1:{port}");
|
||||||
|
*self.child.lock() = Some(child);
|
||||||
|
*self.api_url.lock() = Some(api_url.clone());
|
||||||
|
*self.config_path.lock() = Some(config_path);
|
||||||
|
*self.key_path.lock() = Some(key_path);
|
||||||
|
|
||||||
|
if let Some(out) = stdout {
|
||||||
|
let me = Arc::clone(self);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(out).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
me.push_log(format!("[stdout] {line}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(err) = stderr {
|
||||||
|
let me = Arc::clone(self);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(err).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
me.push_log(format!("[stderr] {line}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background watcher: polls every 2s and emits `sidecar://exited`
|
||||||
|
// when the child dies after the start sequence has succeeded.
|
||||||
|
{
|
||||||
|
let me = Arc::clone(self);
|
||||||
|
let app = app.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
if !me.is_running() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(code) = me.child_exit_status() {
|
||||||
|
me.handle_exit(&app, code);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health-check loop. The pkexec dialog can take several seconds, so
|
||||||
|
// give the daemon a generous window to come up before failing.
|
||||||
|
let client = MyceliumClient::new(&api_url);
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
// Bail early if the child died (auth cancel, missing TUN cap, etc.).
|
||||||
|
if let Some(code) = self.child_exit_status() {
|
||||||
|
self.cleanup();
|
||||||
|
if elevation::is_auth_failure(code) {
|
||||||
|
return Err(AppError::ElevationCancelled);
|
||||||
|
}
|
||||||
|
return Err(AppError::SidecarExited(format!("exit code {code}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.is_alive().await {
|
||||||
|
info!(api_url = %api_url, "mycelium sidecar healthy");
|
||||||
|
let _ = app.emit("sidecar://ready", &api_url);
|
||||||
|
return Ok(api_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if started.elapsed() > Duration::from_secs(HEALTH_CHECK_TIMEOUT_SECS) {
|
||||||
|
self.stop().await;
|
||||||
|
return Err(AppError::HealthCheckTimeout(HEALTH_CHECK_TIMEOUT_SECS));
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(HEALTH_CHECK_INTERVAL_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn child_exit_status(&self) -> Option<i32> {
|
||||||
|
let mut lock = self.child.lock();
|
||||||
|
let child = lock.as_mut()?;
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(s)) => Some(s.code().unwrap_or(-1)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_exit(&self, app: &AppHandle, code: i32) {
|
||||||
|
warn!(code, "mycelium sidecar exited");
|
||||||
|
self.cleanup();
|
||||||
|
let _ = app.emit("sidecar://exited", code);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup(&self) {
|
||||||
|
*self.api_url.lock() = None;
|
||||||
|
let _ = self.child.lock().take();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&self) {
|
||||||
|
// Take the child by value so the parking_lot guard isn't held across
|
||||||
|
// the await on `wait()`. `kill_on_drop(true)` is set, but pkexec runs
|
||||||
|
// mycelium as root, so we still ask politely first; the polkit agent
|
||||||
|
// reaps the elevated child.
|
||||||
|
let child_opt = self.child.lock().take();
|
||||||
|
if let Some(mut child) = child_opt {
|
||||||
|
let _ = child.start_kill();
|
||||||
|
let _ = child.wait().await;
|
||||||
|
}
|
||||||
|
self.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the bundled `mycelium-<triple>` binary in both `tauri dev`
|
||||||
|
/// (cargo manifest) and bundled (resource_dir) modes.
|
||||||
|
fn locate_sidecar(app: &AppHandle) -> AppResult<PathBuf> {
|
||||||
|
let triple = std::env::var("TAURI_ENV_TARGET_TRIPLE")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| option_env!("TARGET").map(|s| s.to_string()))
|
||||||
|
.unwrap_or_else(|| "x86_64-unknown-linux-gnu".to_string());
|
||||||
|
let name = format!("mycelium-{triple}");
|
||||||
|
let mut tried: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
|
if let Ok(resource) = app.path().resource_dir() {
|
||||||
|
let p = resource.join(&name);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
tried.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let dev_path = manifest_dir.join("binaries").join(&name);
|
||||||
|
if dev_path.exists() {
|
||||||
|
return Ok(dev_path);
|
||||||
|
}
|
||||||
|
tried.push(dev_path);
|
||||||
|
|
||||||
|
Err(AppError::SidecarNotFound(tried))
|
||||||
|
}
|
||||||
20
src-tauri/src/state.rs
Normal file
20
src-tauri/src/state.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use crate::sidecar::SidecarHandle;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub sidecar: Arc<SidecarHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sidecar: SidecarHandle::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/App.vue
86
src/App.vue
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed, onBeforeUnmount, onMounted } from "vue";
|
||||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
@@ -8,9 +8,16 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Hash,
|
Hash,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
|
import StartupOverlay from "@/components/StartupOverlay.vue";
|
||||||
|
import { useNodeStore } from "@/stores/node";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const node = useNodeStore();
|
||||||
|
const { phase, info, error } = storeToRefs(node);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/status", label: "Status", icon: Activity },
|
{ to: "/status", label: "Status", icon: Activity },
|
||||||
@@ -22,15 +29,48 @@ const navItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium");
|
const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium");
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
node.bootstrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
node.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleStart() {
|
||||||
|
try {
|
||||||
|
await node.start();
|
||||||
|
} catch {
|
||||||
|
// error already in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop() {
|
||||||
|
await node.stop();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen w-screen overflow-hidden bg-background text-foreground">
|
<div
|
||||||
<aside
|
class="relative flex h-screen w-screen overflow-hidden bg-background text-foreground"
|
||||||
class="flex w-56 shrink-0 flex-col border-r border-border bg-card"
|
|
||||||
>
|
>
|
||||||
|
<aside class="flex w-56 shrink-0 flex-col border-r border-border bg-card">
|
||||||
<div class="flex h-14 items-center px-4 border-b border-border">
|
<div class="flex h-14 items-center px-4 border-b border-border">
|
||||||
<span class="font-semibold text-base">Mycellium</span>
|
<span class="font-semibold text-base">Mycellium</span>
|
||||||
|
<span
|
||||||
|
class="ml-auto inline-block h-2 w-2 rounded-full"
|
||||||
|
:class="
|
||||||
|
phase === 'ready'
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: phase === 'starting'
|
||||||
|
? 'bg-yellow-500 animate-pulse'
|
||||||
|
: phase === 'error'
|
||||||
|
? 'bg-destructive'
|
||||||
|
: 'bg-muted-foreground'
|
||||||
|
"
|
||||||
|
:title="phase"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-1">
|
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-1">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
@@ -45,17 +85,49 @@ const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium"
|
|||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div
|
||||||
|
v-if="info"
|
||||||
|
class="border-t border-border px-3 py-3 text-xs space-y-1"
|
||||||
|
>
|
||||||
|
<div class="text-muted-foreground">Overlay subnet</div>
|
||||||
|
<div class="font-mono break-all">{{ info.nodeSubnet }}</div>
|
||||||
|
<button
|
||||||
|
class="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-md border border-border px-2 py-1 text-xs hover:bg-secondary"
|
||||||
|
@click="handleStop"
|
||||||
|
>
|
||||||
|
<PowerOff class="h-3 w-3" />
|
||||||
|
Stop daemon
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="phase === 'idle' || phase === 'error'"
|
||||||
|
class="border-t border-border px-3 py-3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-primary px-2 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90"
|
||||||
|
@click="handleStart"
|
||||||
|
>
|
||||||
|
<Power class="h-3 w-3" />
|
||||||
|
Start daemon
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex flex-1 flex-col overflow-hidden">
|
<main class="flex flex-1 flex-col overflow-hidden">
|
||||||
<header
|
<header class="flex h-14 items-center border-b border-border px-6 shrink-0">
|
||||||
class="flex h-14 items-center border-b border-border px-6 shrink-0"
|
|
||||||
>
|
|
||||||
<h1 class="text-lg font-semibold">{{ currentTitle }}</h1>
|
<h1 class="text-lg font-semibold">{{ currentTitle }}</h1>
|
||||||
</header>
|
</header>
|
||||||
<div class="flex-1 overflow-y-auto p-6">
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<StartupOverlay
|
||||||
|
v-if="phase !== 'ready' && phase !== 'idle'"
|
||||||
|
:phase="phase as 'starting' | 'error'"
|
||||||
|
:error="error"
|
||||||
|
@start="handleStart"
|
||||||
|
@retry="handleStart"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
32
src/components/InfoCard.vue
Normal file
32
src/components/InfoCard.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Copy, Check } from "lucide-vue-next";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
copied?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{ (e: "copy"): void }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-start justify-between gap-4 rounded-lg border border-border bg-card p-4"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 break-all font-mono text-sm">{{ value }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="shrink-0 rounded-md border border-border p-2 hover:bg-secondary"
|
||||||
|
:title="copied ? 'Copied' : 'Copy'"
|
||||||
|
@click="$emit('copy')"
|
||||||
|
>
|
||||||
|
<Check v-if="copied" class="h-4 w-4 text-emerald-500" />
|
||||||
|
<Copy v-else class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
74
src/components/StartupOverlay.vue
Normal file
74
src/components/StartupOverlay.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Loader2, ShieldAlert, Power } from "lucide-vue-next";
|
||||||
|
import type { Phase } from "@/stores/node";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
phase: Exclude<Phase, "ready">;
|
||||||
|
error?: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: "start"): void;
|
||||||
|
(e: "retry"): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-md rounded-lg border border-border bg-card p-8 shadow-lg">
|
||||||
|
<template v-if="phase === 'starting'">
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<Loader2 class="h-10 w-10 animate-spin text-primary" />
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Starting Mycelium daemon…</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Authenticate in the polkit dialog to allow the daemon to create
|
||||||
|
the TUN interface.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="phase === 'idle'">
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<Power class="h-10 w-10 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Daemon is offline</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Click below to start the Mycelium daemon. Root privileges are
|
||||||
|
required to create the TUN interface.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||||
|
@click="$emit('start')"
|
||||||
|
>
|
||||||
|
Start daemon
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="phase === 'error'">
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<ShieldAlert class="h-10 w-10 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Daemon failed to start</h2>
|
||||||
|
<p
|
||||||
|
class="mt-2 max-h-32 overflow-auto rounded bg-muted p-2 text-left font-mono text-xs"
|
||||||
|
>
|
||||||
|
{{ error || "unknown error" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||||
|
@click="$emit('retry')"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
35
src/lib/api.ts
Normal file
35
src/lib/api.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
// ─── Types (mirror src-tauri Rust structs) ───────────────────────────────────
|
||||||
|
|
||||||
|
export interface DaemonStatus {
|
||||||
|
running: boolean;
|
||||||
|
apiUrl: string | null;
|
||||||
|
keyPath: string | null;
|
||||||
|
configPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeInfo {
|
||||||
|
nodeSubnet: string;
|
||||||
|
nodePubkey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidecarConfig {
|
||||||
|
peers: string[];
|
||||||
|
tunName: string | null;
|
||||||
|
noTun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Type-safe invoke wrappers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const cmd = <T>(name: string, args?: Record<string, unknown>) =>
|
||||||
|
invoke<T>(name, args);
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
daemonStatus: () => cmd<DaemonStatus>("daemon_status"),
|
||||||
|
startDaemon: (config?: SidecarConfig) =>
|
||||||
|
cmd<DaemonStatus>("start_daemon", { config: config ?? null }),
|
||||||
|
stopDaemon: () => cmd<DaemonStatus>("stop_daemon"),
|
||||||
|
nodeInfo: () => cmd<NodeInfo>("node_info"),
|
||||||
|
sidecarLogs: () => cmd<string[]>("sidecar_logs"),
|
||||||
|
};
|
||||||
23
src/lib/events.ts
Normal file
23
src/lib/events.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { listen, type UnlistenFn, type EventCallback } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend event names emitted from src-tauri/src/sidecar.rs.
|
||||||
|
* Keep this list in sync with `app.emit(...)` calls there.
|
||||||
|
*/
|
||||||
|
export const Events = {
|
||||||
|
SidecarReady: "sidecar://ready",
|
||||||
|
SidecarExited: "sidecar://exited",
|
||||||
|
PeersUpdated: "peers://updated",
|
||||||
|
StatsUpdated: "stats://updated",
|
||||||
|
RoutesUpdated: "routes://updated",
|
||||||
|
MessageIncoming: "messages://incoming",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EventName = (typeof Events)[keyof typeof Events];
|
||||||
|
|
||||||
|
export async function on<T>(
|
||||||
|
event: EventName,
|
||||||
|
handler: EventCallback<T>,
|
||||||
|
): Promise<UnlistenFn> {
|
||||||
|
return listen<T>(event, handler);
|
||||||
|
}
|
||||||
100
src/stores/node.ts
Normal file
100
src/stores/node.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { api, type DaemonStatus, type NodeInfo } from "@/lib/api";
|
||||||
|
import { Events, on } from "@/lib/events";
|
||||||
|
|
||||||
|
export type Phase = "idle" | "starting" | "ready" | "error";
|
||||||
|
|
||||||
|
export const useNodeStore = defineStore("node", () => {
|
||||||
|
const phase = ref<Phase>("idle");
|
||||||
|
const status = ref<DaemonStatus | null>(null);
|
||||||
|
const info = ref<NodeInfo | null>(null);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
let exitedUnlisten: (() => void) | null = null;
|
||||||
|
let readyUnlisten: (() => void) | null = null;
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
if (!exitedUnlisten) {
|
||||||
|
exitedUnlisten = await on<number>(Events.SidecarExited, (e) => {
|
||||||
|
error.value = `daemon exited (code ${e.payload})`;
|
||||||
|
phase.value = "error";
|
||||||
|
status.value = { running: false, apiUrl: null, keyPath: null, configPath: null };
|
||||||
|
info.value = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!readyUnlisten) {
|
||||||
|
readyUnlisten = await on<string>(Events.SidecarReady, async () => {
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cur = await api.daemonStatus();
|
||||||
|
status.value = cur;
|
||||||
|
if (cur.running) {
|
||||||
|
phase.value = "ready";
|
||||||
|
try {
|
||||||
|
info.value = await api.nodeInfo();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
phase.value = "starting";
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const s = await api.startDaemon();
|
||||||
|
status.value = s;
|
||||||
|
info.value = await api.nodeInfo();
|
||||||
|
phase.value = "ready";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
phase.value = "error";
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
try {
|
||||||
|
const s = await api.stopDaemon();
|
||||||
|
status.value = s;
|
||||||
|
info.value = null;
|
||||||
|
phase.value = "idle";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
status.value = await api.daemonStatus();
|
||||||
|
if (status.value.running) {
|
||||||
|
info.value = await api.nodeInfo();
|
||||||
|
phase.value = "ready";
|
||||||
|
} else {
|
||||||
|
info.value = null;
|
||||||
|
phase.value = "idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
exitedUnlisten?.();
|
||||||
|
readyUnlisten?.();
|
||||||
|
exitedUnlisten = null;
|
||||||
|
readyUnlisten = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
status,
|
||||||
|
info,
|
||||||
|
error,
|
||||||
|
bootstrap,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
refresh,
|
||||||
|
dispose,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,9 +1,57 @@
|
|||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useNodeStore } from "@/stores/node";
|
||||||
|
import InfoCard from "@/components/InfoCard.vue";
|
||||||
|
|
||||||
|
const node = useNodeStore();
|
||||||
|
const { info, status } = storeToRefs(node);
|
||||||
|
|
||||||
|
const copiedField = ref<string | null>(null);
|
||||||
|
async function copy(field: string, value: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
copiedField.value = field;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (copiedField.value === field) copiedField.value = null;
|
||||||
|
}, 1200);
|
||||||
|
} catch {
|
||||||
|
/* clipboard unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div v-if="info" class="max-w-3xl space-y-4">
|
||||||
<p class="text-sm text-muted-foreground">
|
<InfoCard
|
||||||
Daemon status will appear here once the sidecar is wired up (P1).
|
label="Overlay subnet"
|
||||||
</p>
|
:value="info.nodeSubnet"
|
||||||
|
:copied="copiedField === 'subnet'"
|
||||||
|
@copy="copy('subnet', info.nodeSubnet)"
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
label="Public key"
|
||||||
|
:value="info.nodePubkey"
|
||||||
|
:copied="copiedField === 'pk'"
|
||||||
|
@copy="copy('pk', info.nodePubkey)"
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
v-if="status?.apiUrl"
|
||||||
|
label="API endpoint"
|
||||||
|
:value="status.apiUrl"
|
||||||
|
:copied="copiedField === 'api'"
|
||||||
|
@copy="copy('api', status.apiUrl!)"
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
v-if="status?.keyPath"
|
||||||
|
label="Identity file"
|
||||||
|
:value="status.keyPath"
|
||||||
|
:copied="copiedField === 'kp'"
|
||||||
|
@copy="copy('kp', status.keyPath!)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="text-sm text-muted-foreground">
|
||||||
|
The daemon is not running. Start it from the sidebar.
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user