Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions frontend/src/lib/components/workstation/HomeWorkbenchView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -531,11 +535,11 @@
</div>
<div>
<span>Open work</span>
<strong>{totalPendingItems(visibleFolders).toLocaleString('en-US')}</strong>
<strong>{visiblePendingItems.toLocaleString('en-US')}</strong>
</div>
<div>
<span>Projected reclaim</span>
<strong>{formatBytes(totalProjectedReclaim(visibleFolders))}</strong>
<strong>{formatBytes(visibleProjectedReclaim)}</strong>
</div>
</div>
</div>
Expand Down
129 changes: 129 additions & 0 deletions frontend/src/lib/components/workstation/RouteLoadingView.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<script lang="ts">
import OperatorShell, { type ShellRouteId } from './OperatorShell.svelte';

let {
route,
subject,
crumb,
message = 'Loading workstation data',
error = ''
}: {
route: ShellRouteId;
subject: string;
crumb: string;
message?: string;
error?: string;
} = $props();
</script>

<OperatorShell
{route}
{subject}
{crumb}
statusTiles={[
{
label: 'Route',
value: error ? 'Issue' : 'Loading',
detail: error || 'Fetching current payload',
tone: error ? 'fail' : 'active'
},
{
label: 'UI',
value: 'Ready',
detail: 'Navigation accepted',
tone: 'ready'
},
{
label: 'Data',
value: error ? 'Failed' : 'Pending',
detail: error ? 'Showing error state' : 'Waiting for API response',
tone: error ? 'fail' : 'wait'
}
]}
footerSignals={[{ label: 'Navigation', value: message, tone: error ? 'fail' : 'active' }]}
>
<main class="route-loading-workspace" aria-busy={!error} aria-live="polite">
<section class="route-loading-panel">
<div>
<span>{subject}</span>
<h1>{error ? 'Route data failed' : message}</h1>
<p>{error || 'The route is visible while Mediaforce waits on the API.'}</p>
</div>
{#if !error}
<div class="meter" aria-hidden="true"><i></i></div>
{/if}
</section>
</main>
</OperatorShell>

<style>
.route-loading-workspace {
display: grid;
padding: var(--mf-space-6);
}

.route-loading-panel {
align-items: end;
background: var(--mf-bg-panel);
border: var(--mf-border);
display: grid;
gap: var(--mf-space-6);
grid-template-columns: minmax(0, 1fr) minmax(160px, 280px);
min-height: 180px;
padding: var(--mf-space-6);
}

span {
color: var(--mf-active-fg-bright);
font-family: var(--mf-font-mono), monospace;
font-size: var(--mf-text-xs);
text-transform: uppercase;
}

h1,
p {
margin: 0;
}

h1 {
font-size: var(--mf-text-xl);
line-height: var(--mf-leading-tight);
margin-top: var(--mf-space-3);
}

p {
color: var(--mf-fg-secondary);
margin-top: var(--mf-space-3);
}

.meter {
background: var(--mf-bg-strip);
border: var(--mf-border);
height: 10px;
overflow: hidden;
}

.meter i {
animation: loading-meter 1.1s var(--mf-ease) infinite;
background: var(--mf-active-fg);
display: block;
height: 100%;
width: 38%;
}

@keyframes loading-meter {
0% {
transform: translateX(-100%);
}

100% {
transform: translateX(280%);
}
}

@media (max-width: 760px) {
.route-loading-panel {
grid-template-columns: 1fr;
}
}
</style>
5 changes: 3 additions & 2 deletions frontend/src/lib/components/workstation/queue-workstation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
},
{
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<script lang="ts">
import { navigating } from '$app/stores';
import favicon from '$lib/assets/favicon.svg';
import '$lib/design/tokens.css';

let { children } = $props();
const routeLoading = $derived(Boolean($navigating?.to));
</script>

<svelte:head>
Expand All @@ -11,3 +13,60 @@
</svelte:head>

{@render children()}

{#if routeLoading}
<div class="route-loading" role="status" aria-live="polite" aria-label="Loading route data">
<span class="route-loading__bar" aria-hidden="true"></span>
<span>Loading workstation data</span>
</div>
{/if}

<style>
.route-loading {
align-items: center;
background: var(--mf-bg-shell);
border: var(--mf-border-strong);
bottom: var(--mf-space-5);
box-shadow: var(--mf-shadow-popover);
color: var(--mf-fg-primary);
display: inline-flex;
font-family: var(--mf-font-mono), monospace;
font-size: var(--mf-text-xs);
gap: var(--mf-space-4);
left: var(--mf-space-5);
min-height: 32px;
padding: 0 var(--mf-space-5);
position: fixed;
text-transform: uppercase;
z-index: 100;
}

.route-loading__bar {
background: var(--mf-active-fg);
display: inline-block;
height: 14px;
position: relative;
width: 4px;
}

.route-loading__bar::after {
animation: route-loading-pulse 900ms var(--mf-ease) infinite;
background: var(--mf-active-fg-bright);
content: '';
height: 14px;
left: 8px;
position: absolute;
top: 0;
width: 4px;
}

@keyframes route-loading-pulse {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
</style>
41 changes: 35 additions & 6 deletions frontend/src/routes/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,15 +1,44 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchJson } from '$lib/api/client';
import type {
DashboardFoldersPayload,
DashboardSummaryPayload,
HostsPayload
} from '$lib/api/types';
import HomeWorkbenchView from '$lib/components/workstation/HomeWorkbenchView.svelte';
import RouteLoadingView from '$lib/components/workstation/RouteLoadingView.svelte';

let { data } = $props();
let dashboard = $state<DashboardSummaryPayload | null>(null);
let foldersPayload = $state<DashboardFoldersPayload | null>(null);
let hosts = $state<HostsPayload | null>(null);
let loadError = $state('');

onMount(async () => {
try {
const [dashboardPayload, hostsPayload] = await Promise.all([
fetchJson<DashboardSummaryPayload>('/api/dashboard'),
fetchJson<HostsPayload>('/api/hosts?compact=1')
]);
dashboard = dashboardPayload;
hosts = hostsPayload;
foldersPayload = {
folders: dashboardPayload.folders_preview,
folder_cache_key: dashboardPayload.folder_cache_key,
catalog_empty: dashboardPayload.catalog_empty
};
} catch (error) {
loadError = error instanceof Error ? error.message : 'Work route failed to load.';
}
});
</script>

<svelte:head>
<title>Mediaforce Work</title>
</svelte:head>

<HomeWorkbenchView
dashboard={data.dashboard}
foldersPayload={data.foldersPayload}
hosts={data.hosts}
/>
{#if dashboard && foldersPayload && hosts}
<HomeWorkbenchView {dashboard} {foldersPayload} {hosts} />
{:else}
<RouteLoadingView route="queue" subject="Work" crumb="/" error={loadError} />
{/if}
21 changes: 2 additions & 19 deletions frontend/src/routes/+page.ts
Original file line number Diff line number Diff line change
@@ -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<DashboardSummaryPayload>('/api/dashboard', fetch),
fetchJson<HostsPayload>('/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 {};
}
21 changes: 19 additions & 2 deletions frontend/src/routes/completed/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchJson } from '$lib/api/client';
import type { CompletedPayload } from '$lib/api/types';
import CompletedWorkstationView from '$lib/components/workstation/CompletedWorkstationView.svelte';
import RouteLoadingView from '$lib/components/workstation/RouteLoadingView.svelte';

let { data } = $props();
let completed = $state<CompletedPayload | null>(null);
let loadError = $state('');

onMount(async () => {
try {
completed = await fetchJson<CompletedPayload>('/api/completed');
} catch (error) {
loadError = error instanceof Error ? error.message : 'Completed route failed to load.';
}
});
</script>

<svelte:head>
<title>Mediaforce Completed</title>
</svelte:head>

<CompletedWorkstationView completed={data.completed} loadError={data.loadError} />
{#if completed}
<CompletedWorkstationView {completed} loadError={null} />
{:else}
<RouteLoadingView route="completed" subject="Completed" crumb="/completed" error={loadError} />
{/if}
26 changes: 2 additions & 24 deletions frontend/src/routes/completed/+page.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,3 @@
import { fetchJson } from '$lib/api/client';
import type { CompletedPayload } from '$lib/api/types';

type SettledPayload<T> = { data: T; error: null } | { data: null; error: string };

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Request failed.';
}

async function settle<T>(promise: Promise<T>): Promise<SettledPayload<T>> {
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<CompletedPayload>('/api/completed', fetch));

return {
completed: completed.data,
loadError: completed.error
};
export function load() {
return {};
}
Loading