feat(admin dashboard): clarify quota usage vs backend usage
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user