mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-05-07 01:17:18 +00:00
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:
commit
89dd7c3643
11 changed files with 637 additions and 3 deletions
22
app/api/batches/[id]/cancel/route.js
Normal file
22
app/api/batches/[id]/cancel/route.js
Normal 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 });
|
||||
}
|
||||
67
app/api/batches/[id]/export/route.js
Normal file
67
app/api/batches/[id]/export/route.js
Normal 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';
|
||||
}
|
||||
13
app/api/batches/[id]/pause/route.js
Normal file
13
app/api/batches/[id]/pause/route.js
Normal 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 });
|
||||
}
|
||||
13
app/api/batches/[id]/resume/route.js
Normal file
13
app/api/batches/[id]/resume/route.js
Normal 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 });
|
||||
}
|
||||
62
app/api/batches/[id]/simulate/route.js
Normal file
62
app/api/batches/[id]/simulate/route.js
Normal 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,
|
||||
});
|
||||
}
|
||||
24
app/api/batches/[id]/start/route.js
Normal file
24
app/api/batches/[id]/start/route.js
Normal 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 });
|
||||
}
|
||||
20
app/api/jobs/[id]/retry/route.js
Normal file
20
app/api/jobs/[id]/retry/route.js
Normal 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
10
app/batch/[id]/page.js
Normal 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} />;
|
||||
}
|
||||
354
components/batch/BatchDetail.jsx
Normal file
354
components/batch/BatchDetail.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
components/batch/BatchDetailShell.jsx
Normal file
41
components/batch/BatchDetailShell.jsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue