diff --git a/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte b/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte index 7e74b14..368244a 100644 --- a/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte +++ b/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte @@ -36,12 +36,16 @@ dashboard, foldersPayload, hosts, + foldersPending = false, + loadError = '', crumb = '/', mode = 'queue' }: { dashboard: DashboardSummaryPayload; foldersPayload: DashboardFoldersPayload; hosts: HostsPayload; + foldersPending?: boolean; + loadError?: string; crumb?: string; mode?: 'queue' | 'folders'; } = $props(); @@ -133,10 +137,12 @@ dashboard, visibleFoldersPayload, hosts, - isFolderIndex ? folderScopeLabel : 'Work folders' + isFolderIndex ? folderScopeLabel : 'Work folders', + foldersPending ) ); const footerSignals = $derived(buildQueueFooterSignals(dashboard, visibleFoldersPayload, hosts)); + const hasLoadError = $derived(loadError.trim().length > 0); const runningSamples = $derived(dashboard.calibration_queue.sample.running_count); const queuedSamples = $derived(dashboard.calibration_queue.sample.queued_count); const pendingReviews = $derived(dashboard.calibration_queue.sample.pending_review_count); @@ -178,9 +184,20 @@ isFolderIndex ? `Matching ${folderScopeLabel.toLowerCase()}` : 'Workflow lanes' ); const visibleScopeSummary = $derived( - `${visibleFolders.length.toLocaleString('en-US')} / ${folders.length.toLocaleString( - 'en-US' - )} ${isFolderIndex ? folderScopeLabel.toLowerCase() : 'folders'}` + foldersPending && !isFolderIndex + ? 'Loading work folders' + : `${visibleFolders.length.toLocaleString('en-US')} / ${folders.length.toLocaleString( + 'en-US' + )} ${isFolderIndex ? folderScopeLabel.toLowerCase() : 'folders'}` + ); + const visibleFoldersCopy = $derived( + foldersPending && !isFolderIndex ? 'loading' : visibleFolders.length.toLocaleString('en-US') + ); + const visiblePendingCopy = $derived( + foldersPending && !isFolderIndex ? 'loading' : visiblePendingItems.toLocaleString('en-US') + ); + const visibleReclaimCopy = $derived( + foldersPending && !isFolderIndex ? 'loading' : formatBytes(visibleProjectedReclaim) ); type LibraryOption = { @@ -499,6 +516,9 @@

{headerCopy}

+ {#if hasLoadError} +
{loadError}
+ {/if} {#if isFolderIndex}
Scope @@ -531,15 +551,15 @@
{isFolderIndex ? `Visible ${folderScopeLabel}` : 'Visible folders'} - {visibleFolders.length.toLocaleString('en-US')} + {visibleFoldersCopy}
Open work - {visiblePendingItems.toLocaleString('en-US')} + {visiblePendingCopy}
Projected reclaim - {formatBytes(visibleProjectedReclaim)} + {visibleReclaimCopy}
@@ -720,6 +740,10 @@ {/each} + {:else if foldersPending} + + Loading folder worklist... + {:else} No folders match the current filters. @@ -802,6 +826,14 @@ min-width: 0; } + .load-error { + background: var(--mf-fail-bg); + border-left: 2px solid var(--mf-fail-fg); + color: var(--mf-fail-fg); + font-size: var(--mf-text-sm); + padding: var(--mf-space-3) var(--mf-space-4); + } + .queue-header__facts { display: grid; gap: var(--mf-space-4); diff --git a/frontend/src/lib/components/workstation/queue-workstation.ts b/frontend/src/lib/components/workstation/queue-workstation.ts index d412cf9..1cdb50f 100644 --- a/frontend/src/lib/components/workstation/queue-workstation.ts +++ b/frontend/src/lib/components/workstation/queue-workstation.ts @@ -222,7 +222,8 @@ export function buildQueueStatusTiles( dashboard: DashboardSummaryPayload, foldersPayload: DashboardFoldersPayload, hosts: HostsPayload, - scopeLabel = 'folders' + scopeLabel = 'folders', + foldersPending = false ): StatusTile[] { const folders = foldersPayload.folders; const visibleScopeLabel = scopeLabel.trim().toLowerCase() || 'folders'; @@ -233,15 +234,19 @@ export function buildQueueStatusTiles( return [ { label: 'Next work', - value: `${folders.length} ${visibleScopeLabel}`, - detail: `${totalPendingItems(folders).toLocaleString('en-US')} open work`, - tone: folders.length ? 'wait' : 'idle' + value: foldersPending ? 'loading worklist' : `${folders.length} ${visibleScopeLabel}`, + detail: foldersPending + ? 'folder rows hydrating' + : `${totalPendingItems(folders).toLocaleString('en-US')} open work`, + tone: foldersPending ? 'active' : folders.length ? 'wait' : 'idle' }, { label: 'Projected reclaim', - value: formatBytes(projectedReclaim), - detail: `estimated from visible ${visibleScopeLabel}`, - tone: projectedReclaim > 0 ? 'ready' : 'idle', + value: foldersPending ? 'loading' : formatBytes(projectedReclaim), + detail: foldersPending + ? `estimated after ${visibleScopeLabel} load` + : `estimated from visible ${visibleScopeLabel}`, + tone: foldersPending ? 'active' : projectedReclaim > 0 ? 'ready' : 'idle', mono: true }, { diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 02f32ec..539e7ef 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -7,30 +7,86 @@ HostsPayload } from '$lib/api/types'; import HomeWorkbenchView from '$lib/components/workstation/HomeWorkbenchView.svelte'; - import RouteLoadingView from '$lib/components/workstation/RouteLoadingView.svelte'; - let dashboard = $state(null); - let foldersPayload = $state(null); - let hosts = $state(null); + const emptyQueueLane = { + running: [], + queued: [], + pending_review: [], + running_count: 0, + queued_count: 0, + pending_review_count: 0 + }; + + const initialDashboard: DashboardSummaryPayload = { + folders_preview: [], + library_colors: {}, + scan_job: null, + calibration_queue: { + sample: emptyQueueLane, + full: emptyQueueLane, + active_count: 0 + }, + encode_queue: { + running_count: 0, + queued_count: 0, + running: [], + queued: [], + state: { is_paused: false, stop_requested: false, scheduler_summary: 'loading' } + }, + catalog_empty: false, + folder_cache_key: 'loading', + metric_support: { vmaf: false, xpsnr: false, ssim: false }, + metric_status_copy: 'loading' + }; + + const initialFoldersPayload: DashboardFoldersPayload = { + folders: [], + folder_cache_key: 'loading', + catalog_empty: false + }; + + const initialHosts: HostsPayload = { compact: true, hosts: [] }; + + let dashboard = $state(initialDashboard); + let foldersPayload = $state(initialFoldersPayload); + let hosts = $state(initialHosts); let loadError = $state(''); + let foldersPending = $state(true); onMount(async () => { try { const [dashboardPayload, hostsPayload] = await Promise.all([ - fetchJson('/api/dashboard'), + fetchJson('/api/dashboard?preview_limit=0'), fetchJson('/api/hosts?compact=1') ]); dashboard = dashboardPayload; hosts = hostsPayload; foldersPayload = { - folders: dashboardPayload.folders_preview, + folders: [], folder_cache_key: dashboardPayload.folder_cache_key, - catalog_empty: dashboardPayload.catalog_empty + catalog_empty: false }; + foldersPending = true; + void hydrateFolders(dashboardPayload); } catch (error) { loadError = error instanceof Error ? error.message : 'Work route failed to load.'; + foldersPending = false; } }); + + async function hydrateFolders(dashboardPayload: DashboardSummaryPayload) { + try { + const hydratedFolders = await fetchJson( + '/api/dashboard/folders?include_series=0' + ); + foldersPayload = hydratedFolders; + dashboard = { ...dashboardPayload, folders_preview: hydratedFolders.folders }; + } catch (error) { + loadError = error instanceof Error ? error.message : 'Work folders failed to load.'; + } finally { + foldersPending = false; + } + } @@ -38,7 +94,5 @@ {#if dashboard && foldersPayload && hosts} - -{:else} - + {/if} diff --git a/frontend/src/routes/folders/+page.svelte b/frontend/src/routes/folders/+page.svelte index fbb4d5e..bf07a7d 100644 --- a/frontend/src/routes/folders/+page.svelte +++ b/frontend/src/routes/folders/+page.svelte @@ -17,12 +17,12 @@ onMount(async () => { try { const [dashboardPayload, foldersPayloadResult, hostsPayload] = await Promise.all([ - fetchJson('/api/dashboard'), + fetchJson('/api/dashboard?preview_limit=0'), fetchJson('/api/dashboard/folders'), fetchJson('/api/hosts?compact=1') ]); - dashboard = dashboardPayload; foldersPayload = foldersPayloadResult; + dashboard = { ...dashboardPayload, folders_preview: foldersPayloadResult.folders }; hosts = hostsPayload; } catch (error) { loadError = error instanceof Error ? error.message : 'Folders route failed to load.'; diff --git a/frontend/src/routes/ops/+page.svelte b/frontend/src/routes/ops/+page.svelte index 58df92d..c901b23 100644 --- a/frontend/src/routes/ops/+page.svelte +++ b/frontend/src/routes/ops/+page.svelte @@ -12,7 +12,7 @@ onMount(async () => { try { const [dashboardPayload, hostsPayload] = await Promise.all([ - fetchJson('/api/dashboard'), + fetchJson('/api/dashboard?preview_limit=0'), fetchJson('/api/hosts?compact=1') ]); dashboard = dashboardPayload; diff --git a/mediaforce/web/app.py b/mediaforce/web/app.py index d83e0df..0af0006 100644 --- a/mediaforce/web/app.py +++ b/mediaforce/web/app.py @@ -404,7 +404,7 @@ def _settings_page_payload( schedule_profiles=schedule_profiles, ) - def _dashboard_summary_payload() -> dict[str, Any]: + def _dashboard_summary_payload(preview_limit: int | None = None) -> dict[str, Any]: return dashboard_summary_payload( config, folder_card_cache_key=_folder_card_cache_key, @@ -412,20 +412,22 @@ def _dashboard_summary_payload() -> dict[str, Any]: maybe_schedule_scan=_maybe_schedule_scan, decorate_encode_queue_for_scheduler=_decorate_encode_queue_for_scheduler, library_color_map_for_config=_library_color_map_for_config, + preview_limit=preview_limit, ) - def _dashboard_folders_payload() -> dict[str, Any]: + def _dashboard_folders_payload(include_series_folders: bool = True) -> dict[str, Any]: return dashboard_folders_payload( config, folder_card_cache_key=_folder_card_cache_key, list_folder_cards=_list_folder_cards, list_series_folder_cards=_list_series_folder_cards, + include_series_folders=include_series_folders, ) - def _dashboard_api_payload() -> dict[str, Any]: + def _dashboard_api_payload(preview_limit: int | None = None) -> dict[str, Any]: metric_support = _metric_support() return { - **_dashboard_summary_payload(), + **_dashboard_summary_payload(preview_limit), "metric_support": dict(metric_support), "metric_status_copy": _metric_status_copy(metric_support), } diff --git a/mediaforce/web/routes/dashboard.py b/mediaforce/web/routes/dashboard.py index 0b49e4e..954ce1c 100644 --- a/mediaforce/web/routes/dashboard.py +++ b/mediaforce/web/routes/dashboard.py @@ -1,20 +1,22 @@ from collections.abc import Callable -from typing import Any +from typing import Annotated, Any -from fastapi import FastAPI +from fastapi import FastAPI, Query from fastapi.responses import JSONResponse +PreviewLimit = Annotated[int | None, Query(ge=0)] + def register_dashboard_routes( app: FastAPI, *, - dashboard_payload: Callable[[], dict[str, Any]], - dashboard_folders_payload: Callable[[], dict[str, Any]], + dashboard_payload: Callable[[int | None], dict[str, Any]], + dashboard_folders_payload: Callable[[bool], dict[str, Any]], ) -> None: @app.get("/api/dashboard") - def api_dashboard() -> JSONResponse: - return JSONResponse(dashboard_payload()) + def api_dashboard(preview_limit: PreviewLimit = None) -> JSONResponse: + return JSONResponse(dashboard_payload(preview_limit)) @app.get("/api/dashboard/folders") - def api_dashboard_folders() -> JSONResponse: - return JSONResponse(dashboard_folders_payload()) + def api_dashboard_folders(include_series: int = 1) -> JSONResponse: + return JSONResponse(dashboard_folders_payload(bool(include_series))) diff --git a/mediaforce/web/runtime/dashboard_payloads.py b/mediaforce/web/runtime/dashboard_payloads.py index d07f5ba..d5b8b34 100644 --- a/mediaforce/web/runtime/dashboard_payloads.py +++ b/mediaforce/web/runtime/dashboard_payloads.py @@ -1,9 +1,12 @@ from dataclasses import asdict from typing import Any +from sqlalchemy import func, select + from mediaforce.tuning.calibration_jobs import list_queue_summary from mediaforce.core.config import MediaforceConfig from mediaforce.core.db import open_db +from mediaforce.core.db_tables import library_items from mediaforce.encoding.encode_queue import summarize_encode_queue from mediaforce.library.workflow_state import build_folder_workflow_state @@ -16,11 +19,26 @@ def dashboard_summary_payload( maybe_schedule_scan: Any, decorate_encode_queue_for_scheduler: Any, library_color_map_for_config: Any, + preview_limit: int | None = None, ) -> dict[str, Any]: + if preview_limit is not None and preview_limit < 0: + raise ValueError("preview_limit must be non-negative") + cache_key = folder_card_cache_key(config) with open_db(config.paths.db_path) as connection: scan_job = maybe_schedule_scan(connection, config, prefix=None) - preview_folders = preview_folder_cards(config, connection) + preview_folders = [] if preview_limit == 0 else preview_folder_cards(config, connection) + if preview_limit is not None and preview_limit > 0: + preview_folders = preview_folders[:preview_limit] + catalog_empty = not preview_folders + if preview_limit == 0: + catalog_empty = int( + connection.execute( + select(func.count()) + .select_from(library_items) + .where(library_items.c.status != "missing") + ).scalar_one() + ) == 0 calibration_queue = list_queue_summary(connection) encode_queue = decorate_encode_queue_for_scheduler(config, summarize_encode_queue(connection)) return { @@ -29,7 +47,7 @@ def dashboard_summary_payload( "calibration_queue": calibration_queue, "encode_queue": encode_queue, "folders_preview": [asdict(folder) for folder in preview_folders], - "catalog_empty": not preview_folders, + "catalog_empty": catalog_empty, "folder_cache_key": _serialize_cache_key(cache_key), } @@ -40,11 +58,16 @@ def dashboard_folders_payload( folder_card_cache_key: Any, list_folder_cards: Any, list_series_folder_cards: Any | None = None, + include_series_folders: bool = True, ) -> dict[str, Any]: cache_key = folder_card_cache_key(config) with open_db(config.paths.db_path) as connection: folders = list_folder_cards(config, connection) - series_folders = list_series_folder_cards(config, connection) if list_series_folder_cards is not None else [] + series_folders = ( + list_series_folder_cards(config, connection) + if include_series_folders and list_series_folder_cards is not None + else [] + ) return { "folders": [asdict(folder) for folder in folders], "series_folders": [asdict(folder) for folder in series_folders], diff --git a/tests/test_tuning_runtime.py b/tests/test_tuning_runtime.py index 21a9980..0010b46 100644 --- a/tests/test_tuning_runtime.py +++ b/tests/test_tuning_runtime.py @@ -312,6 +312,35 @@ def _insert_promoted_artifact( ) connection.commit() + def _insert_library_item(self, *, rel_path: str, status: str = "discovered") -> None: + source_path = self.root / "source" / rel_path + source_path.parent.mkdir(parents=True, exist_ok=True) + source_path.write_text("source") + with open_db(self.config.paths.db_path) as connection: + connection.execute( + library_items.insert().values( + source_path=str(source_path), + rel_path=rel_path, + media_root="tv", + parent_dir=str(Path(rel_path).parent), + file_name=Path(rel_path).name, + container=Path(rel_path).suffix or ".mkv", + size_bytes=1024, + mtime_ns=1, + fingerprint=f"fp-{rel_path}", + duration_seconds=1800.0, + video_codec="h264", + audio_summary_json="[]", + subtitle_summary_json="[]", + status=status, + last_scan_id="scan-1", + discovered_at="2025-01-01T00:00:00Z", + last_seen_at="2025-01-01T00:00:00Z", + updated_at="2025-01-01T00:00:00Z", + ) + ) + connection.commit() + def test_request_operator_note_parse_uses_structured_runtime_path(self) -> None: commands, fake_run = self._capture_subprocess_commands( json.dumps( @@ -1042,6 +1071,37 @@ def test_dashboard_summary_payload_does_not_scan_archive_cleanup(self) -> None: self.assertNotIn("archive_cleanup", payload) + def test_dashboard_summary_payload_preview_limit_zero_skips_folder_cards(self) -> None: + self._insert_library_item(rel_path="tv/show/episode-1.mkv") + + def fail_preview_cards(_config: MediaforceConfig, _connection: Any) -> list[Any]: + raise AssertionError("folder cards should not be built for preview_limit=0") + + payload = dashboard_summary_payload( + self.config, + folder_card_cache_key=lambda _config: ("one-item", 0, 0), + preview_folder_cards=fail_preview_cards, + maybe_schedule_scan=lambda _connection, _config, prefix=None: None, + decorate_encode_queue_for_scheduler=lambda _config, summary: summary, + library_color_map_for_config=lambda _config: {}, + preview_limit=0, + ) + + self.assertEqual(payload["folders_preview"], []) + self.assertFalse(payload["catalog_empty"]) + + def test_dashboard_summary_payload_rejects_negative_preview_limit(self) -> None: + with self.assertRaises(ValueError): + dashboard_summary_payload( + self.config, + folder_card_cache_key=lambda _config: ("empty", 0, 0), + preview_folder_cards=lambda _config, _connection: [], + maybe_schedule_scan=lambda _connection, _config, prefix=None: None, + decorate_encode_queue_for_scheduler=lambda _config, summary: summary, + library_color_map_for_config=lambda _config: {}, + preview_limit=-1, + ) + def test_clear_archive_cleanup_action_removes_files_and_prunes_directories(self) -> None: archive_root = self.config.archive_root archived = archive_root / "tv/show/episode-1.mkv"