diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index f7a8f6e2..231e64f3 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -338,20 +338,32 @@ export function promoteUserToInternal(userId: string): Promise { // ── Dashboard ─────────────────────────────────────────────────────────── +export interface DriveKindUsage { + kind: 'personal' | 'shared'; + used_bytes: number; + // null when there are no capped drives of this kind — the FE + // hides the ratio and just renders "N unlimited" + capped_quota_bytes: number | null; + unlimited_count: number; + capped_count: number; +} + export interface AdminDashboard { total_users: number; active_users: number; admin_users: number; server_version: string; - total_used_bytes: number; - total_quota_bytes: number; - storage_usage_percent: number; + drive_usage: DriveKindUsage[]; auth_enabled: boolean; oidc_configured: boolean; quotas_enabled: boolean; registration_enabled?: boolean; users_over_80_percent: number; users_over_quota: number; + // Backend physical accounting — omitted when the dedup service + // is unavailable. Renders as "—" in that case. + total_bytes_stored?: number; + dedup_ratio?: number; } export function getDashboard(): Promise { diff --git a/frontend/src/lib/components/AdminJobsPanel.svelte b/frontend/src/lib/components/AdminJobsPanel.svelte index 8de34fe5..5ddb5896 100644 --- a/frontend/src/lib/components/AdminJobsPanel.svelte +++ b/frontend/src/lib/components/AdminJobsPanel.svelte @@ -484,7 +484,6 @@
-

{t('admin.jobs.title', 'Jobs')}

{t( 'admin.jobs.hint', @@ -1006,10 +1005,6 @@ flex-wrap: wrap; } - .jobs-panel__header-text h2 { - margin: 0 0 0.25rem; - } - .jobs-panel__hint { margin: 0; color: var(--color-text-muted); @@ -1251,9 +1246,24 @@ background: var(--color-bg-surface); padding: 0.5rem; border-radius: 4px; - overflow-x: auto; font-size: 0.8rem; margin: 0.5rem 0 0; + /* Wrap long values (cursor_hex is 128 hex chars) instead of + expanding the table cell — the run-drawer sits inside a + `` that would otherwise grow horizontally past + the viewport and blow out the page layout. `pre-wrap` + preserves the multi-line JSON.stringify(…, 2) indent; + `word-break: break-all` breaks the long hex strings mid-run + without hyphens. + + `overflow-x: auto` is kept as a defense-in-depth for any + future field that pre-wrap can't handle (e.g. a single + unbroken word longer than max-width). It only kicks in + when wrapping isn't enough. */ + white-space: pre-wrap; + word-break: break-all; + max-width: 100%; + overflow-x: auto; } .jobs-panel__findings h4 { diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index f7085c27..560120e1 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -1642,7 +1642,7 @@ communicates which admin area we're in — the plain "Admin" h1 was informationless once the tab bar moved out. --> -

{tabLabel}

+

{t('admin.title', 'Admin')} > {tabLabel}

{#if tab === 'dashboard'} {#if dashboardError} @@ -1713,20 +1713,105 @@
{/if} -
-

{t('admin.storage', 'Storage')}

-
-
70} - class:ds-fill--danger={dashboard.storage_usage_percent > 90} - style:width="{Math.min(dashboard.storage_usage_percent, 100)}%" - >
+
+
+

{t('admin.quota_usage', 'Quota usage')}

+

+ {t( + 'admin.quota_usage_hint', + 'Pre-dedup, logical file sizes. Includes trashed files until permanent deletion.' + )} +

+ + + {#each dashboard.drive_usage ?? [] as row (row.kind)} + {@const label = + row.kind === 'personal' + ? t('admin.quota_personal', 'Personal drives') + : t('admin.quota_shared', 'Shared drives')} + {@const pct = + row.capped_quota_bytes && row.capped_quota_bytes > 0 + ? (row.used_bytes / row.capped_quota_bytes) * 100 + : null} + {#if row.capped_count > 0 || row.unlimited_count > 0} + + + + + + + {/if} + {/each} + +
{label} + {#if row.capped_quota_bytes !== null && pct !== null} + {formatBytes(row.used_bytes)} / {formatBytes(row.capped_quota_bytes)} + ({pct.toFixed(1)}%) + {:else} + {formatBytes(row.used_bytes)} + {/if} + + {#if pct !== null} +
+
70} + class:ds-fill--danger={pct > 90} + style:width="{Math.min(pct, 100)}%" + >
+
+ {/if} +
+ {#if row.unlimited_count > 0} + + {t( + 'admin.quota_unlimited', + { n: row.unlimited_count }, + '{{n}} unlimited' + )} + + {/if} +
+
+ +
+

{t('admin.backend_storage', 'Backend storage')}

+ {#if dashboard.total_bytes_stored !== undefined} +
+
+
{t('admin.backend_stored', 'Stored')}
+
{formatBytes(dashboard.total_bytes_stored)}
+
+
+
+ {t('admin.backend_referenced', 'Referenced')} + +
+
+ {formatBytes( + Math.round((dashboard.total_bytes_stored ?? 0) * (dashboard.dedup_ratio ?? 1)) + )} +
+
+
+
{t('admin.backend_dedup_ratio', 'Dedup ratio')}
+
+ {dashboard.dedup_ratio !== undefined + ? `${dashboard.dedup_ratio.toFixed(2)}×` + : '—'} +
+
+
+ {:else} +

—

+ {/if}
-

- {formatBytes(dashboard.total_used_bytes)} / {formatBytes(dashboard.total_quota_bytes)} - ({dashboard.storage_usage_percent.toFixed(1)}%) -

{#if dashboard.registration_enabled !== undefined} @@ -1996,7 +2081,7 @@ history; deleted here in one sweep. ══════════════════════════════════════════════════════════ -->
-

{t('admin.storage_tab', 'Storage entries')}

+

{t('admin.storage_title', 'Storage entries')}

{t( 'admin.storage_move_hint', @@ -3878,6 +3963,109 @@ background: var(--color-error-text); } + .storage-cards { + display: grid; + grid-template-columns: 2fr 1fr; + gap: var(--space-3); + margin-bottom: var(--space-4); + } + + @media (width <= 40rem) { + .storage-cards { + grid-template-columns: 1fr; + } + } + + .storage-cards__hint { + margin-top: calc(-1 * var(--space-2)); + margin-bottom: var(--space-3); + font-size: var(--text-xs); + } + + .storage-cards__stats { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin: 0; + } + + .storage-cards__stats > div { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + } + + .storage-cards__stats dt { + display: inline-flex; + align-items: center; + gap: var(--space-1); + color: var(--color-text-muted); + font-size: var(--text-sm); + } + + .storage-cards__stats dd { + margin: 0; + font-weight: var(--weight-semibold); + color: var(--color-text-heading); + font-variant-numeric: tabular-nums; + } + + .storage-cards__stat-hint { + cursor: help; + } + + .quota-table { + width: 100%; + border-collapse: collapse; + } + + .quota-table th, + .quota-table td { + padding: var(--space-2) var(--space-2); + text-align: left; + vertical-align: middle; + font-size: var(--text-sm); + } + + .quota-table th { + font-weight: var(--weight-semibold); + color: var(--color-text-heading); + white-space: nowrap; + } + + .quota-table__num { + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .quota-table__pct { + color: var(--color-text-muted); + } + + .quota-table__bar { + width: 40%; + min-width: 6rem; + } + + .quota-table__bar .ds-bar { + margin-bottom: 0; + } + + .quota-table__meta { + text-align: right; + white-space: nowrap; + } + + .quota-table__unlimited { + display: inline-block; + padding: 2px var(--space-2); + border-radius: var(--radius-full); + background: var(--color-bg-muted); + color: var(--color-text-muted); + font-size: var(--text-xs); + } + .kv { display: grid; grid-template-columns: auto 1fr; diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index 25a41968..dd5769f3 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -118,6 +118,30 @@ pub struct ListUsersQueryDto { pub summary: Option, } +/// 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, + /// 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, 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub dedup_ratio: Option, pub registration_enabled: bool, } diff --git a/src/infrastructure/services/drives_consistency_service.rs b/src/infrastructure/services/drives_consistency_service.rs index 78e9e899..aad2d429 100644 --- a/src/infrastructure/services/drives_consistency_service.rs +++ b/src/infrastructure/services/drives_consistency_service.rs @@ -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 diff --git a/src/infrastructure/services/storage_migration_service.rs b/src/infrastructure/services/storage_migration_service.rs index 5b6202b5..478b955e 100644 --- a/src/infrastructure/services/storage_migration_service.rs +++ b/src/infrastructure/services/storage_migration_service.rs @@ -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 diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index bb1a0e2e..dd18e03a 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -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