- Add `content: Option<String>` to SourceRecord with rkyv schema evolution (LegacySourceRecord compat deserializer for backward compatibility) - Add MAX_SOURCE_CONTENT_LEN (1MB) limit with API validation - Strip content from list responses, include in single-source GET - Update Go SDK RegisterSourceRequest with Content field - FCM pipeline extracts PDF text via pdftotext and passes to registration - Dashboard impact panel fetches and displays source content with expand/collapse - Add feed endpoint, dashboard feed panel, and signed assertion support - Update data-structures.md, API docs, and storage docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
157 lines
4.9 KiB
Rust
157 lines
4.9 KiB
Rust
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<HealthResponse> {
|
|
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<ClusterStatusResponse> {
|
|
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<RangeInfoDto> {
|
|
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<Vec<RangeInfoDto>> {
|
|
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(())
|
|
}
|
|
}
|