feat(admin dashboard): clarify quota usage vs backend usage

This commit is contained in:
Edouard Vanbelle
2026-08-02 12:08:08 +02:00
parent dd1528de92
commit 4297d8139a
7 changed files with 421 additions and 51 deletions
+43 -4
View File
@@ -118,6 +118,30 @@ pub struct ListUsersQueryDto {
pub summary: Option<bool>,
}
/// One row of the dashboard's quota panel — usage aggregate for a
/// single drive kind. Unlimited caps are excluded from `capped_quota_bytes`
/// and counted in `unlimited_count` so the panel can render the ratio
/// honestly ("X / Y over N capped drives · M unlimited").
#[derive(Debug, Serialize, Deserialize)]
pub struct DriveKindUsageDto {
/// `"personal"` or `"shared"`.
pub kind: String,
/// Total bytes stored across drives of this kind. Excludes trashed
/// files (see `bug_trash_excluded_from_quota` for the known gap).
pub used_bytes: i64,
/// Sum of caps over capped drives only. `None` when there are no
/// capped drives of this kind (would otherwise report `0 / 0`
/// meaninglessly).
pub capped_quota_bytes: Option<i64>,
/// Count of drives (personal: users) with no cap. Personal-kind
/// unlimited = `auth.users.storage_quota_bytes = 0`; shared-kind
/// unlimited = `storage.drives.quota_bytes IS NULL`.
pub unlimited_count: i64,
/// Count of drives with a numeric cap. Used to hide rows with
/// zero drives and denominate the ratio.
pub capped_count: i64,
}
/// Dashboard statistics
#[derive(Debug, Serialize, Deserialize)]
pub struct DashboardStatsDto {
@@ -130,12 +154,27 @@ pub struct DashboardStatsDto {
pub total_users: i64,
pub active_users: i64,
pub admin_users: i64,
// Storage stats
pub total_quota_bytes: i64,
pub total_used_bytes: i64,
pub storage_usage_percent: f64,
// ── Per-drive-kind quota accounting ──
// One row per drive kind (personal, shared). Pre-dedup, logical
// file sizes summed from `drives.used_bytes` (personal rolls up
// via the user envelope). Cap sums exclude unlimited entries;
// `unlimited_count` tracks them separately so the ratio stays
// honest.
pub drive_usage: Vec<DriveKindUsageDto>,
pub users_over_80_percent: i64,
pub users_over_quota: i64,
// ── Backend physical accounting ──
// Bytes actually stored on the active backend (`storage.blobs`
// aggregate) plus the dedup ratio (referenced / stored).
// `total_bytes_stored` is typically << `total_used_bytes` on a
// healthy deployment — dedup + shared blobs mean many user file
// rows resolve to one physical blob. `None` when the dedup
// stats service is unavailable or errored (dashboard renders as
// "—" in that case).
#[serde(skip_serializing_if = "Option::is_none")]
pub total_bytes_stored: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dedup_ratio: Option<f64>,
pub registration_enabled: bool,
}
@@ -1,8 +1,11 @@
//! First tenant of Part 2 (recoverable-run engine).
//!
//! Iterates `storage.drives` and reports each drive whose cached
//! `used_bytes` differs from `SUM(files.size) WHERE NOT is_trashed`
//! for that drive. **Read-only** — reports drift as findings but does
//! `used_bytes` differs from `SUM(files.size)` for that drive.
//! Includes trashed files — matches the hot-path delta (upload
//! writes never decrement on `move_to_trash`) and the sweep at
//! `storage_usage_service.rs::update_all_drives_storage_usage`.
//! **Read-only** — reports drift as findings but does
//! NOT fix it. The existing `storage_reconcile` job (Part 1) is what
//! corrects the counter; this check surfaces WHEN drift happens so
//! operators can trace it back to root cause (missed delta call,
@@ -167,7 +170,6 @@ impl RecoverableJobHandler for DrivesConsistencyCheck {
SELECT SUM(size)::bigint
FROM storage.files
WHERE drive_id = d.id
AND NOT is_trashed
), 0) AS actual_bytes
FROM storage.drives d
LEFT JOIN storage.folders rf ON rf.id = d.root_folder_id
@@ -75,6 +75,15 @@ pub const STORAGE_MIGRATION_JOB_NAME: &str = "storage_migration";
/// projections read the same constant.
pub const TARGET_NAME_PARAM: &str = "target_name";
/// Companion to [`TARGET_NAME_PARAM`] — records the source entry
/// name (the active backend at Fresh-open time) so a run row read
/// months later self-describes the migration direction. Without
/// this, an operator inspecting a Completed row from an old
/// deployment could see "migrated to `s3_prod`" but had to
/// cross-reference `admin_settings` history to know what it came
/// from. Stamped once on Fresh open; Resume reads it back.
pub const SOURCE_NAME_PARAM: &str = "source_name";
/// Rows per batch. Copies are I/O-bound (source read + target write);
/// larger batches amortise fewer SQL round-trips but the checkpoint
/// / cancel-poll cadence lengthens. 100 balances the two — one
@@ -259,11 +268,56 @@ impl RecoverableJobHandler for StorageMigrationService {
// reference reads from this local. A hot-swap that fires
// mid-run (e.g., a second migration starting after this one
// completes) doesn't reshape our decisions from underneath.
let active_backend_name = self
.active_backend_name
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
//
// For Fresh runs we ALSO stamp this into `params.source_name`
// so an audit-log reader can self-describe the migration
// direction without cross-referencing `admin_settings`
// history. On Resume we read it back — the ORIGINAL source
// (from when the run was opened) is what's audit-worthy,
// not whatever the active backend happens to be at resume
// time.
let active_backend_name = if is_fresh {
let snap = self
.active_backend_name
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
if let Err(e) = store.set_string_param(SOURCE_NAME_PARAM, &snap).await {
return RunOutcome::Failed {
message: format!("failed to persist source_name to params: {e}"),
};
}
snap
} else {
match store.get_string_param(SOURCE_NAME_PARAM).await {
Ok(Some(name)) => name,
Ok(None) => {
// Paused row predates K3.8's source-stamping.
// Fall back to current active name and log a
// note so the audit trail is at least
// approximately correct.
let fallback = self
.active_backend_name
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
tracing::warn!(
target: "oxicloud::migration",
event = "storage_migration.legacy_paused_row_source_defaulted",
run_id = %store.run_id(),
fallback_source = %fallback,
"resumed run has no source_name in params (pre-K3.8 row) — defaulting \
to current active backend for the audit line"
);
fallback
}
Err(e) => {
return RunOutcome::Failed {
message: format!("read {SOURCE_NAME_PARAM} from params: {e}"),
};
}
}
};
// First-line guard: target name equals the currently-active
// entry. Silent no-op if we let it through — the app would
+80 -15
View File
@@ -16,10 +16,10 @@ use crate::application::dtos::plugin_dto::{
SetEnabledDto,
};
use crate::application::dtos::settings_dto::{
AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, ListUsersQueryDto,
MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto,
SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto,
UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto,
AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, DriveKindUsageDto,
ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto,
SendSmtpTestDto, SmtpInfoDto, SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto,
TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto,
};
use crate::application::dtos::user_dto::{AdminUserSummaryDto, UserDto};
use crate::application::ports::authorization_ports::AuthorizationEngine;
@@ -897,8 +897,6 @@ pub async fn get_dashboard_stats(
COUNT(*)::INT8 as total_users,
COUNT(*) FILTER (WHERE active = true)::INT8 as active_users,
COUNT(*) FILTER (WHERE role::text = 'admin')::INT8 as admin_users,
COALESCE(SUM(storage_quota_bytes)::INT8, 0) as total_quota_bytes,
COALESCE(SUM(storage_used_bytes)::INT8, 0) as total_used_bytes,
COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes * 0.8)::INT8 as users_over_80,
COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes)::INT8 as users_over_quota
FROM auth.users
@@ -910,13 +908,80 @@ pub async fn get_dashboard_stats(
.map_err(|e| AppError::internal_error(format!("Database query failed: {}", e)))?;
use sqlx::Row;
let total_quota: i64 = stats_row.get("total_quota_bytes");
let total_used: i64 = stats_row.get("total_used_bytes");
let usage_percent = if total_quota > 0 {
(total_used as f64 / total_quota as f64) * 100.0
} else {
0.0
// Per-drive-kind quota panel:
//
// - **Personal** rolls up via the user envelope
// (`auth.users.storage_quota_bytes`; `= 0` means unlimited),
// because personal drives inherit their cap from the user per
// `docs/plan/drive.md`. The "N unlimited" here counts USERS
// with unlimited envelope, not drives.
// - **Shared** uses `storage.drives.quota_bytes` directly
// (`IS NULL` means unlimited).
//
// Both rows sum `used_bytes` — for personal that's
// `auth.users.storage_used_bytes`, which is itself
// `SUM(drives.used_bytes) WHERE kind='personal'` per the sweep
// in `storage_usage_service.rs`. For shared it's the drive's own
// `used_bytes`. Trashed files are excluded from both — see
// `bug_trash_excluded_from_quota` for the known gap.
let personal_row = sqlx::query(
r#"
SELECT
COALESCE(SUM(storage_used_bytes)::INT8, 0) AS used_bytes,
COALESCE(SUM(storage_quota_bytes) FILTER (WHERE storage_quota_bytes > 0)::INT8, 0) AS capped_quota_bytes,
COUNT(*) FILTER (WHERE storage_quota_bytes = 0)::INT8 AS unlimited_count,
COUNT(*) FILTER (WHERE storage_quota_bytes > 0)::INT8 AS capped_count
FROM auth.users
WHERE is_external = false
"#,
)
.fetch_one(db_pool.as_ref())
.await
.map_err(|e| AppError::internal_error(format!("Personal-drive stats failed: {}", e)))?;
let shared_row = sqlx::query(
r#"
SELECT
COALESCE(SUM(used_bytes)::INT8, 0) AS used_bytes,
COALESCE(SUM(quota_bytes) FILTER (WHERE quota_bytes IS NOT NULL)::INT8, 0) AS capped_quota_bytes,
COUNT(*) FILTER (WHERE quota_bytes IS NULL)::INT8 AS unlimited_count,
COUNT(*) FILTER (WHERE quota_bytes IS NOT NULL)::INT8 AS capped_count
FROM storage.drives
WHERE kind::text = 'shared'
"#,
)
.fetch_one(db_pool.as_ref())
.await
.map_err(|e| AppError::internal_error(format!("Shared-drive stats failed: {}", e)))?;
let build_row = |kind: &str, row: sqlx::postgres::PgRow| DriveKindUsageDto {
kind: kind.to_string(),
used_bytes: row.get("used_bytes"),
// Only surface the cap when there's at least one capped drive
// — else the FE would render "0 / 0 (NaN%)" for a kind that's
// entirely unlimited.
capped_quota_bytes: {
let capped_count: i64 = row.get("capped_count");
if capped_count > 0 {
Some(row.get("capped_quota_bytes"))
} else {
None
}
},
unlimited_count: row.get("unlimited_count"),
capped_count: row.get("capped_count"),
};
let drive_usage = vec![
build_row("personal", personal_row),
build_row("shared", shared_row),
];
// Backend physical stats (post-dedup, post-encryption) —
// rendered in the dashboard's "Backend Storage" card next to
// the user-quota panel. Same source `StorageSettingsDto` uses;
// cheap aggregate over `storage.blobs`.
let dedup_stats = state.core.dedup_service.get_stats().await;
let stats = DashboardStatsDto {
server_version: env!("CARGO_PKG_VERSION").to_string(),
@@ -926,11 +991,11 @@ pub async fn get_dashboard_stats(
total_users: stats_row.get("total_users"),
active_users: stats_row.get("active_users"),
admin_users: stats_row.get("admin_users"),
total_quota_bytes: total_quota,
total_used_bytes: total_used,
storage_usage_percent: (usage_percent * 100.0).round() / 100.0,
drive_usage,
users_over_80_percent: stats_row.get("users_over_80"),
users_over_quota: stats_row.get("users_over_quota"),
total_bytes_stored: Some(dedup_stats.total_bytes_stored as i64),
dedup_ratio: Some(dedup_stats.dedup_ratio),
registration_enabled: {
if let Some(svc) = state.admin_settings_service.as_ref() {
svc.get_registration_enabled().await