Merge pull request #7 from Ahoum-Dev/feat/batch-progress-ui

feat(batch): progress UI, controls, retry, CSV export, demo simulator [slice 5/5 UI]
This commit is contained in:
Anurag Pappula 2026-04-23 09:15:28 +05:30 committed by GitHub
commit 89dd7c3643
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 637 additions and 3 deletions

View file

@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function POST(_request, { params }) {
const { id } = await params;
const batch = await prisma.batch.findUnique({ where: { id } });
if (!batch) return NextResponse.json({ error: 'Batch not found' }, { status: 404 });
if (['completed', 'cancelled'].includes(batch.status)) {
return NextResponse.json({ error: `Batch already ${batch.status}` }, { status: 409 });
}
await prisma.$transaction([
prisma.job.updateMany({
where: { batchId: id, status: { in: ['queued', 'draft'] } },
data: { status: 'cancelled' },
}),
prisma.batch.update({ where: { id }, data: { status: 'cancelled' } }),
]);
const updated = await prisma.batch.findUnique({ where: { id } });
return NextResponse.json({ batch: updated });
}

View file

@ -0,0 +1,67 @@
import prisma from '@/lib/prisma';
const COLUMNS = [
'Row',
'Practice Name',
'Trainer',
'Studio',
'Status',
'Video URL',
'Error',
'Retries',
];
export async function GET(_request, { params }) {
const { id } = await params;
const batch = await prisma.batch.findUnique({
where: { id },
include: {
jobs: {
orderBy: { rowIndex: 'asc' },
include: {
trainer: { select: { name: true, csvLabel: true } },
studio: { select: { name: true, csvLabel: true } },
},
},
},
});
if (!batch) {
return new Response('Batch not found', { status: 404 });
}
const lines = [COLUMNS.join(',')];
for (const job of batch.jobs) {
const cells = [
job.rowIndex + 1,
job.practiceName,
job.trainer ? `${job.trainer.name}${job.trainer.csvLabel ? ` (${job.trainer.csvLabel})` : ''}` : '',
job.studio ? `${job.studio.name}${job.studio.csvLabel ? ` (${job.studio.csvLabel})` : ''}` : '',
job.status,
job.videoUrl || '',
job.error || '',
job.retries,
];
lines.push(cells.map(csvCell).join(','));
}
const filename = `${slugify(batch.name)}-results.csv`;
return new Response(lines.join('\n'), {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
}
function csvCell(value) {
const s = String(value ?? '');
if (s.includes('"') || s.includes(',') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
function slugify(s) {
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'batch';
}

View file

@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function POST(_request, { params }) {
const { id } = await params;
const batch = await prisma.batch.findUnique({ where: { id } });
if (!batch) return NextResponse.json({ error: 'Batch not found' }, { status: 404 });
if (batch.status !== 'running') {
return NextResponse.json({ error: `Can only pause running batches (was "${batch.status}")` }, { status: 409 });
}
const updated = await prisma.batch.update({ where: { id }, data: { status: 'paused' } });
return NextResponse.json({ batch: updated });
}

View file

@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function POST(_request, { params }) {
const { id } = await params;
const batch = await prisma.batch.findUnique({ where: { id } });
if (!batch) return NextResponse.json({ error: 'Batch not found' }, { status: 404 });
if (batch.status !== 'paused') {
return NextResponse.json({ error: `Can only resume paused batches (was "${batch.status}")` }, { status: 409 });
}
const updated = await prisma.batch.update({ where: { id }, data: { status: 'running' } });
return NextResponse.json({ batch: updated });
}

View file

@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
const SAMPLE_VIDEO = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4';
// Demo-only endpoint: marks a few queued jobs as 'done' (with a placeholder
// video URL) and a smaller number as 'failed', so the progress UI shows
// realistic state during a stakeholder demo without needing the real worker.
export async function POST(_request, { params }) {
const { id } = await params;
const batch = await prisma.batch.findUnique({ where: { id } });
if (!batch) return NextResponse.json({ error: 'Batch not found' }, { status: 404 });
const queued = await prisma.job.findMany({
where: { batchId: id, status: 'queued' },
orderBy: { rowIndex: 'asc' },
take: Math.max(1, Math.ceil(batch.total * 0.1)),
select: { id: true },
});
if (queued.length === 0) {
return NextResponse.json({ message: 'Nothing to simulate — no queued jobs.', batch });
}
const failCount = Math.max(0, Math.floor(queued.length * 0.1));
const doneIds = queued.slice(failCount).map((j) => j.id);
const failIds = queued.slice(0, failCount).map((j) => j.id);
await prisma.$transaction([
prisma.job.updateMany({
where: { id: { in: doneIds } },
data: {
status: 'done',
videoUrl: SAMPLE_VIDEO,
completedAt: new Date(),
},
}),
prisma.job.updateMany({
where: { id: { in: failIds } },
data: {
status: 'failed',
error: 'Simulated failure for demo. Click Retry to requeue.',
completedAt: new Date(),
},
}),
prisma.batch.update({
where: { id },
data: {
done: { increment: doneIds.length },
failed: { increment: failIds.length },
},
}),
]);
const updated = await prisma.batch.findUnique({ where: { id } });
return NextResponse.json({
advanced: doneIds.length + failIds.length,
done: doneIds.length,
failed: failIds.length,
batch: updated,
});
}

View file

@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function POST(_request, { params }) {
const { id } = await params;
const batch = await prisma.batch.findUnique({ where: { id } });
if (!batch) return NextResponse.json({ error: 'Batch not found' }, { status: 404 });
if (batch.status === 'running') return NextResponse.json({ batch });
if (!['draft', 'paused'].includes(batch.status)) {
return NextResponse.json({ error: `Cannot start batch in status "${batch.status}"` }, { status: 409 });
}
await prisma.$transaction([
prisma.job.updateMany({
where: { batchId: id, status: { in: ['draft', 'cancelled'] } },
data: { status: 'queued' },
}),
prisma.batch.update({ where: { id }, data: { status: 'running' } }),
]);
const updated = await prisma.batch.findUnique({ where: { id } });
return NextResponse.json({ batch: updated });
}

View file

@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function POST(_request, { params }) {
const { id } = await params;
const job = await prisma.job.findUnique({ where: { id } });
if (!job) return NextResponse.json({ error: 'Job not found' }, { status: 404 });
const updated = await prisma.job.update({
where: { id },
data: {
status: 'queued',
retries: 0,
error: null,
nextAttemptAt: null,
muapiRequestId: null,
},
});
return NextResponse.json({ job: updated });
}

10
app/batch/[id]/page.js Normal file
View file

@ -0,0 +1,10 @@
import BatchDetailShell from '@/components/batch/BatchDetailShell';
export const metadata = {
title: 'Batch detail — Open Generative AI',
};
export default async function BatchDetailPage({ params }) {
const { id } = await params;
return <BatchDetailShell batchId={id} />;
}

View file

@ -0,0 +1,354 @@
'use client';
import { useEffect, useState, useCallback, useMemo } from 'react';
const STATUS_STYLES = {
draft: 'bg-white/5 text-white/60 border-white/10',
queued: 'bg-white/5 text-white/40 border-white/10',
submitting: 'bg-blue-500/10 text-blue-300 border-blue-500/30',
polling: 'bg-blue-500/10 text-blue-300 border-blue-500/30',
done: 'bg-[#d9ff00]/10 text-[#d9ff00] border-[#d9ff00]/30',
failed: 'bg-red-500/10 text-red-300 border-red-500/30',
cancelled: 'bg-white/5 text-white/30 border-white/10',
paused: 'bg-yellow-500/10 text-yellow-300 border-yellow-500/30',
running: 'bg-blue-500/10 text-blue-300 border-blue-500/30',
completed: 'bg-[#d9ff00]/10 text-[#d9ff00] border-[#d9ff00]/30',
};
export default function BatchDetail({ batchId, apiKey }) {
const [batch, setBatch] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [busyAction, setBusyAction] = useState(null);
const [filter, setFilter] = useState('all'); // all|done|failed|pending
const [previewUrl, setPreviewUrl] = useState(null);
const refresh = useCallback(async () => {
try {
const res = await fetch(`/api/batches/${batchId}`, { headers: { 'x-api-key': apiKey } });
if (!res.ok) throw new Error(`GET batch failed: ${res.status}`);
const data = await res.json();
setBatch(data.batch);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [batchId, apiKey]);
useEffect(() => { refresh(); }, [refresh]);
// Poll while a batch is actively progressing.
useEffect(() => {
if (!batch) return undefined;
if (!['running', 'paused'].includes(batch.status)) return undefined;
const t = setInterval(refresh, 3000);
return () => clearInterval(t);
}, [batch, refresh]);
const action = async (path, label) => {
setBusyAction(label);
try {
const res = await fetch(`/api/batches/${batchId}${path}`, {
method: 'POST',
headers: { 'x-api-key': apiKey },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `${label} failed: ${res.status}`);
await refresh();
} catch (err) {
window.alert(err.message);
} finally {
setBusyAction(null);
}
};
const retryJob = async (jobId) => {
setBusyAction(`retry-${jobId}`);
try {
const res = await fetch(`/api/jobs/${jobId}/retry`, {
method: 'POST',
headers: { 'x-api-key': apiKey },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Retry failed: ${res.status}`);
await refresh();
} catch (err) {
window.alert(err.message);
} finally {
setBusyAction(null);
}
};
const filteredJobs = useMemo(() => {
if (!batch?.jobs) return [];
if (filter === 'done') return batch.jobs.filter((j) => j.status === 'done');
if (filter === 'failed') return batch.jobs.filter((j) => j.status === 'failed');
if (filter === 'pending') return batch.jobs.filter((j) => !['done', 'failed', 'cancelled'].includes(j.status));
return batch.jobs;
}, [batch, filter]);
if (loading && !batch) {
return (
<div className="min-h-screen bg-[#030303] text-white flex items-center justify-center">
<div className="animate-spin text-[#d9ff00] text-3xl"></div>
</div>
);
}
if (error && !batch) {
return (
<div className="min-h-screen bg-[#030303] text-white flex items-center justify-center">
<div className="bg-red-500/10 border border-red-500/30 text-red-300 px-6 py-4 rounded-md max-w-md">{error}</div>
</div>
);
}
if (!batch) return null;
const pct = batch.total > 0 ? Math.min(100, Math.round((batch.done / batch.total) * 100)) : 0;
const failedCount = batch.failed;
const inflightCount = batch.jobs.filter((j) => ['submitting', 'polling'].includes(j.status)).length;
const queuedCount = batch.jobs.filter((j) => j.status === 'queued').length;
return (
<div className="min-h-screen bg-[#030303] text-white flex flex-col">
{/* Top header */}
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md">
<div className="flex items-center gap-3">
<a href="/batch" className="text-white/40 hover:text-white text-[12px]"> Back to batches</a>
<span className="text-white/20">/</span>
<span className="text-sm font-bold tracking-tight">{batch.name}</span>
<StatusPill status={batch.status} />
</div>
<div className="flex items-center gap-2">
<a
href={`/api/batches/${batchId}/export`}
className="text-[12px] text-white/60 hover:text-white px-3 py-1.5 rounded-md border border-white/[0.04] bg-white/[0.02] hover:bg-white/5 transition-all"
>
Download CSV
</a>
</div>
</header>
<main className="flex-1 overflow-y-auto px-6 py-6">
<div className="max-w-6xl mx-auto space-y-6">
{/* Progress card */}
<section className="bg-[#0a0a0a] border border-white/[0.04] rounded-md p-6">
<div className="flex items-end justify-between mb-3">
<div>
<p className="text-white/40 text-[11px] uppercase tracking-wide">Progress</p>
<p className="text-3xl font-bold mt-1">
{batch.done}<span className="text-white/30 text-base"> / {batch.total}</span>
</p>
<p className="text-[12px] text-white/50 mt-1">
{failedCount > 0 && <span className="text-red-300">{failedCount} failed · </span>}
{inflightCount > 0 && <span className="text-blue-300">{inflightCount} running · </span>}
{queuedCount} queued
</p>
</div>
<ControlButtons batch={batch} busyAction={busyAction} action={action} />
</div>
<div className="h-2 bg-white/5 rounded-full overflow-hidden">
<div
className="h-full bg-[#d9ff00] transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-center justify-between mt-2">
<p className="text-[11px] text-white/40">{pct}% complete</p>
{['running', 'paused'].includes(batch.status) && (
<p className="text-[10px] text-white/30 flex items-center gap-1">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-[#d9ff00] animate-pulse" /> auto-refresh every 3s
</p>
)}
</div>
</section>
{/* Settings summary */}
<section className="grid grid-cols-2 md:grid-cols-5 gap-3">
<Mini label="Model" value={batch.model} />
<Mini label="Duration" value={`${batch.duration}s`} />
<Mini label="Quality" value={batch.quality} />
<Mini label="Aspect" value={batch.aspectRatio} />
<Mini label="Concurrency" value={`${batch.concurrency}×`} />
</section>
{/* Filters */}
<section className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-1 bg-white/[0.02] border border-white/[0.04] rounded-md p-0.5">
{[
{ id: 'all', label: `All (${batch.jobs.length})` },
{ id: 'pending', label: `Pending (${queuedCount + inflightCount})` },
{ id: 'done', label: `Done (${batch.done})` },
{ id: 'failed', label: `Failed (${failedCount})` },
].map((f) => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={`px-3 py-1.5 rounded text-[11px] font-medium transition-colors ${
filter === f.id ? 'bg-white/10 text-white' : 'text-white/50 hover:text-white/80'
}`}
>
{f.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => action('/simulate', 'simulate')}
disabled={busyAction === 'simulate' || !['running', 'paused', 'draft'].includes(batch.status)}
className="bg-white/5 border border-white/[0.04] text-white/70 hover:bg-white/10 text-[11px] px-3 py-1.5 rounded-md transition-all disabled:opacity-30"
title="Demo helper — marks ~10% of queued jobs as done with a placeholder video URL"
>
{busyAction === 'simulate' ? 'Simulating…' : '⚡ Simulate progress (demo)'}
</button>
</div>
</section>
{/* Jobs table */}
<section className="border border-white/[0.04] rounded-md overflow-hidden">
<table className="w-full text-[12px]">
<thead className="bg-white/[0.02] text-white/40 text-[11px] uppercase tracking-wide">
<tr>
<th className="text-left px-3 py-2 w-10">#</th>
<th className="text-left px-3 py-2">Practice</th>
<th className="text-left px-3 py-2 w-32">Trainer</th>
<th className="text-left px-3 py-2 w-28">Studio</th>
<th className="text-left px-3 py-2 w-28">Status</th>
<th className="text-left px-3 py-2 w-32">Result</th>
<th className="text-right px-3 py-2 w-24">Action</th>
</tr>
</thead>
<tbody>
{filteredJobs.map((job) => (
<tr key={job.id} className="border-t border-white/[0.03] hover:bg-white/[0.02]">
<td className="px-3 py-2 text-white/40">{job.rowIndex + 1}</td>
<td className="px-3 py-2 text-white/90">
<div className="font-medium">{job.practiceName}</div>
{job.error && <div className="text-red-400/80 text-[11px] mt-0.5 line-clamp-1">{job.error}</div>}
</td>
<td className="px-3 py-2 text-white/60 text-[11px]">{job.trainer?.name || '—'}</td>
<td className="px-3 py-2 text-white/60 text-[11px]">{job.studio?.name || '—'}</td>
<td className="px-3 py-2"><StatusPill status={job.status} /></td>
<td className="px-3 py-2">
{job.videoUrl ? (
<button
onClick={() => setPreviewUrl(job.videoUrl)}
className="text-[#d9ff00] hover:underline text-[11px]"
>
Play
</button>
) : (
<span className="text-white/20"></span>
)}
</td>
<td className="px-3 py-2 text-right">
{job.status === 'failed' && (
<button
onClick={() => retryJob(job.id)}
disabled={busyAction === `retry-${job.id}`}
className="bg-white/5 border border-white/[0.04] text-white/70 hover:bg-white/10 text-[10px] px-2 py-1 rounded transition-all"
>
{busyAction === `retry-${job.id}` ? '…' : 'Retry'}
</button>
)}
</td>
</tr>
))}
{filteredJobs.length === 0 && (
<tr><td colSpan={7} className="px-3 py-8 text-center text-white/30 text-[12px]">No jobs in this filter.</td></tr>
)}
</tbody>
</table>
</section>
</div>
</main>
{previewUrl && (
<div
className="fixed inset-0 bg-black/80 backdrop-blur-md flex items-center justify-center z-50 p-6"
onClick={() => setPreviewUrl(null)}
>
<div className="bg-black border border-white/10 rounded-md overflow-hidden max-w-3xl w-full" onClick={(e) => e.stopPropagation()}>
<video src={previewUrl} controls autoPlay className="w-full" />
<div className="px-3 py-2 flex items-center justify-between text-[11px] text-white/40">
<a href={previewUrl} target="_blank" rel="noreferrer" className="hover:text-white truncate">{previewUrl}</a>
<button onClick={() => setPreviewUrl(null)} className="text-white/60 hover:text-white">Close</button>
</div>
</div>
</div>
)}
</div>
);
}
function StatusPill({ status }) {
return (
<span className={`inline-block px-2 py-0.5 rounded-full border text-[10px] uppercase tracking-wide font-semibold ${STATUS_STYLES[status] || STATUS_STYLES.queued}`}>
{status}
</span>
);
}
function Mini({ label, value }) {
return (
<div className="bg-[#0a0a0a] border border-white/[0.04] rounded-md p-3">
<p className="text-[10px] uppercase tracking-wide text-white/40 mb-0.5">{label}</p>
<p className="text-sm font-semibold text-white truncate">{value}</p>
</div>
);
}
function ControlButtons({ batch, busyAction, action }) {
const btn = 'h-9 px-3 rounded-md text-xs font-semibold transition-all disabled:opacity-30 disabled:cursor-not-allowed';
return (
<div className="flex items-center gap-2">
{batch.status === 'draft' && (
<button
onClick={() => action('/start', 'start')}
disabled={busyAction === 'start'}
className={`${btn} bg-[#d9ff00] text-black hover:bg-[#e5ff33]`}
>
{busyAction === 'start' ? 'Starting…' : '▶ Start batch'}
</button>
)}
{batch.status === 'running' && (
<>
<button
onClick={() => action('/pause', 'pause')}
disabled={busyAction === 'pause'}
className={`${btn} bg-yellow-500/10 text-yellow-300 border border-yellow-500/30 hover:bg-yellow-500/20`}
>
{busyAction === 'pause' ? '…' : '⏸ Pause'}
</button>
<button
onClick={() => { if (confirm('Cancel this batch? Queued jobs will stop.')) action('/cancel', 'cancel'); }}
disabled={busyAction === 'cancel'}
className={`${btn} bg-red-500/10 text-red-300 border border-red-500/30 hover:bg-red-500/20`}
>
{busyAction === 'cancel' ? '…' : '✕ Cancel'}
</button>
</>
)}
{batch.status === 'paused' && (
<>
<button
onClick={() => action('/resume', 'resume')}
disabled={busyAction === 'resume'}
className={`${btn} bg-[#d9ff00] text-black hover:bg-[#e5ff33]`}
>
{busyAction === 'resume' ? '…' : '▶ Resume'}
</button>
<button
onClick={() => { if (confirm('Cancel this batch? Queued jobs will stop.')) action('/cancel', 'cancel'); }}
disabled={busyAction === 'cancel'}
className={`${btn} bg-red-500/10 text-red-300 border border-red-500/30 hover:bg-red-500/20`}
>
Cancel
</button>
</>
)}
</div>
);
}

View file

@ -0,0 +1,41 @@
'use client';
import { useEffect, useState } from 'react';
import ApiKeyModal from '@/components/ApiKeyModal';
import BatchDetail from './BatchDetail';
const STORAGE_KEY = 'muapi_key';
export default function BatchDetailShell({ batchId }) {
const [apiKey, setApiKey] = useState(null);
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
const stored = typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null;
if (stored) {
setApiKey(stored);
document.cookie = `muapi_key=${stored}; path=/; max-age=31536000; SameSite=Lax`;
}
}, []);
const handleKeySave = (key) => {
localStorage.setItem(STORAGE_KEY, key);
document.cookie = `muapi_key=${key}; path=/; max-age=31536000; SameSite=Lax`;
setApiKey(key);
};
if (!hasMounted) {
return (
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
<div className="animate-spin text-[#d9ff00] text-3xl"></div>
</div>
);
}
if (!apiKey) {
return <ApiKeyModal onSave={handleKeySave} />;
}
return <BatchDetail batchId={batchId} apiKey={apiKey} />;
}

View file

@ -68,7 +68,11 @@ export default function BatchesTab({ apiKey }) {
</thead>
<tbody>
{batches.map((b) => (
<tr key={b.id} className="border-t border-white/[0.03] hover:bg-white/[0.02]">
<tr
key={b.id}
onClick={() => { window.location.href = `/batch/${b.id}`; }}
className="border-t border-white/[0.03] hover:bg-white/[0.03] cursor-pointer"
>
<td className="px-4 py-3 text-white/90 font-medium">{b.name}</td>
<td className="px-4 py-3"><StatusBadge status={b.status} /></td>
<td className="px-4 py-3 text-white/60 text-[12px]">
@ -89,9 +93,13 @@ export default function BatchesTab({ apiKey }) {
<NewBatchWizard
apiKey={apiKey}
onClose={() => setWizardOpen(false)}
onCreated={async () => {
onCreated={(batch) => {
setWizardOpen(false);
await refresh();
if (batch?.id) {
window.location.href = `/batch/${batch.id}`;
} else {
refresh();
}
}}
/>
)}