diff --git a/Cargo.toml b/Cargo.toml index ea2eab7..61e6f20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.2.10" +version = "0.2.11" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/admin/v2.rs b/src/admin/v2.rs index ab2ac03..b663bf9 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::path::Path; use cot::db::{Database, Model}; use cot::html::Html; @@ -150,6 +151,7 @@ struct AdminDashboardDto { user: AdminUserDto, build: BuildDto, stats: OverviewStatsDto, + runtime: RuntimeOverviewDto, reviews: ReviewPageDto, jobs: Vec, recent_runs: Vec, @@ -167,6 +169,40 @@ struct OverviewStatsDto { hidden_artists: i64, } +#[derive(Debug, Serialize, JsonSchema)] +struct RuntimeOverviewDto { + agent: AgentStatusDto, + storage: Vec, + node: NodeStatsDto, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct AgentStatusDto { + status: String, + enabled: bool, + llm_configured: bool, + model: String, + concurrency: u64, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct StoragePathDto { + label: String, + path: String, + exists: bool, + free_bytes: Option, + total_bytes: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct NodeStatsDto { + hostname: String, + os: &'static str, + arch: &'static str, + pid: u32, + cpu_count: usize, +} + #[derive(Debug, Serialize, JsonSchema)] struct StatusCountDto { status: String, @@ -565,6 +601,8 @@ pub async fn dashboard( limit: Some(80), offset: Some(0), }; + let (config, _) = AppConfig::load_with_db(&db).await; + let runtime = load_runtime_overview(&config); let (reviews, stats, jobs, recent_runs, library) = tokio::try_join!( load_review_page(pool, reviews_query), load_overview_stats(pool), @@ -582,6 +620,7 @@ pub async fn dashboard( }, build: build_dto(), stats, + runtime, reviews, jobs, recent_runs, @@ -965,7 +1004,9 @@ pub async fn run_metadata_backfill( let duration_ms = start.elapsed().as_millis() as i64; match result { Ok(()) => { - let _ = run.set_completed(&db_for_task, duration_ms, &log.output()).await; + let _ = run + .set_completed(&db_for_task, duration_ms, &log.output()) + .await; } Err(err) => { let _ = run @@ -1233,11 +1274,13 @@ pub async fn update_library_item( .map_err(|e| cot::Error::internal(e.to_string()))?; } } else { - sqlx::query("DELETE FROM furumusic__track_artist WHERE track_id = $1 AND role = 'main'") - .bind(body.id) - .execute(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + sqlx::query( + "DELETE FROM furumusic__track_artist WHERE track_id = $1 AND role = 'main'", + ) + .bind(body.id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; for (position, artist_id) in artist_ids.iter().enumerate() { sqlx::query( "INSERT INTO furumusic__track_artist (track_id, artist_id, role, position) VALUES ($1, $2, 'main', $3)", @@ -1511,6 +1554,158 @@ async fn load_overview_stats(pool: &PgPool) -> anyhow::Result }) } +fn load_runtime_overview(config: &AppConfig) -> RuntimeOverviewDto { + let llm_configured = !config.agent_llm_url.trim().is_empty(); + let agent_status = if !config.agent_enabled { + "disabled" + } else if !llm_configured { + "not_configured" + } else { + "enabled" + }; + + RuntimeOverviewDto { + agent: AgentStatusDto { + status: agent_status.to_owned(), + enabled: config.agent_enabled, + llm_configured, + model: config.agent_llm_model.clone(), + concurrency: config.agent_concurrency, + }, + storage: vec![ + storage_path_dto("Inbox", &config.agent_inbox_dir), + storage_path_dto("Library", &config.agent_storage_dir), + ], + node: NodeStatsDto { + hostname: node_hostname(), + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + pid: std::process::id(), + cpu_count: std::thread::available_parallelism() + .map(|count| count.get()) + .unwrap_or(1), + }, + } +} + +fn storage_path_dto(label: &str, raw_path: &str) -> StoragePathDto { + let path = raw_path.trim(); + let path_ref = Path::new(path); + let usage = if path.is_empty() { + None + } else { + disk_usage(path_ref).or_else(|| { + path_ref + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .and_then(disk_usage) + }) + }; + + StoragePathDto { + label: label.to_owned(), + path: path.to_owned(), + exists: !path.is_empty() && path_ref.exists(), + free_bytes: usage.map(|value| value.free_bytes), + total_bytes: usage.map(|value| value.total_bytes), + } +} + +fn node_hostname() -> String { + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "unknown".to_owned()) +} + +#[derive(Debug, Clone, Copy)] +struct DiskUsage { + free_bytes: u64, + total_bytes: u64, +} + +#[cfg(windows)] +fn disk_usage(path: &Path) -> Option { + use std::os::windows::ffi::OsStrExt; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetDiskFreeSpaceExW( + lpDirectoryName: *const u16, + lpFreeBytesAvailableToCaller: *mut u64, + lpTotalNumberOfBytes: *mut u64, + lpTotalNumberOfFreeBytes: *mut u64, + ) -> i32; + } + + let mut wide: Vec = path.as_os_str().encode_wide().collect(); + wide.push(0); + + let mut free_available = 0_u64; + let mut total = 0_u64; + let mut total_free = 0_u64; + let ok = unsafe { + GetDiskFreeSpaceExW( + wide.as_ptr(), + &mut free_available, + &mut total, + &mut total_free, + ) + }; + (ok != 0).then_some(DiskUsage { + free_bytes: free_available, + total_bytes: total, + }) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn disk_usage(path: &Path) -> Option { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + #[repr(C)] + struct Statvfs { + f_bsize: std::ffi::c_ulong, + f_frsize: std::ffi::c_ulong, + f_blocks: std::ffi::c_ulong, + f_bfree: std::ffi::c_ulong, + f_bavail: std::ffi::c_ulong, + f_files: std::ffi::c_ulong, + f_ffree: std::ffi::c_ulong, + f_favail: std::ffi::c_ulong, + f_fsid: std::ffi::c_ulong, + f_flag: std::ffi::c_ulong, + f_namemax: std::ffi::c_ulong, + __f_spare: [std::ffi::c_int; 6], + } + + unsafe extern "C" { + fn statvfs(path: *const std::ffi::c_char, buf: *mut Statvfs) -> std::ffi::c_int; + } + + let c_path = CString::new(path.as_os_str().as_bytes()).ok()?; + let mut stat = std::mem::MaybeUninit::::uninit(); + let ok = unsafe { statvfs(c_path.as_ptr(), stat.as_mut_ptr()) }; + if ok != 0 { + return None; + } + let stat = unsafe { stat.assume_init() }; + let fragment_size = if stat.f_frsize > 0 { + stat.f_frsize as u64 + } else { + stat.f_bsize as u64 + }; + + Some(DiskUsage { + free_bytes: stat.f_bavail as u64 * fragment_size, + total_bytes: stat.f_blocks as u64 * fragment_size, + }) +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "android")))] +fn disk_usage(_path: &Path) -> Option { + None +} + async fn load_library_overview(pool: &PgPool) -> anyhow::Result { let stats = load_overview_stats(pool).await?; Ok(LibraryOverviewDto { diff --git a/templates/admin/v2.html b/templates/admin/v2.html index 499094b..3834865 100644 --- a/templates/admin/v2.html +++ b/templates/admin/v2.html @@ -185,7 +185,7 @@ button { .stats-strip { display: grid; - grid-template-columns: repeat(7, minmax(104px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 8px; margin-bottom: 14px; } @@ -201,12 +201,18 @@ button { .stat-value { font-size: 18px; font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .stat-label { margin-top: 2px; color: var(--text-subdued); font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .panel { @@ -1355,8 +1361,8 @@ tbody tr:hover {
@@ -2298,6 +2304,7 @@ function adminV2() { libraryLoading: false, toastMessage: '', stats: {}, + runtime: { agent: {}, storage: [], node: {} }, libraryOverview: {}, reviews: { items: [], total: 0, limit: 80, offset: 0, status_counts: [] }, reviewFilter: { status: null, search: '' }, @@ -2419,6 +2426,7 @@ function adminV2() { try { const data = await this.request(`${this.apiBase}/dashboard`); this.stats = data.stats || {}; + this.runtime = data.runtime || this.runtime; this.libraryOverview = data.library || {}; this.reviews = data.reviews || this.reviews; this.jobs = data.jobs || []; @@ -3406,17 +3414,56 @@ function adminV2() { }, statCells() { + const agent = (this.runtime && this.runtime.agent) || {}; + const storage = (this.runtime && this.runtime.storage) || []; + const node = (this.runtime && this.runtime.node) || {}; + const inbox = storage.find(item => item.label === 'Inbox') || {}; + const library = storage.find(item => item.label === 'Library') || {}; return [ { label: 'Tracks', value: this.stats.tracks || 0 }, { label: 'Releases', value: this.stats.releases || 0 }, { label: 'Artists', value: this.stats.artists || 0 }, { label: 'Playlists', value: this.stats.playlists || 0 }, - { label: 'Hidden tracks', value: this.stats.hidden_tracks || 0 }, - { label: 'Hidden releases', value: this.stats.hidden_releases || 0 }, - { label: 'Hidden artists', value: this.stats.hidden_artists || 0 } + { + label: agent.model ? `AI agent · ${agent.model}` : 'AI agent', + display: agent.status || 'unknown', + title: `enabled: ${agent.enabled ? 'yes' : 'no'} · llm: ${agent.llm_configured ? 'configured' : 'missing'} · concurrency: ${agent.concurrency || 0}` + }, + this.storageStatCell(inbox, 'Inbox disk'), + this.storageStatCell(library, 'Library disk'), + { + label: `${node.hostname || 'node'} · pid ${node.pid || '?'}`, + display: `${node.cpu_count || '?'} CPU`, + title: `${node.os || 'unknown'} / ${node.arch || 'unknown'}` + } ]; }, + storageStatCell(item, fallbackLabel) { + const free = item && item.free_bytes != null ? item.free_bytes : null; + const total = item && item.total_bytes != null ? item.total_bytes : null; + const suffix = item && item.exists === false ? ' · missing' : ''; + return { + label: `${item.label || fallbackLabel}${suffix}`, + display: free != null ? `${this.formatBytes(free)} free` : 'n/a', + title: `${item.path || 'not configured'}${total != null ? ` · ${this.formatBytes(total)} total` : ''}` + }; + }, + + formatBytes(bytes) { + const value = Number(bytes || 0); + if (!Number.isFinite(value) || value <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let size = value; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + const digits = size >= 10 || unit === 0 ? 0 : 1; + return `${size.toFixed(digits)} ${units[unit]}`; + }, + pageTitle() { if (this.activeView === 'library') return 'Library Workbench'; if (this.activeView === 'jobs') return 'Tasks'; diff --git a/templates/player/scripts.html b/templates/player/scripts.html index e8ed180..5bd73bc 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -168,18 +168,6 @@ document.addEventListener('alpine:init', () => { Alpine.store('mobile', { libraryOpen: false, playerExpanded: false, - playerDragging: false, - playerDragOffset: 0, - playerCloseOffset: 0, - _playerDragStartY: 0, - _playerDragStartX: 0, - _playerDragTracking: false, - _playerDragMode: null, - _playerDragPointerId: null, - _playerDragElement: null, - _playerDragMove: null, - _playerDragEnd: null, - _playerSuppressClickUntil: 0, toggleLibrary() { this.libraryOpen = !this.libraryOpen; if (this.libraryOpen) Alpine.store('user').menuOpen = false; @@ -188,119 +176,30 @@ document.addEventListener('alpine:init', () => { this.libraryOpen = false; }, isMobilePlayer() { - return window.matchMedia && window.matchMedia('(max-width: 720px)').matches; + if (!window.matchMedia) return false; + return window.matchMedia('(max-width: 900px)').matches + || window.matchMedia('(pointer: coarse) and (max-width: 1024px)').matches; }, openPlayerFullscreen() { if (!this.isMobilePlayer() || !Alpine.store('player').currentTrack) return; this.playerExpanded = true; - this.playerDragging = false; - this.playerDragOffset = 0; - this.playerCloseOffset = 0; Alpine.store('queue').visible = false; Alpine.store('devices').open = false; + this.resetPlayerFullscreenScroll(); }, closePlayerFullscreen() { this.playerExpanded = false; - this.playerDragging = false; - this.playerDragOffset = 0; - this.playerCloseOffset = 0; + Alpine.store('queue').visible = false; + Alpine.store('devices').open = false; }, - playerDragStyle() { - return `--mobile-player-drag:${this.playerDragOffset}px; --mobile-player-close-drag:${this.playerCloseOffset}px;`; - }, - handlePlayerClick(event) { - if (Date.now() <= this._playerSuppressClickUntil) { - event.preventDefault(); - event.stopPropagation(); - this._playerSuppressClickUntil = 0; - } - }, - startPlayerDrag(event, force = false) { - if (!this.isMobilePlayer() || !Alpine.store('player').currentTrack) return; - if (event.button && event.button !== 0) return; - const target = event.target; - const isDragBlocked = target.closest('input, select, textarea, .volume-slider, .device-popover, .mobile-expanded-queue'); - if (!force) { - if (this.playerExpanded) { - const isCloseHandle = target.closest('.player-now-playing'); - const scroller = event.currentTarget?.classList?.contains('player-bar') ? event.currentTarget : null; - if (!isCloseHandle || isDragBlocked || (scroller && scroller.scrollTop > 4)) return; - } else if (isDragBlocked) { - return; + resetPlayerFullscreenScroll() { + requestAnimationFrame(() => { + const full = document.querySelector('.player-bar .mobile-full-player'); + if (full) { + full.scrollTop = 0; + full.scrollLeft = 0; } - } - if (this._playerDragTracking) this.endPlayerDrag({ type: 'pointercancel' }); - this.playerDragging = false; - this._playerDragTracking = true; - this._playerDragMode = this.playerExpanded ? 'close' : 'open'; - this._playerDragStartY = event.clientY; - this._playerDragStartX = event.clientX; - this._playerDragPointerId = event.pointerId; - this._playerDragElement = event.currentTarget; - this.playerDragOffset = 0; - this.playerCloseOffset = 0; - this._playerDragMove = e => this.movePlayerDrag(e); - this._playerDragEnd = e => this.endPlayerDrag(e); - window.addEventListener('pointermove', this._playerDragMove, { passive: false }); - window.addEventListener('pointerup', this._playerDragEnd, { passive: false }); - window.addEventListener('pointercancel', this._playerDragEnd, { passive: false }); - }, - movePlayerDrag(event) { - if (!this._playerDragTracking) return; - const delta = this._playerDragStartY - event.clientY; - const absDelta = Math.abs(delta); - if (!this.playerDragging) { - const horizontalDelta = Math.abs(event.clientX - this._playerDragStartX); - const wantsOpen = this._playerDragMode === 'open' && delta > 0; - const wantsClose = this._playerDragMode === 'close' && delta < 0; - if (absDelta < 8 || absDelta < horizontalDelta * 1.15 || (!wantsOpen && !wantsClose)) return; - this.playerDragging = true; - try { - this._playerDragElement?.setPointerCapture?.(this._playerDragPointerId); - } catch (_) {} - } - event.preventDefault(); - if (this._playerDragMode === 'close') { - this.playerCloseOffset = Math.max(0, Math.min(window.innerHeight, -delta)); - } else { - const max = Math.max(0, window.innerHeight - 132); - this.playerDragOffset = Math.max(0, Math.min(max, delta)); - } - }, - endPlayerDrag(event) { - const openThreshold = Math.min(180, Math.max(90, window.innerHeight * 0.18)); - const closeThreshold = Math.min(110, Math.max(64, window.innerHeight * 0.1)); - const wasCancelled = event?.type === 'pointercancel'; - const wasDragging = this.playerDragging; - if (wasDragging) this._playerSuppressClickUntil = Date.now() + 450; - if (this._playerDragMode === 'close') { - if (!wasCancelled && this.playerCloseOffset > closeThreshold) this.closePlayerFullscreen(); - else { - this.playerCloseOffset = 0; - this.playerDragging = false; - } - } else if (this._playerDragMode === 'open' && !wasCancelled && this.playerDragOffset > openThreshold) { - this.openPlayerFullscreen(); - } else { - this.playerDragOffset = 0; - this.playerDragging = false; - } - try { - if (this._playerDragPointerId !== null && this._playerDragElement?.hasPointerCapture?.(this._playerDragPointerId)) { - this._playerDragElement.releasePointerCapture(this._playerDragPointerId); - } - } catch (_) {} - if (this._playerDragMove) window.removeEventListener('pointermove', this._playerDragMove); - if (this._playerDragEnd) { - window.removeEventListener('pointerup', this._playerDragEnd); - window.removeEventListener('pointercancel', this._playerDragEnd); - } - this._playerDragTracking = false; - this._playerDragMode = null; - this._playerDragPointerId = null; - this._playerDragElement = null; - this._playerDragMove = null; - this._playerDragEnd = null; + }); }, }); diff --git a/templates/player/shell.html b/templates/player/shell.html index 95fb355..4c201b7 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -958,16 +958,14 @@
- -
+ :class="{ 'mobile-expanded': $store.mobile.playerExpanded }"> +
+ +
-
+
-
+
v{{ t.app_version() }}
-
+
-
+