use anyhow::{Context, Result}; use tracing::{debug, instrument}; use crate::types::{ ClusterStatusResponse, HealthResponse, RangeInfoDto, RangesWrapper, ShardInfoResponse, }; /// HTTP client for StemeDB Gateway API pub struct AdminClient { base_url: String, client: reqwest::Client, } impl AdminClient { /// Create a new admin client pointing to the gateway pub fn new(base_url: String) -> Self { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .expect("Failed to build HTTP client"); Self { base_url, client } } /// Check gateway health #[instrument(skip(self))] pub async fn health(&self) -> Result { let url = format!("{}/v1/health", self.base_url); debug!("Fetching health from: {}", url); let response = self .client .get(&url) .send() .await .context(format!("Failed to connect to gateway at {}", self.base_url))?; if !response.status().is_success() { anyhow::bail!( "Gateway returned error status: {} - {}", response.status(), response.text().await.unwrap_or_default() ); } response.json().await.context("Failed to parse health response") } /// Get cluster status overview #[instrument(skip(self))] pub async fn cluster_status(&self) -> Result { let url = format!("{}/v1/cluster/status", self.base_url); debug!("Fetching cluster status from: {}", url); let response = self .client .get(&url) .send() .await .context(format!("Failed to connect to gateway at {}", self.base_url))?; if !response.status().is_success() { anyhow::bail!( "Gateway returned error status: {} - {}", response.status(), response.text().await.unwrap_or_default() ); } response.json().await.context("Failed to parse cluster status response") } /// Get detailed information about a specific shard #[instrument(skip(self))] pub async fn shard_info(&self, shard_id: u32) -> Result { let url = format!("{}/v1/shards/{}", self.base_url, shard_id); debug!("Fetching shard info from: {}", url); let response = self .client .get(&url) .send() .await .context(format!("Failed to connect to gateway at {}", self.base_url))?; if !response.status().is_success() { if response.status() == reqwest::StatusCode::NOT_FOUND { anyhow::bail!("Shard not found: {}", shard_id); } anyhow::bail!( "Gateway returned error status: {} - {}", response.status(), response.text().await.unwrap_or_default() ); } // Gateway returns different format than /admin/ranges, so convert it let shard_response: ShardInfoResponse = response.json().await.context("Failed to parse shard info response")?; Ok(shard_response.into()) } /// Get information about all shards #[instrument(skip(self))] pub async fn all_ranges(&self) -> Result> { let url = format!("{}/v1/admin/ranges", self.base_url); debug!("Fetching all ranges from: {}", url); let response = self .client .get(&url) .send() .await .context(format!("Failed to connect to gateway at {}", self.base_url))?; if !response.status().is_success() { anyhow::bail!( "Gateway returned error status: {} - {}", response.status(), response.text().await.unwrap_or_default() ); } // Gateway returns {"ranges": [...]} so we need to unwrap it let wrapper: RangesWrapper = response.json().await.context("Failed to parse ranges response")?; Ok(wrapper.ranges) } /// Trigger anti-entropy sync (Phase 2 feature - not yet exposed in CLI) #[allow(dead_code)] #[instrument(skip(self))] pub async fn force_sync(&self) -> Result<()> { let url = format!("{}/v1/admin/sync", self.base_url); debug!("Triggering sync at: {}", url); let response = self .client .post(&url) .send() .await .context(format!("Failed to connect to gateway at {}", self.base_url))?; if !response.status().is_success() { anyhow::bail!( "Gateway returned error status: {} - {}", response.status(), response.text().await.unwrap_or_default() ); } Ok(()) } }