diff --git a/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte b/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte index 0e5fa91..7e74b14 100644 --- a/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte +++ b/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte @@ -89,9 +89,14 @@ ); const visibleFoldersPayload = $derived({ ...foldersPayload, folders: visibleFolders }); const visibleTableFolders = $derived(visibleFolders.slice(0, 32)); + const visibleFolderRanks = $derived( + new Map(visibleFolders.map((folder, index) => [folder.prefix, String(index + 1)])) + ); const visibleWorkLaneGroups = $derived( isFolderIndex ? [] : buildWorkLaneGroups(visibleTableFolders) ); + const visiblePendingItems = $derived(totalPendingItems(visibleFolders)); + const visibleProjectedReclaim = $derived(totalProjectedReclaim(visibleFolders)); const nextFolder = $derived( isFolderIndex ? (visibleFolders[0] ?? null) @@ -198,8 +203,7 @@ } function rowRank(folder: FolderCard): string { - const index = visibleFolders.findIndex((candidate) => candidate.prefix === folder.prefix); - return index >= 0 ? String(index + 1) : '—'; + return visibleFolderRanks.get(folder.prefix) ?? '—'; } function libraryKey(label: string): string { @@ -531,11 +535,11 @@
Open work - {totalPendingItems(visibleFolders).toLocaleString('en-US')} + {visiblePendingItems.toLocaleString('en-US')}
Projected reclaim - {formatBytes(totalProjectedReclaim(visibleFolders))} + {formatBytes(visibleProjectedReclaim)}
diff --git a/frontend/src/lib/components/workstation/RouteLoadingView.svelte b/frontend/src/lib/components/workstation/RouteLoadingView.svelte new file mode 100644 index 0000000..f6a542a --- /dev/null +++ b/frontend/src/lib/components/workstation/RouteLoadingView.svelte @@ -0,0 +1,129 @@ + + + +
+
+
+ {subject} +

{error ? 'Route data failed' : message}

+

{error || 'The route is visible while Mediaforce waits on the API.'}

+
+ {#if !error} + + {/if} +
+
+
+ + diff --git a/frontend/src/lib/components/workstation/queue-workstation.ts b/frontend/src/lib/components/workstation/queue-workstation.ts index 79839d7..d412cf9 100644 --- a/frontend/src/lib/components/workstation/queue-workstation.ts +++ b/frontend/src/lib/components/workstation/queue-workstation.ts @@ -229,6 +229,7 @@ export function buildQueueStatusTiles( const readyHosts = hosts.hosts.filter((host) => host.available).length; const encodeQueue = dashboard.encode_queue; const scanStatus = dashboard.scan_job?.status ?? 'idle'; + const projectedReclaim = totalProjectedReclaim(folders); return [ { label: 'Next work', @@ -238,9 +239,9 @@ export function buildQueueStatusTiles( }, { label: 'Projected reclaim', - value: formatBytes(totalProjectedReclaim(folders)), + value: formatBytes(projectedReclaim), detail: `estimated from visible ${visibleScopeLabel}`, - tone: totalProjectedReclaim(folders) > 0 ? 'ready' : 'idle', + tone: projectedReclaim > 0 ? 'ready' : 'idle', mono: true }, { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 7e71d93..a6aa78b 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,8 +1,10 @@ @@ -11,3 +13,60 @@ {@render children()} + +{#if routeLoading} +
+ + Loading workstation data +
+{/if} + + diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 7c8410d..02f32ec 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,15 +1,44 @@ Mediaforce Work - +{#if dashboard && foldersPayload && hosts} + +{:else} + +{/if} diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts index 011cc2a..291f5cb 100644 --- a/frontend/src/routes/+page.ts +++ b/frontend/src/routes/+page.ts @@ -1,20 +1,3 @@ -import { fetchJson } from '$lib/api/client'; -import type { - DashboardFoldersPayload, - DashboardSummaryPayload, - HostsPayload -} from '$lib/api/types'; - -export async function load({ fetch }: { fetch: typeof window.fetch }) { - const [dashboard, hosts] = await Promise.all([ - fetchJson('/api/dashboard', fetch), - fetchJson('/api/hosts?compact=1', fetch) - ]); - const foldersPayload: DashboardFoldersPayload = { - folders: dashboard.folders_preview, - folder_cache_key: dashboard.folder_cache_key, - catalog_empty: dashboard.catalog_empty - }; - - return { dashboard, foldersPayload, hosts }; +export function load() { + return {}; } diff --git a/frontend/src/routes/completed/+page.svelte b/frontend/src/routes/completed/+page.svelte index 4a13604..f599605 100644 --- a/frontend/src/routes/completed/+page.svelte +++ b/frontend/src/routes/completed/+page.svelte @@ -1,11 +1,28 @@ Mediaforce Completed - +{#if completed} + +{:else} + +{/if} diff --git a/frontend/src/routes/completed/+page.ts b/frontend/src/routes/completed/+page.ts index 7ca4c87..291f5cb 100644 --- a/frontend/src/routes/completed/+page.ts +++ b/frontend/src/routes/completed/+page.ts @@ -1,25 +1,3 @@ -import { fetchJson } from '$lib/api/client'; -import type { CompletedPayload } from '$lib/api/types'; - -type SettledPayload = { data: T; error: null } | { data: null; error: string }; - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'Request failed.'; -} - -async function settle(promise: Promise): Promise> { - try { - return { data: await promise, error: null }; - } catch (error) { - return { data: null, error: errorMessage(error) }; - } -} - -export async function load({ fetch }: { fetch: typeof window.fetch }) { - const completed = await settle(fetchJson('/api/completed', fetch)); - - return { - completed: completed.data, - loadError: completed.error - }; +export function load() { + return {}; } diff --git a/frontend/src/routes/folders/+page.svelte b/frontend/src/routes/folders/+page.svelte index 957d26b..fbb4d5e 100644 --- a/frontend/src/routes/folders/+page.svelte +++ b/frontend/src/routes/folders/+page.svelte @@ -1,17 +1,41 @@ Mediaforce Folders - +{#if dashboard && foldersPayload && hosts} + +{:else} + +{/if} diff --git a/frontend/src/routes/folders/+page.ts b/frontend/src/routes/folders/+page.ts index 90cd5ab..291f5cb 100644 --- a/frontend/src/routes/folders/+page.ts +++ b/frontend/src/routes/folders/+page.ts @@ -1,16 +1,3 @@ -import { fetchJson } from '$lib/api/client'; -import type { - DashboardFoldersPayload, - DashboardSummaryPayload, - HostsPayload -} from '$lib/api/types'; - -export async function load({ fetch }: { fetch: typeof window.fetch }) { - const [dashboard, foldersPayload, hosts] = await Promise.all([ - fetchJson('/api/dashboard', fetch), - fetchJson('/api/dashboard/folders', fetch), - fetchJson('/api/hosts?compact=1', fetch) - ]); - - return { dashboard, foldersPayload, hosts }; +export function load() { + return {}; } diff --git a/frontend/src/routes/ops/+page.svelte b/frontend/src/routes/ops/+page.svelte index a0f2695..58df92d 100644 --- a/frontend/src/routes/ops/+page.svelte +++ b/frontend/src/routes/ops/+page.svelte @@ -1,11 +1,34 @@ Mediaforce Ops - +{#if dashboard && hosts} + +{:else} + +{/if} diff --git a/frontend/src/routes/ops/+page.ts b/frontend/src/routes/ops/+page.ts index d702905..291f5cb 100644 --- a/frontend/src/routes/ops/+page.ts +++ b/frontend/src/routes/ops/+page.ts @@ -1,30 +1,3 @@ -import { fetchJson } from '$lib/api/client'; -import type { DashboardSummaryPayload, HostsPayload } from '$lib/api/types'; - -type SettledPayload = { data: T; error: null } | { data: null; error: string }; - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'Request failed.'; -} - -async function settle(promise: Promise): Promise> { - try { - return { data: await promise, error: null }; - } catch (error) { - return { data: null, error: errorMessage(error) }; - } -} - -export async function load({ fetch }: { fetch: typeof window.fetch }) { - const [dashboard, hosts] = await Promise.all([ - settle(fetchJson('/api/dashboard', fetch)), - settle(fetchJson('/api/hosts?compact=1', fetch)) - ]); - const loadError = [dashboard.error, hosts.error].filter(Boolean).join(' · ') || null; - - return { - dashboard: dashboard.data, - hosts: hosts.data, - loadError - }; +export function load() { + return {}; } diff --git a/mediaforce/web/app.py b/mediaforce/web/app.py index 485d299..d83e0df 100644 --- a/mediaforce/web/app.py +++ b/mediaforce/web/app.py @@ -25,7 +25,7 @@ from fastapi.responses import Response from fastapi.staticfiles import StaticFiles from sqlalchemy import delete -from sqlalchemy import func +from sqlalchemy import and_, func from sqlalchemy import literal_column from sqlalchemy import or_ from sqlalchemy import select @@ -2620,13 +2620,31 @@ def _folder_series_context(prefix: str) -> dict[str, str] | None: def _folder_encode_candidate_count(connection: DBClient, config: MediaforceConfig, prefix: str) -> int: - return len( - select_encode_candidates( - connection, - config, - prefixes=[prefix], - limit=None, - ) + _ = config + normalized_prefix = prefix.strip().strip("/") + prefix_filter = or_( + library_items.c.rel_path == normalized_prefix, + library_items.c.rel_path.like(_prefix_descendant_like_pattern(normalized_prefix), escape="\\"), + ) + candidate_filter = or_( + library_items.c.status.in_(("discovered", "planned")), + and_( + library_items.c.status == "validated", + staged_artifacts.c.library_item_id.is_(None), + ), + ) + return int( + connection.execute( + select(func.count()) + .select_from( + library_items.outerjoin( + staged_artifacts, + staged_artifacts.c.library_item_id == library_items.c.id, + ) + ) + .where(prefix_filter) + .where(candidate_filter) + ).scalar_one() ) diff --git a/mediaforce/web/runtime/completed_runtime.py b/mediaforce/web/runtime/completed_runtime.py index 0adf455..1ba4913 100644 --- a/mediaforce/web/runtime/completed_runtime.py +++ b/mediaforce/web/runtime/completed_runtime.py @@ -11,10 +11,10 @@ from mediaforce.core.db import DBClient from mediaforce.core.db_tables import item_events, library_items, staged_artifacts from mediaforce.encoding.staging import safe_unlink -from mediaforce.web.runtime.archive_cleanup import archive_cleanup_summary FolderGroup = tuple[str, str, str, str] ORIGINALS_REMOVED_EVENT = "originals_removed_confirmed" +HISTORY_DETAIL_MAX_CHARS = 320 @dataclass(slots=True) @@ -67,7 +67,7 @@ def completed_page_payload( "folders": [asdict(folder) for folder in folders], "completed_count": len(folders), "folders_with_backups_count": folders_with_backups_count, - "archive_cleanup": archive_cleanup_summary(config), + "archive_cleanup": completed_archive_cleanup_summary(archive_root, folders), "history": [ asdict(event) for event in list_completed_history_events(connection, folder_group=folder_group) @@ -75,6 +75,17 @@ def completed_page_payload( } +def completed_archive_cleanup_summary(archive_root: Path | None, folders: list[CompletedFolder]) -> dict[str, Any]: + file_count = sum(folder.archived_backup_count for folder in folders) + total_size_bytes = sum(folder.archived_backup_size_bytes for folder in folders) + return { + "archive_root": str(archive_root) if archive_root is not None else "", + "file_count": file_count, + "total_size_bytes": total_size_bytes, + "has_cleanup": file_count > 0, + } + + def list_completed_folders( connection: DBClient, *, @@ -508,7 +519,7 @@ def _history_event_copy(event_type: str, details: dict[str, Any]) -> tuple[str, return ( "Encoding failed", "fail", - _clean_text(details.get("error")) or "Encode failed before promotion.", + _history_detail_summary(details.get("error")) or "Encode failed before promotion.", None, ) if event_type == "validation_completed": @@ -526,6 +537,14 @@ def _path_detail(path: object, fallback: str) -> str: return cleaned or fallback +def _history_detail_summary(value: object, max_chars: int = HISTORY_DETAIL_MAX_CHARS) -> str: + cleaned = _clean_text(value) + if len(cleaned) <= max_chars: + return cleaned + omitted = len(cleaned) - max_chars + return f"{cleaned[:max_chars].rstrip()} ... [{omitted:,} characters omitted]" + + def _bytes_detail(value: object, fallback: str) -> str: parsed = _int_or_none(value) if parsed is None: diff --git a/scripts/smoke-web-routes.mjs b/scripts/smoke-web-routes.mjs index 9697ac8..3067679 100755 --- a/scripts/smoke-web-routes.mjs +++ b/scripts/smoke-web-routes.mjs @@ -310,6 +310,11 @@ async function checkRoutes(baseUrl, routeChecksForBrowser, timeoutMs) { state: "visible", timeout: timeoutMs, }); + await page.waitForFunction( + (expectedMarker) => document.body.innerText.includes(expectedMarker), + marker, + { timeout: timeoutMs }, + ); const state = await page.evaluate((expectedMarker) => { const bodyText = document.body.innerText.trim(); return { @@ -367,6 +372,11 @@ async function checkNarrowRoutes(baseUrl, routeChecksForNarrow, timeoutMs) { state: "visible", timeout: timeoutMs, }); + await page.waitForFunction( + (expectedMarker) => document.body.innerText.includes(expectedMarker), + marker, + { timeout: timeoutMs }, + ); const state = await page.evaluate((expectedMarker) => { const bodyText = document.body.innerText.trim(); const visibleWideTables = Array.from(document.querySelectorAll("table")) diff --git a/tests/test_encode_queue_recovery.py b/tests/test_encode_queue_recovery.py index 19dea70..a42a134 100644 --- a/tests/test_encode_queue_recovery.py +++ b/tests/test_encode_queue_recovery.py @@ -3337,6 +3337,29 @@ def test_folder_studio_series_context_and_encode_candidate_count_follow_manifest self.assertEqual(web_app._folder_encode_candidate_count(connection, self.config, "tv/show/Season 2"), 0) self.assertEqual(web_app._folder_encode_candidate_count(connection, self.config, "tv/show"), 1) + def test_folder_encode_candidate_count_counts_sql_eligible_items_only(self) -> None: + candidate = self._create_source_file("candidate.mkv") + staged = self._create_source_file("staged.mkv") + sibling = self._create_source_file("sibling.mkv") + + with open_db(self.config.paths.db_path) as connection: + candidate_id = self._insert_library_item(connection, candidate, status="validated") + staged_id = self._insert_library_item(connection, staged, status="validated") + sibling_id = self._insert_library_item(connection, sibling, status="planned") + for item_id, rel_path, parent_dir in ( + (candidate_id, "tv/show_1/Season 1/candidate.mkv", "tv/show_1/Season 1"), + (staged_id, "tv/show_1/Season 1/staged.mkv", "tv/show_1/Season 1"), + (sibling_id, "tv/show-special/Season 1/sibling.mkv", "tv/show-special/Season 1"), + ): + connection.execute( + update(library_items) + .where(library_items.c.id == item_id) + .values(rel_path=rel_path, parent_dir=parent_dir) + ) + self._insert_staged_artifact(connection, staged_id, self._staging_path("staged.mkv")) + + self.assertEqual(web_app._folder_encode_candidate_count(connection, self.config, "tv/show_1"), 1) + def test_folder_cards_projected_reclaim_uses_known_validated_savings(self) -> None: validated = self._create_source_file("episode-validated.mkv") diff --git a/tests/test_tuning_runtime.py b/tests/test_tuning_runtime.py index 824bf13..21a9980 100644 --- a/tests/test_tuning_runtime.py +++ b/tests/test_tuning_runtime.py @@ -1120,6 +1120,26 @@ def test_completed_page_payload_groups_promoted_folders_and_active_backups(self) self.assertEqual(second_folder["cleanup_state"], "unknown") self.assertEqual(second_folder["missing_backup_count"], 1) + def test_completed_page_payload_uses_folder_backup_counts_for_archive_summary(self) -> None: + from mediaforce.web import app as web_app + + self._insert_promoted_artifact( + rel_path="tv/Example Show/Season 1/Episode 01.mkv", + promoted_at="2026-04-10T10:00:00+00:00", + archived_size_bytes=3, + bytes_saved=12, + ) + unrelated_archive = self.config.archive_root / "unrelated" / "orphan.mkv" + unrelated_archive.parent.mkdir(parents=True, exist_ok=True) + unrelated_archive.write_text("not part of promoted state") + + with open_db(self.config.paths.db_path) as connection: + payload = completed_page_payload(self.config, connection, folder_group=web_app._folder_group) + + self.assertEqual(payload["archive_cleanup"]["file_count"], 1) + self.assertEqual(payload["archive_cleanup"]["total_size_bytes"], 3) + self.assertTrue(payload["archive_cleanup"]["has_cleanup"]) + def test_completed_page_payload_ignores_archived_paths_outside_active_archive_root(self) -> None: from mediaforce.web import app as web_app @@ -1141,7 +1161,7 @@ def test_completed_page_payload_ignores_archived_paths_outside_active_archive_ro connection.commit() payload = completed_page_payload(self.config, connection, folder_group=web_app._folder_group) - self.assertEqual(payload["archive_cleanup"]["file_count"], 1) + self.assertEqual(payload["archive_cleanup"]["file_count"], 0) self.assertEqual(payload["folders"][0]["archived_backup_count"], 0) self.assertEqual(payload["folders"][0]["cleanup_state"], "blocked") self.assertEqual(payload["folders"][0]["outside_archive_root_count"], 1) @@ -1172,6 +1192,31 @@ def test_completed_page_payload_includes_recent_history_events(self) -> None: self.assertEqual(payload["history"][0]["prefix"], "tv/Example Show/Season 1") self.assertEqual(payload["history"][0]["tone"], "ready") + def test_completed_page_payload_truncates_large_failed_history_details(self) -> None: + from mediaforce.web import app as web_app + + self._insert_promoted_artifact( + rel_path="tv/Example Show/Season 1/Episode 01.mkv", + promoted_at="2026-04-10T10:00:00+00:00", + archived_size_bytes=3, + bytes_saved=12, + ) + with open_db(self.config.paths.db_path) as connection: + library_item_id = connection.execute(select(library_items.c.id)).scalar_one() + connection.execute( + item_events.insert().values( + library_item_id=library_item_id, + created_at="2026-04-10T10:01:00+00:00", + event_type="encoding_failed", + details_json=json.dumps({"error": "x" * 1200}), + ) + ) + connection.commit() + payload = completed_page_payload(self.config, connection, folder_group=web_app._folder_group) + + self.assertLessEqual(len(payload["history"][0]["detail"]), 380) + self.assertIn("characters omitted", payload["history"][0]["detail"]) + def test_completed_page_payload_tolerates_missing_archive_root(self) -> None: from mediaforce.web import app as web_app