From 6a11036d9641443ba32829cf6e5fe2c45552e90c Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 21 Aug 2026 22:42:57 +0200 Subject: [PATCH] feat(admin): show active session/users on dashboard --- frontend/src/lib/api/endpoints/admin.ts | 21 +++ .../src/routes/admin/[[tab]]/+page.svelte | 130 +++++++++++++++++- frontend/static/locales/en.json | 11 ++ frontend/static/locales/fr.json | 11 ++ src/application/dtos/settings_dto.rs | 31 ++++- .../services/auth_application_service.rs | 20 ++- src/interfaces/api/handlers/admin_handler.rs | 48 +++++++ 7 files changed, 263 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index 2cf9ee48..c00b8538 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -387,9 +387,30 @@ export interface DriveKindUsage { } export interface AdminDashboard { + // ── User accounts (static breakdown of auth.users) ── + // All four are counts of the same table under different + // predicates. Rendered as one grouped section on the dashboard. total_users: number; active_users: number; admin_users: number; + /** Grant-only accounts (magic-link / OIDC-only / OCM recipients). + * Filtered out of `total_users` / `active_users` — those count + * operational seats. Surfaced here as its own metric because + * external-heavy deployments (public-share collab, invited-only + * shops) need the invited population at a glance. */ + external_users: number; + // ── Live activity (projection over auth.sessions) ── + // Both change minute-to-minute — a whole different cadence from + // the account counts above. Rendered as a separate section on + // the dashboard with the presence-dot visual cue. + /** Distinct users behind non-revoked sessions active in the last + * 5 min. Same 5-min window as the `oxicloud_sessions_online_users` + * Prometheus gauge; single source of truth on the backend. */ + online_users: number; + /** Non-revoked sessions active in the last 5 min. Ratio + * `online_sessions / online_users` is the multi-device factor + * (browser + desktop + phone). */ + online_sessions: number; server_version: string; drive_usage: DriveKindUsage[]; auth_enabled: boolean; diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 715bfd2c..9d66fbde 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -1730,6 +1730,16 @@ {:else if !dashboard}

{t('common.loading', 'Loading…')}

{:else} + + + +

{t('admin.section_accounts', 'User accounts')}

{dashboard.total_users}{t('admin.total_users', 'Total users')} @@ -1740,11 +1750,66 @@
{dashboard.admin_users}{t('admin.admin_users', 'Admins')}
-
- v{dashboard.server_version}{t('admin.version', 'Version')} +
+ {dashboard.external_users}{t( + 'admin.external_users', + 'External' + )}
+ +

+ {t('admin.section_activity', 'Live activity')} + + {t('admin.live', 'live')} + +

+
+
+ + + {dashboard.online_users} + + {t('admin.online_users', 'Online users')} +
+
+ + + {dashboard.online_sessions} + + {t('admin.online_sessions', 'Online sessions')} +
+
+ + +

{t('admin.section_system', 'System')}

@@ -1768,6 +1833,9 @@ {t('admin.quotas', 'Quotas')}
+
+ v{dashboard.server_version}{t('admin.version', 'Version')} +
{#if dashboard.users_over_quota > 0} @@ -4340,6 +4408,40 @@ margin-bottom: var(--space-4); } + /* Section title bar above each dashboard grid — labels the + nature of the cards below (accounts vs live activity vs + system). Small, muted, so it structures the page without + competing with the numbers. `text-transform: uppercase` + + `letter-spacing` matches the small-caps section-header pattern + used elsewhere in the admin surface. */ + .ds-section-title { + display: flex; + align-items: baseline; + gap: var(--space-2); + margin: var(--space-4) 0 var(--space-2) 0; + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + } + + /* "live" pill next to the "Live activity" section header — + subtle visual hint that the values in this grid change on + their own cadence. Matches the presence-dot's success token + so the whole live-activity block reads as one visual family. */ + .ds-section-live { + display: inline-block; + padding: 0 var(--space-2); + border-radius: var(--radius-full); + background: var(--color-success-bg); + color: var(--color-success-text); + font-size: 0.65rem; + font-weight: var(--weight-bold); + letter-spacing: 0.08em; + vertical-align: middle; + } + .ds-card { display: flex; flex-direction: column; @@ -4358,6 +4460,18 @@ color: var(--color-text-heading); } + /* Live-count variant — same font size as `.ds-num`, plus a + flex container so the leading presence dot aligns with the + number baseline instead of the top of the digit. Reuses the + `.presence-dot--online` class from the sessions-panel work + so the visual signal for presence is identical across the + admin surface. */ + .ds-num.ds-num--live { + display: inline-flex; + align-items: center; + gap: var(--space-2); + } + .ds-bar { height: 8px; background: var(--color-bg-muted); @@ -5230,9 +5344,17 @@ } .admin { - max-width: 64rem; + /* Raised from 64rem to 80rem so the data-dense tables (sessions + row with 9+ cells, users table with vignette + role + auth + chips + quota bar) have more horizontal room. At viewports + above 80rem `margin: 0 auto` still centers with the leftover + whitespace — DevTools shows that whitespace as horizontal + margin (not padding) and is what "content looks squeezed" + really means on wide displays. Vertical rhythm and the small + horizontal padding are unchanged. */ + max-width: 80rem; margin: 0 auto; - padding: 1.5rem 1rem; + padding: 1.5rem var(--space-2); display: flex; flex-direction: column; gap: 1rem; diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 88557131..4058be85 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -836,6 +836,17 @@ "total_users": "Total Users", "active_users": "Active Users", "admins": "Admins", + "external_users": "External", + "external_users_tooltip": "Grant-only accounts — magic-link, OIDC-only, OCM recipients", + "online_users": "Online users", + "online_users_tooltip": "Distinct users with a session active in the last 5 minutes", + "online_sessions": "Online sessions", + "online_sessions_tooltip": "Non-revoked sessions active in the last 5 minutes — multi-device users contribute more than one", + "section_accounts": "User accounts", + "section_activity": "Live activity", + "section_system": "System", + "live": "live", + "live_tooltip": "Reflects sessions active in the last 5 minutes", "version": "Version", "storage_overview": "Storage Overview", "used": "Used", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index 998c1e4a..d3b6713f 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -801,6 +801,17 @@ "total_users": "Utilisateurs totaux", "active_users": "Utilisateurs actifs", "admins": "Admins", + "external_users": "Externes", + "external_users_tooltip": "Comptes invités — magic-link, OIDC seulement, destinataires OCM", + "online_users": "Utilisateurs en ligne", + "online_users_tooltip": "Utilisateurs distincts ayant une session active dans les 5 dernières minutes", + "online_sessions": "Sessions en ligne", + "online_sessions_tooltip": "Sessions non révoquées actives dans les 5 dernières minutes — les utilisateurs multi-appareils en contribuent plusieurs", + "section_accounts": "Comptes utilisateurs", + "section_activity": "Activité en direct", + "section_system": "Système", + "live": "en direct", + "live_tooltip": "Reflète les sessions actives dans les 5 dernières minutes", "version": "Version", "storage_overview": "Aperçu du stockage", "used": "Utilisé", diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index 0d2cc9da..835f4719 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -163,10 +163,39 @@ pub struct DashboardStatsDto { pub auth_enabled: bool, pub oidc_configured: bool, pub quotas_enabled: bool, - // User stats + // ── User accounts (static breakdown of auth.users) ── + // All four are counts of the SAME table under different + // predicates. `active`, `admin`, `external` are all subsets of + // `total`. `external` is disjoint from `admin` by DB constraint + // (`users_external_not_admin`). The dashboard renders these as + // one grouped section separate from the live-activity section + // below, so admins don't confuse "as-of-now row count" with + // "who's here right now". pub total_users: i64, pub active_users: i64, pub admin_users: i64, + /// Grant-only accounts (magic-link / OIDC-only / OCM recipients). + /// Filtered out of `total_users` / `active_users` since those + /// columns count operational seats (see the SELECT comment). Here + /// as its own metric because operators of external-heavy + /// deployments (public shares, invited-collab shops) need to see + /// the invited population at a glance. + pub external_users: i64, + // ── Live activity (projection over auth.sessions) ── + // Both fields change minute-to-minute, unlike the user counts + // above which only move on register/deactivate/role-toggle. + // Same 5-min window as the Prometheus gauges + // (`oxicloud_sessions_online[_users]` in + // `session_liveness_gauges.rs`), computed via the shared + // `ONLINE_WINDOW` constant so per-user badges + aggregate + // counts + this dashboard number stay consistent by construction. + /// Distinct users behind non-revoked sessions active in the last + /// 5 min. Answers "how many humans are here right now?". + pub online_users: i64, + /// Non-revoked sessions active in the last 5 min. Answers "how + /// many concurrent connections must I serve?". Ratio + /// `online_sessions / online_users` is the multi-device factor. + pub online_sessions: i64, // ── 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 diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 5b2cd930..36c8090f 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -876,7 +876,10 @@ impl AuthApplicationService { is_external = false, "🛂 user registered", ); - Ok(RegisterResult::Created(Box::new(PublicUserDto::new(created_user, false)))) + Ok(RegisterResult::Created(Box::new(PublicUserDto::new( + created_user, + false, + )))) } /// Create the first admin user during initial system setup. @@ -3227,7 +3230,10 @@ impl AuthApplicationService { offset: i64, ) -> Result, DomainError> { let users = self.user_storage.list_users(limit, offset, false).await?; - Ok(users.into_iter().map(|u| PublicUserDto::new(u, false)).collect()) + Ok(users + .into_iter() + .map(|u| PublicUserDto::new(u, false)) + .collect()) } /// Admin-only: lists users including external (grant-only) recipients. @@ -3241,7 +3247,10 @@ impl AuthApplicationService { ) -> Result, DomainError> { self.require_admin_caller(authorization, caller_id).await?; let users = self.user_storage.list_users(limit, offset, true).await?; - Ok(users.into_iter().map(|u| PublicUserDto::new(u, false)).collect()) + Ok(users + .into_iter() + .map(|u| PublicUserDto::new(u, false)) + .collect()) } /// Admin-only user listing. Returns `Vec` — same @@ -3296,7 +3305,10 @@ impl AuthApplicationService { limit: i64, ) -> Result, DomainError> { let users = self.user_storage.search_users(query, limit, false).await?; - Ok(users.into_iter().map(|u| PublicUserDto::new(u, false)).collect()) + Ok(users + .into_iter() + .map(|u| PublicUserDto::new(u, false)) + .collect()) } /// Username-only search for the NC sharee autocomplete: identical diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index b727b5a1..4856f0c6 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -940,6 +940,51 @@ pub async fn get_dashboard_stats( .await .map_err(|e| AppError::internal_error(format!("Database query failed: {}", e)))?; + // External account count — distinct query (not FILTERed into + // `stats_row` above) because `stats_row` scopes to + // `is_external = false` for the operational-seat counts. + // Externals form their own population; the dashboard renders them + // as a separate stat card in the "User accounts" section. + let external_users: i64 = + sqlx::query_scalar(r#"SELECT COUNT(*)::INT8 FROM auth.users WHERE is_external = true"#) + .fetch_one(db_pool.as_ref()) + .await + .map_err(|e| AppError::internal_error(format!("External user count failed: {}", e)))?; + + // Live-activity counts — projection over auth.sessions, same + // `ONLINE_WINDOW` (5 min) the Prometheus gauges use so the + // dashboard number, admin-table green dot, and + // `oxicloud_sessions_online` scrape all agree by construction. + // Bound as `$1 = window_secs` via `make_interval(secs => $1)` + // to keep the single-source-of-truth pattern (no SQL literal + // for the window). Both queries hit the partial index + // `idx_sessions_last_seen_at WHERE revoked = FALSE` so per-run + // cost is ~μs even at tens of thousands of session rows. + let online_window_secs: f64 = + crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64(); + let online_sessions: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*)::INT8 FROM auth.sessions + WHERE revoked = FALSE + AND last_seen_at > NOW() - make_interval(secs => $1) + "#, + ) + .bind(online_window_secs) + .fetch_one(db_pool.as_ref()) + .await + .map_err(|e| AppError::internal_error(format!("Online session count failed: {}", e)))?; + let online_users: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(DISTINCT user_id)::INT8 FROM auth.sessions + WHERE revoked = FALSE + AND last_seen_at > NOW() - make_interval(secs => $1) + "#, + ) + .bind(online_window_secs) + .fetch_one(db_pool.as_ref()) + .await + .map_err(|e| AppError::internal_error(format!("Online user count failed: {}", e)))?; + use sqlx::Row; // Per-drive-kind quota panel: @@ -1024,6 +1069,9 @@ 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"), + external_users, + online_users, + online_sessions, drive_usage, users_over_80_percent: stats_row.get("users_over_80"), users_over_quota: stats_row.get("users_over_quota"),