feat(storage): improve admin panel

This commit is contained in:
Edouard Vanbelle
2026-08-01 16:37:23 +02:00
parent cc439aaff9
commit 8329b4aa56
15 changed files with 1106 additions and 653 deletions
+42 -24
View File
@@ -143,31 +143,29 @@ pub struct DashboardStatsDto {
// Storage Settings DTOs (Admin Panel)
// ============================================================================
/// Current storage settings returned to admin UI (secrets masked).
/// Current storage settings returned to admin UI.
///
/// Post-multi-entry (`docs/plan/storage-multi-entry.md`) this carries
/// two shapes side-by-side: the legacy flat storage-config fields
/// (for the pre-multi-entry admin UI, until slice 6 completes the
/// form retirement), plus the new `entries` / `active_entry_name` /
/// `migration_readonly` view the multi-entry UI drives its entries-list,
/// migration-target-dropdown, and readonly-banner from.
/// Post-multi-entry (`docs/plan/storage-multi-entry.md`) this exposes:
/// - the entries declared in `.env` (safe: `location_hint` shows
/// provider+bucket, credential-related fields never appear),
/// - the name of the active entry (backend selection is admin-observable),
/// - the read-only flag (drives the UI banner during migration),
/// - the currently-live backend type + dedup stats (informational).
///
/// The pre-multi-entry `s3_*` / `backend` / `env_overrides` fields
/// used to also appear here — they duplicated `entries[]` and leaked
/// stale legacy admin_settings rows, so slice-6 dropped them. Consumers
/// wanting per-provider details read them off `entries[i].backend` and
/// `entries[i].location_hint` instead.
#[derive(Debug, Serialize, Deserialize)]
pub struct StorageSettingsDto {
/// Active backend type: "local" or "s3". Legacy flat-config field
/// — mirrors `entries[i where is_active].backend` for the active
/// entry when multi-entry is in use.
pub backend: String,
pub s3_endpoint_url: Option<String>,
pub s3_bucket: Option<String>,
pub s3_region: Option<String>,
/// True if an access key is configured (never reveals the actual value)
pub s3_access_key_set: bool,
/// True if a secret key is configured (never reveals the actual value)
pub s3_secret_key_set: bool,
pub s3_force_path_style: bool,
/// Field names overridden by environment variables (read-only in UI)
pub env_overrides: Vec<String>,
// ── Current stats ──
// ── Current stats — pertain to the running process ──
/// Backend type currently in use (`"local"` / `"s3"` / `"azure"`) —
/// what the LIVE `blob_backend` is bound to, from
/// `dedup_service.backend().backend_type()`. Redundant with
/// `entries[i where is_active].backend` in multi-entry mode; kept
/// because pre-boot / mid-migration inspection may still find it
/// useful.
pub current_backend: String,
pub total_blobs: u64,
pub total_bytes_stored: u64,
@@ -229,9 +227,25 @@ pub struct SaveStorageSettingsDto {
pub s3_force_path_style: Option<bool>,
}
/// Request body for testing a storage connection
#[derive(Debug, Serialize, Deserialize)]
/// Request body for testing a storage connection.
///
/// Two shapes are accepted:
///
/// - Multi-entry test — set `entry_name` to the name of a declared entry
/// (from `OXICLOUD_STORAGE_ENTRIES`). Server looks it up, builds a fresh
/// backend via the shared factory, runs health-check + round-trip against
/// it. `backend` and the S3 fields are ignored in this mode.
/// - Legacy DTO test — leave `entry_name` unset and populate `backend` +
/// the S3 fields. Server builds a temporary backend from those values
/// (pre-multi-entry behaviour). Still supported for zero-entries
/// deployments; deprecated for new integrations.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct TestStorageConnectionDto {
/// If set, all other fields are ignored — server resolves this
/// name against `OXICLOUD_STORAGE_ENTRIES` and tests that entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_name: Option<String>,
#[serde(default)]
pub backend: String,
pub s3_endpoint_url: Option<String>,
pub s3_bucket: Option<String>,
@@ -241,6 +255,10 @@ pub struct TestStorageConnectionDto {
pub s3_force_path_style: Option<bool>,
}
// Default is derived — every field is either an Option (defaults to
// None) or `backend: String` (empty via `#[serde(default)]`). The
// hand-rolled impl was flagged by clippy::derivable_impls.
/// Result of a storage connection + round-trip test.
///
/// `connected` is TRUE when the backend was reachable (health-check
@@ -63,26 +63,6 @@ impl StorageSettingsService {
}
}
/// Detect which storage fields are overridden by environment variables.
fn get_env_overrides(&self) -> Vec<String> {
let mut out = Vec::new();
let vars = [
("OXICLOUD_STORAGE_BACKEND", "backend"),
("OXICLOUD_S3_ENDPOINT_URL", "s3_endpoint_url"),
("OXICLOUD_S3_BUCKET", "s3_bucket"),
("OXICLOUD_S3_REGION", "s3_region"),
("OXICLOUD_S3_ACCESS_KEY", "s3_access_key"),
("OXICLOUD_S3_SECRET_KEY", "s3_secret_key"),
("OXICLOUD_S3_FORCE_PATH_STYLE", "s3_force_path_style"),
];
for (env_key, field_name) in &vars {
if std::env::var(env_key).is_ok() {
out.push(field_name.to_string());
}
}
out
}
/// Apply environment variable overrides on top of a config.
fn apply_env_overrides(&self, config: &mut StorageConfig) {
let e = &self.env_storage_config;
@@ -258,36 +238,16 @@ impl StorageSettingsService {
Ok(config)
}
/// Get storage settings for display in admin UI (secrets masked).
/// Get storage settings for display in admin UI.
///
/// Post-slice-6 the response is the multi-entry view + live stats
/// only. The legacy `backend` / `s3_*` / `env_overrides` fields
/// were retired — they duplicated `entries[]` and leaked stale
/// admin_settings rows saved by the retired admin-panel form.
pub async fn get_storage_settings(&self) -> Result<StorageSettingsDto, DomainError> {
let db: HashMap<String, String> = self.settings_repo.get_by_category("storage").await?;
let has_access_key = db
.get("storage.s3.access_key")
.map(|s| !s.is_empty())
.unwrap_or(false)
|| std::env::var("OXICLOUD_S3_ACCESS_KEY")
.map(|s| !s.is_empty())
.unwrap_or(false);
let has_secret_key = db
.get("storage.s3.secret_key")
.map(|s| !s.is_empty())
.unwrap_or(false)
|| std::env::var("OXICLOUD_S3_SECRET_KEY")
.map(|s| !s.is_empty())
.unwrap_or(false);
let effective = self.load_effective_storage_config().await?;
let stats = self.dedup_service.get_stats().await;
let current_backend = self.dedup_service.backend().backend_type().to_string();
let backend_str = match effective.backend {
crate::common::config::StorageBackendType::Local => "local",
crate::common::config::StorageBackendType::S3 => "s3",
crate::common::config::StorageBackendType::Azure => "azure",
};
// Project the multi-entry view. `is_active` is name-compared
// against the boot-selected `active_entry_name` (matches
// exactly one entry when we're in multi-entry mode; matches
@@ -310,14 +270,6 @@ impl StorageSettingsService {
.collect();
Ok(StorageSettingsDto {
backend: backend_str.to_string(),
s3_endpoint_url: effective.s3.as_ref().and_then(|s| s.endpoint_url.clone()),
s3_bucket: effective.s3.as_ref().map(|s| s.bucket.clone()),
s3_region: effective.s3.as_ref().map(|s| s.region.clone()),
s3_access_key_set: has_access_key,
s3_secret_key_set: has_secret_key,
s3_force_path_style: effective.s3.as_ref().is_some_and(|s| s.force_path_style),
env_overrides: self.get_env_overrides(),
current_backend,
total_blobs: stats.total_blobs,
total_bytes_stored: stats.total_bytes_stored,
@@ -399,6 +351,86 @@ impl StorageSettingsService {
&self,
dto: TestStorageConnectionDto,
) -> Result<StorageTestResultDto, DomainError> {
// Multi-entry mode: `entry_name` present → look up the entry,
// build via the shared factory, health-check + round-trip. The
// legacy DTO fields (backend / s3_*) are ignored. Unknown
// names return a `connected: false` result with a clear
// message, mirroring the shape of the legacy path (no
// exceptions for the client to catch — errors are inline).
if let Some(name) = dto.entry_name.as_deref() {
let entry = match self.storage_entries.iter().find(|e| e.name == name) {
Some(e) => e,
None => {
let available = if self.storage_entries.is_empty() {
"(none)".to_string()
} else {
self.storage_entries
.iter()
.map(|e| e.name.as_str())
.collect::<Vec<_>>()
.join(", ")
};
return Ok(StorageTestResultDto {
connected: false,
message: format!(
"entry `{name}` not declared in OXICLOUD_STORAGE_ENTRIES. \
Available: [{available}]"
),
backend_type: "unknown".to_string(),
available_bytes: None,
roundtrip_passed: None,
phase_reached: None,
bytes_written: None,
bytes_read: None,
roundtrip_elapsed_ms: None,
cleanup_ok: None,
});
}
};
let backend = crate::infrastructure::services::entry_backend::build_entry_backend(
entry,
std::path::Path::new(&self.env_storage_config.root_dir),
);
let backend_kind = backend.backend_type().to_string();
let status = match backend.health_check().await {
Ok(s) => s,
Err(e) => {
return Ok(StorageTestResultDto {
connected: false,
message: format!("health-check failed: {e}"),
backend_type: backend_kind,
available_bytes: None,
roundtrip_passed: None,
phase_reached: None,
bytes_written: None,
bytes_read: None,
roundtrip_elapsed_ms: None,
cleanup_ok: None,
});
}
};
let mut out = StorageTestResultDto {
connected: status.connected,
message: status.message,
backend_type: backend_kind,
available_bytes: status.available_bytes,
roundtrip_passed: None,
phase_reached: None,
bytes_written: None,
bytes_read: None,
roundtrip_elapsed_ms: None,
cleanup_ok: None,
};
if out.connected {
attach_roundtrip(&mut out, backend.as_ref()).await;
}
return Ok(out);
}
// Legacy path — DTO carries backend + s3 fields directly.
// Retained for pre-multi-entry deployments (zero declared
// entries) and for the admin form's on-form-values Test
// button. Deprecated for new integrations.
match dto.backend.as_str() {
"local" => {
// Local: no per-DTO override for the root_dir (the
@@ -727,7 +759,35 @@ async fn run_backend_roundtrip(
fn entry_location_hint(entry: &NamedStorageEntry) -> Option<String> {
match entry.backend {
StorageBackendType::Local => entry.root_dir.clone(),
StorageBackendType::S3 => entry.s3.as_ref().map(|s3| s3.bucket.clone()),
StorageBackendType::Azure => entry.azure.as_ref().map(|az| az.container.clone()),
// S3: show `<endpoint>/<bucket>` — bucket alone can collide
// across providers (a "my-bucket" on AWS vs the same name on
// MinIO / R2 look identical without the endpoint). `aws` is
// the visual stand-in when no custom endpoint is configured
// (i.e. talking to real AWS S3).
StorageBackendType::S3 => entry.s3.as_ref().map(|s3| {
// Trim trailing `/` so an env value of `https://host/`
// doesn't render as `https://host//bucket`. Both shapes
// are legitimate env inputs (some providers publish the
// trailing slash in their docs).
let endpoint = s3
.endpoint_url
.as_deref()
.unwrap_or("aws")
.trim_end_matches('/');
format!("{endpoint}/{}", s3.bucket)
}),
// Azure: show `<endpoint_or_account>/<container>`. The
// endpoint-URL override is uncommon on Azure (Azurite
// emulator, private stamps), so fall back to the account
// name when unset — matches what a reader would look for in
// the portal. Same trailing-slash trim as S3.
StorageBackendType::Azure => entry.azure.as_ref().map(|az| {
let host = az
.endpoint_url
.as_deref()
.unwrap_or(az.account_name.as_str())
.trim_end_matches('/');
format!("{host}/{}", az.container)
}),
}
}