use std::sync::Arc;
use std::time::Instant;
use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use crate::storage::{HealthStatus, RowCounts, StorageBackend, StorageHealth};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminStatusBody {
pub mode: String,
pub version: String,
pub uptime_secs: u64,
pub storage: StorageHealthBlock,
}
#[derive(Clone)]
pub struct AdminStatusState {
pub mode: &'static str,
pub version: &'static str,
pub started_at: Instant,
pub sqlite_path: Option<String>,
pub database_url: Option<String>,
pub storage: Arc<dyn StorageBackend>,
}
impl AdminStatusState {
pub fn new(
mode: &'static str,
storage: Arc<dyn StorageBackend>,
sqlite_path: Option<String>,
database_url: Option<String>,
) -> Self {
Self {
mode,
version: env!("CARGO_PKG_VERSION"),
started_at: Instant::now(),
sqlite_path,
database_url,
storage,
}
}
}
pub async fn admin_status(Extension(state): Extension<AdminStatusState>) -> Json<AdminStatusBody> {
let storage_block = match state.storage.healthcheck().await {
Ok(health) => {
StorageHealthBlock::from_health(&health, state.sqlite_path.as_deref(), state.database_url.as_deref())
}
Err(err) => {
tracing::warn!(
error = %err,
"storage healthcheck failed; reporting unavailable in admin status"
);
StorageHealthBlock::from_health(
&StorageHealth {
status: HealthStatus::Unavailable,
backend: if state.sqlite_path.is_some() {
"sqlite"
} else if state.database_url.is_some() {
"postgres"
} else {
"unknown"
},
latency_ms: 0,
row_counts: RowCounts::default(),
timescale: None,
},
state.sqlite_path.as_deref(),
state.database_url.as_deref(),
)
}
};
Json(AdminStatusBody {
mode: state.mode.to_string(),
version: state.version.to_string(),
uptime_secs: state.started_at.elapsed().as_secs(),
storage: storage_block,
})
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StorageHealthBlock {
pub backend: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub database_url: Option<String>,
pub health: String,
pub latency_ms: u32,
pub row_counts: RowCountsBlock,
#[serde(skip_serializing_if = "Option::is_none")]
pub timescaledb: Option<TimescaleDbBlock>,
}
impl StorageHealthBlock {
pub fn from_health(health: &StorageHealth, sqlite_path: Option<&str>, database_url: Option<&str>) -> Self {
let backend = health.backend.to_string();
let path = (backend == "sqlite").then(|| sqlite_path.map(str::to_string)).flatten();
let database_url = (backend == "postgres")
.then(|| database_url.map(redact_database_url))
.flatten();
let timescaledb = health.timescale.as_ref().map(|stats| TimescaleDbBlock {
enabled: true,
total_chunks: stats.total_chunks,
compressed_chunks: stats.compressed_chunks,
compression_ratio: stats.compression_ratio_tenths as f32 / 10.0,
});
Self {
backend,
path,
database_url,
health: health_status_label(health.status).to_string(),
latency_ms: health.latency_ms,
row_counts: RowCountsBlock {
audit_events_hot: health.row_counts.audit_events,
agents: health.row_counts.agents,
policy_versions: health.row_counts.policy_versions,
},
timescaledb,
}
}
}
const fn health_status_label(status: HealthStatus) -> &'static str {
match status {
HealthStatus::Ok => "ok",
HealthStatus::Degraded => "degraded",
HealthStatus::Unavailable => "unavailable",
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RowCountsBlock {
pub audit_events_hot: u64,
pub agents: u64,
pub policy_versions: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TimescaleDbBlock {
pub enabled: bool,
pub total_chunks: u32,
pub compressed_chunks: u32,
pub compression_ratio: f32,
}
pub fn redact_database_url(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return url.to_string();
};
let authority_start = scheme_end + 3;
let authority_end = url[authority_start..]
.find(['/', '?', '#'])
.map(|i| authority_start + i)
.unwrap_or(url.len());
let authority = &url[authority_start..authority_end];
let Some(at_idx) = authority.rfind('@') else {
return url.to_string();
};
let userinfo = &authority[..at_idx];
let Some(colon_idx) = userinfo.find(':') else {
return url.to_string();
};
let user = &userinfo[..colon_idx];
let host_and_rest = &url[authority_start + at_idx..];
format!("{}://{}:***{}", &url[..scheme_end], user, host_and_rest)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::{HealthStatus, RowCounts, StorageHealth, TimescaleStats};
fn sample_health(backend: &'static str, timescale: Option<TimescaleStats>) -> StorageHealth {
StorageHealth {
status: HealthStatus::Ok,
backend,
latency_ms: 3,
row_counts: RowCounts {
audit_events: 47,
agents: 2,
policy_versions: 1,
},
timescale,
}
}
#[test]
fn redact_replaces_postgres_password_with_stars() {
let redacted = redact_database_url("postgresql://aasm:secret@db.internal:5432/aasm");
assert_eq!(redacted, "postgresql://aasm:***@db.internal:5432/aasm");
}
#[test]
fn redact_leaves_url_without_userinfo_unchanged() {
let input = "postgresql://db.internal:5432/aasm";
assert_eq!(redact_database_url(input), input);
}
#[test]
fn redact_leaves_user_only_userinfo_unchanged() {
let input = "postgresql://aasm@db.internal:5432/aasm";
assert_eq!(redact_database_url(input), input);
}
#[test]
fn redact_leaves_sqlite_url_unchanged() {
let input = "sqlite:///home/dev/.aasm/local.db";
assert_eq!(redact_database_url(input), input);
}
#[test]
fn redact_leaves_non_url_inputs_unchanged() {
for input in ["~/.aasm/local.db", "not-a-url", "://no-scheme", ""] {
assert_eq!(redact_database_url(input), input, "input: {input:?}");
}
}
#[test]
fn redact_handles_at_inside_password_via_rightmost_at_split() {
let redacted = redact_database_url("postgresql://aasm:p@ss@db.internal:5432/aasm");
assert_eq!(redacted, "postgresql://aasm:***@db.internal:5432/aasm");
}
#[test]
fn row_counts_block_serialises_with_documented_keys() {
let counts = RowCountsBlock {
audit_events_hot: 14_293,
agents: 8,
policy_versions: 3,
};
let json = serde_json::to_value(&counts).expect("RowCountsBlock must serialise");
assert_eq!(json["audit_events_hot"], 14_293);
assert_eq!(json["agents"], 8);
assert_eq!(json["policy_versions"], 3);
}
#[test]
fn timescaledb_block_serialises_with_documented_keys() {
let block = TimescaleDbBlock {
enabled: true,
total_chunks: 12,
compressed_chunks: 8,
compression_ratio: 11.4,
};
let json = serde_json::to_value(&block).expect("TimescaleDbBlock must serialise");
assert_eq!(json["enabled"], true);
assert_eq!(json["total_chunks"], 12);
assert_eq!(json["compressed_chunks"], 8);
let ratio = json["compression_ratio"].as_f64().expect("compression_ratio is number");
assert!((ratio - 11.4).abs() < 0.05, "ratio drift > 0.05: got {ratio}");
}
#[test]
fn from_health_propagates_sqlite_path_and_drops_database_url() {
let block = StorageHealthBlock::from_health(
&sample_health("sqlite", None),
Some("~/.aasm/local.db"),
Some("postgresql://u:p@h/db"),
);
assert_eq!(block.backend, "sqlite");
assert_eq!(block.path.as_deref(), Some("~/.aasm/local.db"));
assert!(block.database_url.is_none(), "database_url must be omitted on sqlite");
assert!(block.timescaledb.is_none(), "no TimescaleStats → no timescaledb block");
assert_eq!(block.health, "ok");
assert_eq!(block.latency_ms, 3);
assert_eq!(block.row_counts.audit_events_hot, 47);
}
#[test]
fn from_health_redacts_postgres_database_url_and_drops_sqlite_path() {
let block = StorageHealthBlock::from_health(
&sample_health("postgres", None),
Some("~/.aasm/local.db"),
Some("postgresql://aasm:secret@db.internal:5432/aasm"),
);
assert_eq!(block.backend, "postgres");
assert!(block.path.is_none(), "path must be omitted on postgres");
assert_eq!(
block.database_url.as_deref(),
Some("postgresql://aasm:***@db.internal:5432/aasm")
);
}
#[test]
fn from_health_populates_timescaledb_block_when_stats_present() {
let stats = TimescaleStats {
total_chunks: 12,
compressed_chunks: 8,
compression_ratio_tenths: 114,
oldest_chunk_age_days: 45,
};
let block = StorageHealthBlock::from_health(
&sample_health("postgres", Some(stats)),
None,
Some("postgresql://aasm:secret@db.internal:5432/aasm"),
);
let ts = block.timescaledb.expect("TimescaleStats → Some(TimescaleDbBlock)");
assert!(ts.enabled);
assert_eq!(ts.total_chunks, 12);
assert_eq!(ts.compressed_chunks, 8);
assert!((ts.compression_ratio - 11.4).abs() < 0.05);
}
#[test]
fn storage_block_omits_optional_fields_when_none() {
let block = StorageHealthBlock::from_health(&sample_health("sqlite", None), None, None);
let json = serde_json::to_value(&block).expect("serialise");
assert!(json.get("path").is_none(), "path None must be skipped");
assert!(json.get("database_url").is_none(), "database_url None must be skipped");
assert!(json.get("timescaledb").is_none(), "timescaledb None must be skipped");
}
#[test]
fn from_health_renders_unavailable_status_label() {
let mut health = sample_health("postgres", None);
health.status = HealthStatus::Unavailable;
let block = StorageHealthBlock::from_health(&health, None, Some("postgresql://u:p@h/db"));
assert_eq!(block.health, "unavailable");
}
async fn sqlite_state() -> (tempfile::TempDir, AdminStatusState) {
use crate::storage::{SqliteBackend, SqliteConfig};
let tmp = tempfile::tempdir().expect("create tempdir");
let path = tmp.path().join("local.db");
let backend = SqliteBackend::open(&SqliteConfig { path: path.clone() })
.await
.expect("open sqlite backend");
backend.migrate().await.expect("migrate");
let state = AdminStatusState::new(
"local",
Arc::new(backend),
Some(path.to_string_lossy().into_owned()),
None,
);
(tmp, state)
}
#[tokio::test]
async fn handler_returns_documented_body_against_sqlite_backend() {
let (_tmp, state) = sqlite_state().await;
let Json(body) = admin_status(Extension(state.clone())).await;
assert_eq!(body.mode, "local");
assert_eq!(body.version, env!("CARGO_PKG_VERSION"));
assert_eq!(body.storage.backend, "sqlite");
assert_eq!(body.storage.health, "ok");
assert!(body.storage.path.is_some(), "sqlite path must be reported");
assert!(body.storage.database_url.is_none(), "no postgres URL on sqlite");
assert!(body.storage.timescaledb.is_none(), "no timescaledb on sqlite");
}
#[tokio::test]
async fn handler_uptime_reflects_started_at() {
let (_tmp, mut state) = sqlite_state().await;
state.started_at = Instant::now() - std::time::Duration::from_secs(5);
let Json(body) = admin_status(Extension(state)).await;
assert!(body.uptime_secs >= 5, "expected uptime ≥ 5s, got {}", body.uptime_secs);
}
#[test]
fn admin_status_body_serialises_with_documented_top_level_keys() {
let body = AdminStatusBody {
mode: "remote".into(),
version: "0.0.1".into(),
uptime_secs: 86_400,
storage: StorageHealthBlock::from_health(
&sample_health("postgres", None),
None,
Some("postgresql://aasm:secret@db.internal:5432/aasm"),
),
};
let json = serde_json::to_value(&body).expect("AdminStatusBody must serialise");
for key in ["mode", "version", "uptime_secs", "storage"] {
assert!(json.get(key).is_some(), "missing top-level key {key:?}");
}
assert_eq!(json["mode"], "remote");
assert_eq!(json["uptime_secs"], 86_400);
assert_eq!(json["storage"]["backend"], "postgres");
assert_eq!(
json["storage"]["database_url"],
"postgresql://aasm:***@db.internal:5432/aasm"
);
}
}