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