diff --git a/Cargo.lock b/Cargo.lock index 885c4fa..77c9d48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 480b00c..9e53ed1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.2.13" +version = "0.2.14" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 0ca6d32..3d7b785 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -227,6 +227,55 @@ impl App for AdminApp { }, "admin_v2_reviews_bulk", ), + Route::with_handler_and_name( + "/v2/api/users", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + get(move |session: Session, db: Database, + query: UrlQuery| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("admin pool") + }) + .await; + v2::users(session, db, pg_pool, query.0).await + } + }) + }, + "admin_v2_users", + ), + Route::with_handler_and_name( + "/v2/api/users/{id}", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + get(move |session: Session, db: Database, path: Path| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("admin pool") + }) + .await; + v2::user_detail(session, db, pg_pool, path.0.id).await + } + }) + }, + "admin_v2_user_detail", + ), Route::with_handler_and_name( "/v2/api/reviews/{id}/approve", { diff --git a/src/admin/v2.rs b/src/admin/v2.rs index b663bf9..1ee84de 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -45,6 +45,13 @@ pub(super) struct LibraryQuery { pub(super) offset: Option, } +#[derive(Debug, Deserialize)] +pub(super) struct UsersQuery { + pub(super) search: Option, + pub(super) limit: Option, + pub(super) offset: Option, +} + #[derive(Debug, Deserialize)] pub(super) struct BulkReviewsRequest { action: String, @@ -158,6 +165,48 @@ struct AdminDashboardDto { library: LibraryOverviewDto, } +#[derive(Debug, Serialize, JsonSchema)] +struct AdminUsersPageDto { + items: Vec, + total: i64, + limit: i64, + offset: i64, + search: Option, + online_count: i64, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct AdminUserRowDto { + id: i64, + username: String, + display_name: Option, + email: Option, + role: String, + is_active: bool, + is_online: bool, + last_seen_ms: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct AdminUserDetailDto { + user: AdminUserRowDto, + stats: AdminUserStatsDto, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct AdminUserStatsDto { + plays: i64, + completed_plays: i64, + listened_seconds: i64, + liked_tracks: i64, + followed_artists: i64, + own_playlists: i64, + saved_playlists: i64, + uploaded_tracks: i64, + torrent_sessions: i64, + lastfm_connected: bool, +} + #[derive(Debug, Serialize, JsonSchema)] struct OverviewStatsDto { tracks: i64, @@ -645,6 +694,41 @@ pub async fn reviews( Json(page).into_response() } +pub async fn users( + session: Session, + db: Database, + pool: &PgPool, + query: UsersQuery, +) -> cot::Result { + if let Err(response) = require_admin_json(&session, &db).await { + return Ok(response); + } + + let page = load_admin_users_page(pool, query) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Json(page).into_response() +} + +pub async fn user_detail( + session: Session, + db: Database, + pool: &PgPool, + user_id: i64, +) -> cot::Result { + if let Err(response) = require_admin_json(&session, &db).await { + return Ok(response); + } + + let detail = load_admin_user_detail(pool, user_id) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + match detail { + Some(detail) => Json(detail).into_response(), + None => Ok(json_error(StatusCode::NOT_FOUND, "user not found")), + } +} + pub async fn bulk_reviews( session: Session, db: Database, @@ -1554,6 +1638,197 @@ async fn load_overview_stats(pool: &PgPool) -> anyhow::Result }) } +#[derive(Debug, sqlx::FromRow)] +struct AdminUserSqlRow { + id: i64, + username: String, + display_name: Option, + email: Option, + role: String, + is_active: bool, +} + +async fn load_admin_users_page( + pool: &PgPool, + query: UsersQuery, +) -> anyhow::Result { + let limit = query.limit.unwrap_or(40).clamp(10, 200); + let offset = query.offset.unwrap_or(0).max(0); + let search = query + .search + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let pattern = search.as_ref().map(|value| format!("%{value}%")); + + let mut count_qb = + QueryBuilder::::new("SELECT COUNT(*) FROM furumusic__user WHERE 1=1"); + if let Some(pattern) = pattern.as_ref() { + count_qb.push(" AND (username ILIKE "); + count_qb.push_bind(pattern); + count_qb.push(" OR COALESCE(display_name, '') ILIKE "); + count_qb.push_bind(pattern); + count_qb.push(" OR COALESCE(email, '') ILIKE "); + count_qb.push_bind(pattern); + count_qb.push(")"); + } + let total: i64 = count_qb.build_query_scalar().fetch_one(pool).await?; + + let mut qb = QueryBuilder::::new( + "SELECT id, username::text, display_name, email, role::text, is_active FROM furumusic__user WHERE 1=1", + ); + if let Some(pattern) = pattern.as_ref() { + qb.push(" AND (username ILIKE "); + qb.push_bind(pattern); + qb.push(" OR COALESCE(display_name, '') ILIKE "); + qb.push_bind(pattern); + qb.push(" OR COALESCE(email, '') ILIKE "); + qb.push_bind(pattern); + qb.push(")"); + } + qb.push(" ORDER BY username ASC LIMIT "); + qb.push_bind(limit); + qb.push(" OFFSET "); + qb.push_bind(offset); + let rows: Vec = qb.build_query_as().fetch_all(pool).await?; + + let active = crate::metrics::active_user_last_seen_ms(); + let online_cutoff_ms = 60_000; + let items = rows + .into_iter() + .map(|row| admin_user_row(row, &active, online_cutoff_ms)) + .collect::>(); + let online_count = active + .values() + .filter(|last_seen_ms| **last_seen_ms <= online_cutoff_ms) + .count() as i64; + + Ok(AdminUsersPageDto { + items, + total, + limit, + offset, + search, + online_count, + }) +} + +async fn load_admin_user_detail( + pool: &PgPool, + user_id: i64, +) -> anyhow::Result> { + let row = sqlx::query_as::<_, AdminUserSqlRow>( + "SELECT id, username::text, display_name, email, role::text, is_active FROM furumusic__user WHERE id = $1", + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + let Some(row) = row else { + return Ok(None); + }; + + let active = crate::metrics::active_user_last_seen_ms(); + let user = admin_user_row(row, &active, 60_000); + let ( + plays, + completed_plays, + listened_seconds, + liked_tracks, + followed_artists, + own_playlists, + saved_playlists, + uploaded_tracks, + torrent_sessions, + lastfm_connected, + ) = tokio::try_join!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1" + ) + .bind(user_id) + .fetch_one(pool), + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1 AND completed" + ) + .bind(user_id) + .fetch_one(pool), + sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(duration_listened), 0)::bigint FROM furumusic__play_history WHERE user_id = $1" + ) + .bind(user_id) + .fetch_one(pool), + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM furumusic__user_liked_track WHERE user_id = $1" + ) + .bind(user_id) + .fetch_one(pool), + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM furumusic__user_followed_artist WHERE user_id = $1" + ) + .bind(user_id) + .fetch_one(pool), + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM furumusic__playlist WHERE owner_id = $1" + ) + .bind(user_id) + .fetch_one(pool), + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM furumusic__saved_playlist WHERE user_id = $1" + ) + .bind(user_id) + .fetch_one(pool), + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(DISTINCT t.id) FROM furumusic__track t JOIN furumusic__media_file mf ON mf.id = t.media_file_id WHERE mf.uploaded_by_user_id = $1" + ) + .bind(user_id) + .fetch_one(pool), + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM furumusic__torrent_session WHERE user_id = $1" + ) + .bind(user_id) + .fetch_one(pool), + sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM furumusic__lastfm_account WHERE user_id = $1 AND session_key <> '')" + ) + .bind(user_id) + .fetch_one(pool), + )?; + + Ok(Some(AdminUserDetailDto { + user, + stats: AdminUserStatsDto { + plays, + completed_plays, + listened_seconds, + liked_tracks, + followed_artists, + own_playlists, + saved_playlists, + uploaded_tracks, + torrent_sessions, + lastfm_connected, + }, + })) +} + +fn admin_user_row( + row: AdminUserSqlRow, + active: &HashMap, + online_cutoff_ms: i64, +) -> AdminUserRowDto { + let last_seen_ms = active.get(&row.id).copied(); + AdminUserRowDto { + id: row.id, + username: row.username, + display_name: row.display_name, + email: row.email, + role: row.role, + is_active: row.is_active, + is_online: last_seen_ms.is_some_and(|value| value <= online_cutoff_ms), + last_seen_ms, + } +} + fn load_runtime_overview(config: &AppConfig) -> RuntimeOverviewDto { let llm_configured = !config.agent_llm_url.trim().is_empty(); let agent_status = if !config.agent_enabled { diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 8556674..c942a98 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -298,6 +298,7 @@ translations! { player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков..."; player_connection_lost: "Server connection lost" , "Нет соединения с сервером"; player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю..."; + player_active_device: "Active device" , "Активный девайс"; player_no_results: "No results found" , "Ничего не найдено"; player_new_playlist: "New Playlist" , "Новый плейлист"; player_rename_playlist: "Rename Playlist" , "Переименовать плейлист"; diff --git a/src/metrics.rs b/src/metrics.rs index 0e1b9c4..2f91773 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -159,6 +159,14 @@ pub fn record_active_user(user_id: i64) { users.insert(user_id, Instant::now()); } +pub fn active_user_last_seen_ms() -> HashMap { + let users = ACTIVE_USERS.lock().expect("active user lock"); + users + .iter() + .map(|(user_id, last_seen)| (*user_id, last_seen.elapsed().as_millis() as i64)) + .collect() +} + pub fn record_auth_attempt(method: &'static str, outcome: &'static str, reason: &'static str) { REGISTRY.inc_counter( "furumusic_auth_login_attempts_total", diff --git a/templates/admin/v2.html b/templates/admin/v2.html index 3834865..23539b3 100644 --- a/templates/admin/v2.html +++ b/templates/admin/v2.html @@ -307,7 +307,8 @@ button { } .btn:disabled, -.icon-btn:disabled { +.icon-btn:disabled, +.seg-btn:disabled { opacity: 0.45; cursor: not-allowed; } @@ -416,6 +417,15 @@ tbody tr:hover { .col-tags { width: 250px; } .col-time { width: 132px; } +.users-table-wrap { + max-height: calc(100vh - 300px); +} + +.users-table .user-status-column { width: 116px; } +.users-table .user-role-column { width: 110px; } +.users-table .user-active-column { width: 112px; } +.users-table .user-seen-column { width: 170px; } + .check { width: 16px; height: 16px; @@ -1274,6 +1284,54 @@ tbody tr:hover { padding: 14px; overflow: auto; } + +.user-modal { + width: min(860px, calc(100vw - 80px)); +} + +.user-modal-tabs { + margin-bottom: 14px; +} + +.user-summary-grid, +.user-profile-facts { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 14px; +} + +.user-summary-card, +.user-profile-facts > div { + min-width: 0; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); +} + +.user-summary-card span, +.user-profile-facts span { + display: block; + color: var(--text-subdued); + font-size: 11px; + margin-bottom: 4px; +} + +.user-summary-card strong, +.user-profile-facts strong { + display: block; + overflow: hidden; + color: var(--text-primary); + font-size: 13px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-stats-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} {% endblock head_extra %} @@ -1309,6 +1367,11 @@ tbody tr:hover { Future Tools + + + +
+ + + + + + + + + + + + + +
StatusUserRoleAccountLast activity
+
No users in this filter
+
+
+ +
+ + + +
+
+ + +
@@ -2010,6 +2146,64 @@ tbody tr:hover {
+ +