From 2bcb5245482a8735db34a2816c5d06e4c0023af0 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Wed, 1 Apr 2026 17:07:58 +0100 Subject: [PATCH] feat: add GET /api/v1/nodes/{id} detail endpoint Returns full NodeConfig (flattened) + runtime state (status, pid, uptime_secs) for a single node. This gives GUI clients access to data_dir, rewards_address, ports, binary_path, env_variables, and bootstrap_peers without reading the registry file directly. - Uses existing NodeInfo type (config flattened via serde) - 404 when node ID not found - Coexists with existing DELETE on the same route path - E2e test: verifies full config, flattened JSON shape, and 404 Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 7 +++ ant-core/src/node/daemon/server.rs | 38 +++++++++++- ant-core/tests/daemon_integration.rs | 92 +++++++++++++++++++++++++++- 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c6e09c3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(gh pr:*)" + ] + } +} diff --git a/ant-core/src/node/daemon/server.rs b/ant-core/src/node/daemon/server.rs index 63a32a4..d101d43 100644 --- a/ant-core/src/node/daemon/server.rs +++ b/ant-core/src/node/daemon/server.rs @@ -7,7 +7,7 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::sse::{Event, Sse}; use axum::response::{Html, IntoResponse}; -use axum::routing::{delete, get, post}; +use axum::routing::{get, post}; use axum::{Json, Router}; use tokio::sync::broadcast; use tokio::sync::RwLock; @@ -19,7 +19,7 @@ use crate::node::daemon::supervisor::Supervisor; use crate::node::events::NodeEvent; use crate::node::registry::NodeRegistry; use crate::node::types::{ - AddNodeOpts, AddNodeResult, DaemonConfig, DaemonStatus, NodeStarted, NodeStatus, + AddNodeOpts, AddNodeResult, DaemonConfig, DaemonStatus, NodeInfo, NodeStarted, NodeStatus, NodeStatusResult, NodeStatusSummary, NodeStopped, RemoveNodeResult, ResetResult, StartNodeResult, StopNodeResult, }; @@ -104,7 +104,10 @@ fn build_router(state: Arc) -> Router { .route("/api/v1/events", get(get_events)) .route("/api/v1/nodes/status", get(get_nodes_status)) .route("/api/v1/nodes", post(post_nodes)) - .route("/api/v1/nodes/{id}", delete(delete_node)) + .route( + "/api/v1/nodes/{id}", + get(get_node_detail).delete(delete_node), + ) .route("/api/v1/nodes/{id}/start", post(post_start_node)) .route("/api/v1/nodes/start-all", post(post_start_all)) .route("/api/v1/nodes/{id}/stop", post(post_stop_node)) @@ -194,6 +197,35 @@ async fn get_nodes_status(State(state): State>) -> Json>, + Path(id): Path, +) -> std::result::Result, (StatusCode, Json)> { + let registry = state.registry.read().await; + let config = match registry.get(id) { + Ok(config) => config.clone(), + Err(_) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Node not found: {id}") })), + )) + } + }; + + let supervisor = state.supervisor.read().await; + let status = supervisor.node_status(id).unwrap_or(NodeStatus::Stopped); + let pid = supervisor.node_pid(id); + let uptime_secs = supervisor.node_uptime_secs(id); + + Ok(Json(NodeInfo { + config, + status, + pid, + uptime_secs, + })) +} + /// POST /api/v1/nodes — Add one or more nodes to the registry. async fn post_nodes( State(state): State>, diff --git a/ant-core/tests/daemon_integration.rs b/ant-core/tests/daemon_integration.rs index 6340ed5..0fee3d8 100644 --- a/ant-core/tests/daemon_integration.rs +++ b/ant-core/tests/daemon_integration.rs @@ -1,8 +1,9 @@ use std::net::IpAddr; +use ant_core::node::binary::NoopProgress; use ant_core::node::daemon::server; use ant_core::node::registry::NodeRegistry; -use ant_core::node::types::{DaemonConfig, DaemonStatus}; +use ant_core::node::types::{AddNodeOpts, BinarySource, DaemonConfig, DaemonStatus, NodeInfo}; fn test_config(dir: &tempfile::TempDir) -> DaemonConfig { DaemonConfig { @@ -142,3 +143,92 @@ async fn console_returns_html() { shutdown.cancel(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; } + +/// Create a fake binary that responds to `--version`. +fn create_fake_binary(dir: &std::path::Path) -> std::path::PathBuf { + #[cfg(unix)] + { + let binary_path = dir.join("fake-antnode"); + std::fs::write(&binary_path, "#!/bin/sh\necho \"antnode 0.1.0-test\"\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&binary_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + binary_path + } + #[cfg(windows)] + { + let binary_path = dir.join("fake-antnode.cmd"); + std::fs::write(&binary_path, "@echo off\r\necho antnode 0.1.0-test\r\n").unwrap(); + binary_path + } +} + +#[tokio::test] +async fn get_node_detail_returns_full_config() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(&dir); + let reg_path = config.registry_path.clone(); + + // Add a node to the registry + let binary = create_fake_binary(dir.path()); + let opts = AddNodeOpts { + count: 1, + rewards_address: "0x1234567890abcdef1234567890abcdef12345678".to_string(), + data_dir_path: Some(dir.path().join("data")), + log_dir_path: Some(dir.path().join("logs")), + binary_source: BinarySource::LocalPath(binary), + ..Default::default() + }; + ant_core::node::add_nodes(opts, ®_path, &NoopProgress) + .await + .unwrap(); + + // Start the daemon + let registry = NodeRegistry::load(®_path).unwrap(); + let shutdown = tokio_util::sync::CancellationToken::new(); + let addr = server::start(config, registry, shutdown.clone()) + .await + .unwrap(); + + // GET /api/v1/nodes/1 — should return full config + runtime state + let url = format!("http://{addr}/api/v1/nodes/1"); + let resp = reqwest::get(&url).await.unwrap(); + assert!(resp.status().is_success()); + + let detail: NodeInfo = resp.json().await.unwrap(); + assert_eq!(detail.config.id, 1); + assert_eq!(detail.config.service_name, "node1"); + assert_eq!( + detail.config.rewards_address, + "0x1234567890abcdef1234567890abcdef12345678" + ); + assert!(detail.config.data_dir.exists()); + assert_eq!(detail.status, ant_core::node::types::NodeStatus::Stopped); + assert!(detail.pid.is_none()); + assert!(detail.uptime_secs.is_none()); + + // Verify JSON includes flattened config fields (serde flatten) + let raw: serde_json::Value = reqwest::get(&url).await.unwrap().json().await.unwrap(); + assert!(raw.get("id").is_some(), "should have flattened id"); + assert!( + raw.get("service_name").is_some(), + "should have flattened service_name" + ); + assert!( + raw.get("data_dir").is_some(), + "should have flattened data_dir" + ); + assert!( + raw.get("rewards_address").is_some(), + "should have flattened rewards_address" + ); + assert!(raw.get("status").is_some()); + + // GET /api/v1/nodes/999 — should 404 + let resp_404 = reqwest::get(format!("http://{addr}/api/v1/nodes/999")) + .await + .unwrap(); + assert_eq!(resp_404.status(), 404); + + shutdown.cancel(); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; +}