drop multi-node ssh execution, unify job runner to local + fix job completion atomicity

- remove nodes table, ssh service, nodes api, NodesPage route
- execute.ts: local-only spawn, atomic CAS job claim via UPDATE status
- wrap job done + subtitle_files insert + review_plans status in db transaction
- stream ffmpeg output per line with 500ms throttled flush
- bump version to 2026.04.13
This commit is contained in:
2026-04-13 07:25:19 +02:00
parent 1762f070a9
commit cdcb1ff706
12 changed files with 108 additions and 583 deletions
-158
View File
@@ -1,158 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { Input } from '~/shared/components/ui/input';
import { Alert } from '~/shared/components/ui/alert';
import type { Node } from '~/shared/lib/types';
interface NodesData { nodes: Node[]; }
function nodeStatusVariant(status: string): 'done' | 'error' | 'pending' {
if (status === 'ok') return 'done';
if (status.startsWith('error')) return 'error';
return 'pending';
}
export function NodesPage() {
const [nodes, setNodes] = useState<Node[]>([]);
const [error, setError] = useState('');
const [testing, setTesting] = useState<Set<number>>(new Set());
const fileRef = useRef<HTMLInputElement>(null);
const load = () => api.get<NodesData>('/api/nodes').then((d) => setNodes(d.nodes));
useEffect(() => { load(); }, []);
const submit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError('');
const form = e.currentTarget;
const fd = new FormData(form);
const result = await api.postForm<NodesData>('/api/nodes', fd).catch((err) => { setError(String(err)); return null; });
if (result) { setNodes(result.nodes); form.reset(); if (fileRef.current) fileRef.current.value = ''; }
};
const deleteNode = async (id: number) => {
if (!confirm('Remove node?')) return;
await api.post(`/api/nodes/${id}/delete`);
load();
};
const testNode = async (id: number) => {
setTesting((s) => { const n = new Set(s); n.add(id); return n; });
await api.post<{ ok: boolean; status: string }>(`/api/nodes/${id}/test`);
setTesting((s) => { const n = new Set(s); n.delete(id); return n; });
load();
};
return (
<div>
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold m-0">Remote Nodes</h1>
</div>
<p className="text-gray-500 mb-4">
Remote nodes run FFmpeg over SSH. If the media is mounted at a different path on the
remote node, set the Movies/Series path fields to translate <code className="font-mono bg-gray-100 px-1 rounded">/movies/</code> and <code className="font-mono bg-gray-100 px-1 rounded">/series/</code> to the node's mount points.
</p>
{/* Add form */}
<div className="border border-gray-200 rounded-lg p-4 mb-4">
<div className="font-semibold text-sm mb-3">Add Node</div>
{error && <Alert variant="error" className="mb-3">{error}</Alert>}
<form onSubmit={submit}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-3">
<label className="block text-sm text-gray-700 mb-0.5">
Name
<Input name="name" placeholder="my-server" required className="mt-0.5" />
</label>
<label className="block text-sm text-gray-700 mb-0.5">
Host
<Input name="host" placeholder="192.168.1.200" required className="mt-0.5" />
</label>
<label className="block text-sm text-gray-700 mb-0.5">
SSH Port
<Input type="number" name="port" defaultValue="22" min="1" max="65535" className="mt-0.5" />
</label>
<label className="block text-sm text-gray-700 mb-0.5">
Username
<Input name="username" placeholder="root" required className="mt-0.5" />
</label>
<label className="block text-sm text-gray-700 mb-0.5">
FFmpeg path
<Input name="ffmpeg_path" defaultValue="ffmpeg" className="mt-0.5" />
</label>
<label className="block text-sm text-gray-700 mb-0.5">
Work directory
<Input name="work_dir" defaultValue="/tmp" className="mt-0.5" />
</label>
<label className="block text-sm text-gray-700 mb-0.5">
Movies path
<Input name="movies_path" placeholder="/mnt/media/movies" className="mt-0.5" />
<small className="text-xs text-gray-500 mt-0.5 block">Remote mount point for movies (leave empty if same as container)</small>
</label>
<label className="block text-sm text-gray-700 mb-0.5">
Series path
<Input name="series_path" placeholder="/mnt/media/series" className="mt-0.5" />
<small className="text-xs text-gray-500 mt-0.5 block">Remote mount point for series (leave empty if same as container)</small>
</label>
</div>
<label className="block text-sm text-gray-700 mb-0.5">
Private key (PEM)
<input
ref={fileRef}
type="file"
name="private_key"
accept=".pem,.key,text/plain"
required
className="border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full mt-0.5"
/>
<small className="text-xs text-gray-500 mt-0.5 block">Upload your SSH private key file. Stored securely in the database.</small>
</label>
<Button type="submit" className="mt-3">Add Node</Button>
</form>
</div>
{/* Node list */}
{nodes.length === 0 ? (
<p className="text-gray-500">No nodes configured. Add one above.</p>
) : (
<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>
{['Name', 'Host', 'Port', 'User', 'FFmpeg', 'Movies', 'Series', '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>
{nodes.map((node) => (
<tr key={node.id} className="hover:bg-gray-50">
<td className="py-1.5 px-2 border-b border-gray-100"><strong>{node.name}</strong></td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.host}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.port}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.username}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.ffmpeg_path}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.movies_path || ''}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.series_path || ''}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<Badge variant={nodeStatusVariant(node.status)}>{node.status}</Badge>
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<div className="flex gap-1 items-center">
<Button size="sm" onClick={() => testNode(node.id)} disabled={testing.has(node.id)}>
{testing.has(node.id) ? '' : 'Test'}
</Button>
<Button size="sm" variant="secondary" onClick={() => deleteNode(node.id)}>Remove</Button>
</div>
</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
);
}
import type React from 'react';