add job_type column, simplify execute page: remove node/command columns, add type badge, make item title clickable
All checks were successful
Build and Push Docker Image / build (push) Successful in 37s
All checks were successful
Build and Push Docker Image / build (push) Successful in 37s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -182,7 +182,7 @@ app.post('/series/:seriesKey/approve-all', (c) => {
|
||||
for (const plan of pending) {
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
}
|
||||
return c.json({ ok: true, count: pending.length });
|
||||
});
|
||||
@@ -201,7 +201,7 @@ app.post('/season/:seriesKey/:season/approve-all', (c) => {
|
||||
for (const plan of pending) {
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
}
|
||||
return c.json({ ok: true, count: pending.length });
|
||||
});
|
||||
@@ -216,7 +216,7 @@ app.post('/approve-all', (c) => {
|
||||
for (const plan of pending) {
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
}
|
||||
return c.json({ ok: true, count: pending.length });
|
||||
});
|
||||
@@ -304,7 +304,7 @@ app.post('/:id/approve', (c) => {
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
if (!plan.is_noop) {
|
||||
const { item, streams, decisions } = loadItemDetail(db, id);
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(id, buildCommand(item, streams, decisions));
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(id, buildCommand(item, streams, decisions));
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -305,7 +305,7 @@ app.post('/extract-all', (c) => {
|
||||
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(item.id) as MediaStream[];
|
||||
const command = buildExtractOnlyCommand(item, streams);
|
||||
if (!command) continue;
|
||||
db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(item.id, command);
|
||||
db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'subtitle', 'pending')").run(item.id, command);
|
||||
queued++;
|
||||
}
|
||||
|
||||
@@ -328,7 +328,7 @@ app.post('/:id/extract', (c) => {
|
||||
const command = buildExtractOnlyCommand(item, streams);
|
||||
if (!command) return c.json({ ok: false, error: 'No subtitles to extract' }, 400);
|
||||
|
||||
db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(id, command);
|
||||
db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'subtitle', 'pending')").run(id, command);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export function getDb(): Database {
|
||||
try { _db.exec('ALTER TABLE review_plans ADD COLUMN subs_extracted INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ }
|
||||
try { _db.exec("ALTER TABLE nodes ADD COLUMN movies_path TEXT NOT NULL DEFAULT ''"); } catch { /* already exists */ }
|
||||
try { _db.exec("ALTER TABLE nodes ADD COLUMN series_path TEXT NOT NULL DEFAULT ''"); } catch { /* already exists */ }
|
||||
try { _db.exec("ALTER TABLE jobs ADD COLUMN job_type TEXT NOT NULL DEFAULT 'audio'"); } catch { /* already exists */ }
|
||||
seedDefaults(_db);
|
||||
return _db;
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
command TEXT NOT NULL,
|
||||
job_type TEXT NOT NULL DEFAULT 'audio',
|
||||
node_id INTEGER REFERENCES nodes(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
output TEXT,
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface Job {
|
||||
id: number;
|
||||
item_id: number;
|
||||
command: string;
|
||||
job_type: 'audio' | 'subtitle';
|
||||
node_id: number | null;
|
||||
status: 'pending' | 'running' | 'done' | 'error';
|
||||
output: string | null;
|
||||
|
||||
@@ -3,12 +3,11 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Select } from '~/shared/components/ui/select';
|
||||
import { FilterTabs } from '~/shared/components/ui/filter-tabs';
|
||||
import type { Job, Node, MediaItem } from '~/shared/lib/types';
|
||||
import type { Job, MediaItem } from '~/shared/lib/types';
|
||||
|
||||
interface JobEntry { job: Job; item: MediaItem | null; node: Node | null; }
|
||||
interface ExecuteData { jobs: JobEntry[]; nodes: Node[]; filter: string; totalCounts: Record<string, number>; }
|
||||
interface JobEntry { job: Job; item: MediaItem | null; }
|
||||
interface ExecuteData { jobs: JobEntry[]; filter: string; totalCounts: Record<string, number>; }
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
@@ -26,6 +25,10 @@ function itemName(job: Job, item: MediaItem | null): string {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
function jobTypeLabel(job: Job): string {
|
||||
return job.job_type === 'subtitle' ? 'ST Extract' : 'Audio Mod';
|
||||
}
|
||||
|
||||
// Module-level cache for instant tab switching
|
||||
const cache = new Map<string, ExecuteData>();
|
||||
|
||||
@@ -36,6 +39,7 @@ export function ExecutePage() {
|
||||
const [loading, setLoading] = useState(!cache.has(filter));
|
||||
const [logs, setLogs] = useState<Map<number, string>>(new Map());
|
||||
const [logVisible, setLogVisible] = useState<Set<number>>(new Set());
|
||||
const [cmdVisible, setCmdVisible] = useState<Set<number>>(new Set());
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -108,20 +112,15 @@ export function ExecutePage() {
|
||||
const clearCompleted = async () => { await api.post('/api/execute/clear-completed'); cache.clear(); load(); };
|
||||
const runJob = async (id: number) => { await api.post(`/api/execute/job/${id}/run`); cache.clear(); load(); };
|
||||
const cancelJob = async (id: number) => { await api.post(`/api/execute/job/${id}/cancel`); cache.clear(); load(); };
|
||||
const assignNode = async (jobId: number, nodeId: number | null) => {
|
||||
await api.post(`/api/execute/job/${jobId}/assign`, { node_id: nodeId });
|
||||
cache.clear();
|
||||
load();
|
||||
};
|
||||
|
||||
const toggleLog = (id: number) => setLogVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||
const toggleCmd = (id: number) => setCmdVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||
|
||||
const totalCounts = data?.totalCounts ?? { all: 0, pending: 0, running: 0, done: 0, error: 0 };
|
||||
const pending = totalCounts.pending ?? 0;
|
||||
const done = totalCounts.done ?? 0;
|
||||
const errors = totalCounts.error ?? 0;
|
||||
const jobs = data?.jobs ?? [];
|
||||
const nodes = data?.nodes ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -153,51 +152,31 @@ export function ExecutePage() {
|
||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0"><table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
{['#', 'Item', 'Command', 'Node', 'Status', 'Actions'].map((h) => (
|
||||
{['#', 'Item', 'Type', 'Status', 'Actions'].map((h) => (
|
||||
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(({ job, item, node }) => {
|
||||
{jobs.map(({ job, item }: JobEntry) => {
|
||||
const name = itemName(job, item);
|
||||
const cmdShort = job.command.length > 80 ? job.command.slice(0, 77) + '…' : job.command;
|
||||
const jobLog = logs.get(job.id) ?? job.output ?? '';
|
||||
const showLog = logVisible.has(job.id) || job.status === 'running' || job.status === 'error';
|
||||
const showCmd = cmdVisible.has(job.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr key={job.id} className="hover:bg-gray-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{job.id}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<div className="truncate max-w-[200px]" title={name}>{name}</div>
|
||||
{item && (
|
||||
<div className="text-[0.72rem] mt-0.5">
|
||||
<Link to="/review/audio/$id" params={{ id: String(item.id) }} className="text-gray-400 no-underline hover:underline">← Details</Link>
|
||||
</div>
|
||||
)}
|
||||
{item?.file_path && (
|
||||
<div className="font-mono text-gray-400 text-[0.72rem] truncate max-w-[200px] mt-0.5" title={item.file_path}>
|
||||
{item.file_path.split('/').pop()}
|
||||
</div>
|
||||
)}
|
||||
<div className="truncate max-w-[300px]" title={name}>
|
||||
{item ? (
|
||||
<Link to="/review/audio/$id" params={{ id: String(item.id) }} className="text-inherit no-underline hover:underline">{name}</Link>
|
||||
) : name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-[0.75rem] max-w-[300px]">
|
||||
<span title={job.command}>{cmdShort}</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{job.status === 'pending' ? (
|
||||
<Select
|
||||
value={node?.id ?? ''}
|
||||
onChange={(e) => assignNode(job.id, e.target.value ? Number(e.target.value) : null)}
|
||||
className="text-[0.8rem] py-0.5 px-1 w-auto"
|
||||
>
|
||||
<option value="">Local</option>
|
||||
{nodes.map((n) => <option key={n.id} value={n.id}>{n.name} ({n.host})</option>)}
|
||||
</Select>
|
||||
) : (
|
||||
<span className="text-gray-500">{node?.name ?? 'Local'}</span>
|
||||
)}
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
|
||||
<Badge variant={job.job_type === 'subtitle' ? 'noop' : 'default'}>{jobTypeLabel(job)}</Badge>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<Badge variant={job.status}>{job.status}</Badge>
|
||||
@@ -211,20 +190,28 @@ export function ExecutePage() {
|
||||
<Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}>✕</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" variant="secondary" onClick={() => toggleCmd(job.id)}>Cmd</Button>
|
||||
{(job.status === 'done' || job.status === 'error') && jobLog && (
|
||||
<Button size="sm" variant="secondary" onClick={() => toggleLog(job.id)}>Log</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{jobLog && (
|
||||
{showCmd && (
|
||||
<tr key={`cmd-${job.id}`}>
|
||||
<td colSpan={5} className="p-0 border-b border-gray-100">
|
||||
<div className="font-mono text-[0.74rem] bg-gray-50 text-gray-700 px-3.5 py-2.5 rounded max-h-[120px] overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{job.command}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{jobLog && showLog && (
|
||||
<tr key={`log-${job.id}`}>
|
||||
<td colSpan={6} className="p-0 border-b border-gray-100">
|
||||
{showLog && (
|
||||
<div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3.5 py-2.5 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{jobLog}
|
||||
</div>
|
||||
)}
|
||||
<td colSpan={5} className="p-0 border-b border-gray-100">
|
||||
<div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3.5 py-2.5 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{jobLog}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface Job {
|
||||
item_id: number;
|
||||
node_id: number | null;
|
||||
command: string;
|
||||
job_type: 'audio' | 'subtitle';
|
||||
status: 'pending' | 'running' | 'done' | 'error';
|
||||
output: string | null;
|
||||
exit_code: number | null;
|
||||
|
||||
Reference in New Issue
Block a user