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
58 changes: 58 additions & 0 deletions frontend/src/lib/api/placeholders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type {
CompletedPayload,
DashboardFoldersPayload,
DashboardSummaryPayload,
FolderPayload,
FolderStatusPayload,
HostsPayload,
QueueLane
} from './types';
Expand Down Expand Up @@ -46,6 +48,62 @@ export const initialFoldersPayload: DashboardFoldersPayload = {

export const initialHosts: HostsPayload = { compact: true, hosts: [] };

export function initialFolderPayload(prefix: string): FolderPayload {
return {
prefix,
pending: true,
metric_support: { vmaf: false, xpsnr: false, ssim: false },
metric_status_copy: 'loading',
workflow_state: {
prefix,
state: 'loading',
primary_lane: 'none',
label: 'Loading',
tone: 'active',
detail: 'Fetching folder state, worker readiness, and workflow status.',
counts: {},
lane_counts: {},
state_counts: {},
next_action: {
kind: 'review_scope',
label: 'Open folders',
enabled: true,
target_prefix: prefix
},
blockers: []
}
};
}

export function initialFolderStatusPayload(prefix: string): FolderStatusPayload {
return {
prefix,
polling_active: false,
calibration_status: 'loading',
folder_scan_status: 'loading',
calibration_job: null,
folder_scan_job: null,
workflow_state: {
prefix,
state: 'loading',
primary_lane: 'none',
label: 'Loading',
tone: 'active',
detail: 'Fetching folder state, worker readiness, and workflow status.',
counts: {},
lane_counts: {},
state_counts: {},
next_action: {
kind: 'review_scope',
label: 'Open folders',
enabled: true,
target_prefix: prefix
},
blockers: []
}
};
}

export const initialCompleted: CompletedPayload = {
folders: [],
completed_count: 0,
Expand Down
28 changes: 20 additions & 8 deletions frontend/src/lib/components/workstation/FolderStudioView.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
import { postJson } from '$lib/api/client';
import { folderRoutePath, folderRoutePrefix } from '$lib/folder-display';
Expand Down Expand Up @@ -57,11 +56,17 @@
let {
folder,
status,
hosts
hosts,
folderPending = false,
onMutate = async () => {},
loadError = null
}: {
folder: FolderPayload;
status: FolderStatusPayload;
hosts: HostsPayload;
folderPending?: boolean;
onMutate?: () => Promise<void>;
loadError?: string | null;
} = $props();

let benchNote = $state('');
Expand Down Expand Up @@ -136,7 +141,8 @@
calibrationJob,
encodeJob,
reviewPackReady,
approvalReviewReady
approvalReviewReady,
folderPending
)
);
const workflowSteps = $derived(buildWorkflowSteps(workflow));
Expand Down Expand Up @@ -269,7 +275,7 @@
localPendingProposal = null;
localProposalPrefix = prefix;
benchMessage = response.message || 'Queued the sample run from the draft.';
await invalidateAll();
await onMutate();
} catch (error) {
benchError = error instanceof Error ? error.message : 'Sample could not be queued.';
} finally {
Expand Down Expand Up @@ -302,7 +308,7 @@
}
);
profileMessage = response.message || 'Approved the draft and queued the folder encode.';
await invalidateAll();
await onMutate();
} catch (error) {
profileError =
error instanceof Error ? error.message : 'Folder profile could not be approved.';
Expand All @@ -324,7 +330,7 @@
{ prefix }
);
profileMessage = response.message || 'Queued retry for this folder.';
await invalidateAll();
await onMutate();
} catch (error) {
profileError =
error instanceof Error ? error.message : 'Folder processing could not be retried.';
Expand All @@ -347,7 +353,7 @@
{}
);
profileMessage = response.message || workflow.primary;
await invalidateAll();
await onMutate();
} catch (error) {
profileError = error instanceof Error ? error.message : 'Folder action could not run.';
} finally {
Expand All @@ -368,7 +374,7 @@
{}
);
benchMessage = response.message || 'Stopped running and queued sample work.';
await invalidateAll();
await onMutate();
} catch (error) {
benchError = error instanceof Error ? error.message : 'Sample work could not be stopped.';
} finally {
Expand Down Expand Up @@ -435,6 +441,12 @@
</nav>

<section class="decision decision--{workflow.tone}" aria-labelledby="decision-title">
{#if loadError}
<div class="route-error" role="status">
<strong>Folder state could not be refreshed.</strong>
<span>{loadError}</span>
</div>
{/if}
<div class="decision__summary">
<StateBadge tone={workflow.tone} label={workflow.label} />
<h2 id="decision-title">{workflow.title}</h2>
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/lib/components/workstation/folder-studio-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,31 @@ function folderSummary(overrides: Partial<NonNullable<FolderPayload['summary']>>
}

describe('Folder Studio review request mapping', () => {
it('keeps pending route placeholders in a loading workflow state', () => {
const workflow = resolveWorkflow(
folderPayload({ pending: true, summary: undefined }),
folderStatusPayload({ calibration_status: 'loading', folder_scan_status: 'loading' }),
null,
null,
null,
null,
null,
false,
false,
true
);

expect(workflow).toMatchObject({
tone: 'active',
label: 'Loading',
title: 'Loading folder state',
primary: 'Open Folders',
primaryAction: 'open-folders',
secondary: 'Open Ops',
secondaryAction: 'open-ops'
});
});

it('uses only folder-scoped host options and removes empty keys', () => {
const options = buildBenchHostOptions([
{ key: 'sample-host', label: 'Sample host', detail: 'folder match', available: true },
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/lib/components/workstation/folder-studio-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,8 +1515,21 @@ export function resolveWorkflow(
calibrationJob: FolderCalibrationJob | null,
encodeJob: EncodeQueueJob | null,
reviewPackReady = false,
approvalReviewReady = Boolean(calibration?.review_media_ready)
approvalReviewReady = Boolean(calibration?.review_media_ready),
folderPending = false
): WorkflowState {
if (folderPending) {
return {
tone: 'active',
label: 'Loading',
title: 'Loading folder state',
copy: 'Folder metadata, worker readiness, and workflow status are loading. The workspace will hydrate in place when the route data returns.',
primary: 'Open Folders',
primaryAction: 'open-folders',
secondary: 'Open Ops',
secondaryAction: 'open-ops'
};
}
const backendWorkflow = folder.workflow_state ?? status.workflow_state ?? null;
if (backendWorkflow) {
const resolvedBackendWorkflow = resolveBackendWorkflow(folder, backendWorkflow);
Expand Down
133 changes: 122 additions & 11 deletions frontend/src/routes/folders/[...prefix]/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,26 +1,137 @@
<script lang="ts">
import { fetchJson } from '$lib/api/client';
import {
initialDashboard,
initialFolderPayload,
initialFoldersPayload,
initialFolderStatusPayload,
initialHosts
} from '$lib/api/placeholders';
import type {
DashboardFoldersPayload,
DashboardSummaryPayload,
FolderPayload,
FolderStatusPayload,
HostsPayload
} from '$lib/api/types';
import FolderStudioView from '$lib/components/workstation/FolderStudioView.svelte';
import HomeWorkbenchView from '$lib/components/workstation/HomeWorkbenchView.svelte';

let { data } = $props();
let { data }: { data: { mode: 'directory' | 'studio'; prefix: string } } = $props();
const mode = $derived(data.mode);
const prefix = $derived(data.prefix);

let dashboard = $state<DashboardSummaryPayload>(initialDashboard);
let foldersPayload = $state<DashboardFoldersPayload>(initialFoldersPayload);
let folder = $state<FolderPayload>(initialFolderPayload(''));
let status = $state<FolderStatusPayload>(initialFolderStatusPayload(''));
let hosts = $state<HostsPayload>(initialHosts);
let foldersPending = $state(false);
let folderPending = $state(false);
let loadError = $state<string | null>(null);
let hydrationGeneration = 0;

function encodePrefix(prefix: string): string {
return prefix
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
}

async function hydrateDirectory() {
const generation = ++hydrationGeneration;
foldersPending = true;
loadError = null;
try {
const [dashboardPayload, foldersPayloadResult, hostsPayload] = await Promise.all([
fetchJson<DashboardSummaryPayload>('/api/dashboard?preview_limit=0'),
fetchJson<DashboardFoldersPayload>('/api/dashboard/folders'),
fetchJson<HostsPayload>('/api/hosts?compact=1')
]);
if (generation !== hydrationGeneration) return;
dashboard = dashboardPayload;
foldersPayload = foldersPayloadResult;
hosts = hostsPayload;
} catch (error) {
if (generation === hydrationGeneration) {
loadError = error instanceof Error ? error.message : 'Unable to load folder index.';
}
} finally {
if (generation === hydrationGeneration) foldersPending = false;
}
}

async function hydrateStudio(currentPrefix: string) {
const generation = ++hydrationGeneration;
folderPending = true;
loadError = null;
const encodedPrefix = encodePrefix(currentPrefix);
try {
const [folderPayload, statusPayload, hostsPayload] = await Promise.all([
fetchJson<FolderPayload>(`/api/folders/${encodedPrefix}`),
fetchJson<FolderStatusPayload>(`/api/folders/${encodedPrefix}/status`),
fetchJson<HostsPayload>('/api/hosts?compact=1')
]);
if (generation !== hydrationGeneration) return;
folder = folderPayload;
status = statusPayload;
hosts = hostsPayload;
folderPending = false;
} catch (error) {
if (generation === hydrationGeneration) {
loadError = error instanceof Error ? error.message : 'Unable to load folder state.';
}
}
}

async function refreshStudio() {
await hydrateStudio(prefix);
}

$effect(() => {
const currentMode = mode;
const currentPrefix = prefix;
loadError = null;

if (currentMode === 'studio') {
folder = initialFolderPayload(currentPrefix);
status = initialFolderStatusPayload(currentPrefix);
hosts = initialHosts;
void hydrateStudio(currentPrefix);
} else {
dashboard = initialDashboard;
foldersPayload = initialFoldersPayload;
hosts = initialHosts;
void hydrateDirectory();
}

return () => {
hydrationGeneration += 1;
};
});
</script>

<svelte:head>
<title
>{data.mode === 'studio'
? `${data.folder.prefix} · Mediaforce Folder Studio`
: 'Mediaforce Folders'}</title
>
<title>{mode === 'studio' ? `${prefix} · Mediaforce Folder Studio` : 'Mediaforce Folders'}</title>
</svelte:head>

{#if data.mode === 'studio'}
<FolderStudioView folder={data.folder} status={data.status} hosts={data.hosts} />
{#if mode === 'studio'}
<FolderStudioView
{folder}
{status}
{hosts}
{folderPending}
loadError={loadError ?? undefined}
onMutate={refreshStudio}
/>
{:else}
<HomeWorkbenchView
crumb="/folders"
dashboard={data.dashboard}
foldersPayload={data.foldersPayload}
hosts={data.hosts}
{dashboard}
{foldersPayload}
{hosts}
{foldersPending}
loadError={loadError ?? undefined}
mode="folders"
/>
{/if}
Loading