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:
@@ -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';
|
||||
Reference in New Issue
Block a user