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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 15:01:11 +01:00
parent 2f10037e93
commit 38b0faf55a
7 changed files with 44 additions and 53 deletions

View File

@@ -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 });
});

View File

@@ -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 });
});

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>
)}

View File

@@ -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;