import { useEffect, useState } from 'react'; import { api, SyncPair, Machine } from '../api/client'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Label } from '@/components/ui/Label'; import { Textarea } from '@/components/ui/Textarea'; import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/Select'; import { Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter, } from '@/components/ui/Modal'; import { PageHeader } from '@/components/ui/PageHeader'; import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell, } from '@/components/ui/Table'; import { EmptyState } from '@/components/ui/EmptyState'; import { Badge } from '@/components/ui/Badge'; import { Card } from '@/components/ui/Card'; import { Play, Trash2, Plus, GitCompare, ArrowRight, ArrowLeft, Pencil } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; type SyncPairForm = { id: number | undefined; name: string; source_machine_id: number | null; source_path: string; dest_machine_id: number | null; dest_path: string; direction: 'push' | 'pull' | 'mirror'; rsync_flags: string; exclude_patterns: string; enabled: boolean; }; const defaultForm: SyncPairForm = { id: undefined, name: '', source_machine_id: null, source_path: '', dest_machine_id: null, dest_path: '', direction: 'push', rsync_flags: '-aP', exclude_patterns: '', enabled: true, }; export default function SyncPairs() { const [pairs, setPairs] = useState([]); const [machines, setMachines] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [deleteId, setDeleteId] = useState(null); const [form, setForm] = useState(defaultForm); const [running, setRunning] = useState>({}); const [loading, setLoading] = useState(false); useEffect(() => { load(); }, []); async function load() { try { const [p, m] = await Promise.all([ api('/api/sync-pairs'), api('/api/machines'), ]); setPairs(p); setMachines(m); } catch {} } function openCreate() { setForm(defaultForm); setModalOpen(true); } function openEdit(p: SyncPair) { setForm({ id: p.id, name: p.name, source_machine_id: p.source_machine_id, source_path: p.source_path, dest_machine_id: p.dest_machine_id, dest_path: p.dest_path, direction: p.direction as 'push' | 'pull' | 'mirror', rsync_flags: p.rsync_flags, exclude_patterns: p.exclude_patterns || '', enabled: p.enabled, }); setModalOpen(true); } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!form.name.trim()) { toast.error('Name is required'); return; } if (!form.source_path.trim() || !form.dest_path.trim()) { toast.error('Source and destination paths are required'); return; } if ( form.source_machine_id !== null && form.dest_machine_id !== null && form.source_machine_id === form.dest_machine_id ) { toast.error('Source and destination cannot be the same machine'); return; } setLoading(true); try { await api(form.id ? `/api/sync-pairs/${form.id}` : '/api/sync-pairs', { method: form.id ? 'PUT' : 'POST', body: { name: form.name, source_machine_id: form.source_machine_id, source_path: form.source_path, dest_machine_id: form.dest_machine_id, dest_path: form.dest_path, direction: form.direction, rsync_flags: form.rsync_flags, exclude_patterns: form.exclude_patterns, enabled: form.enabled, }, }); setModalOpen(false); toast.success(form.id ? 'Sync pair updated' : 'Sync pair created'); load(); } catch (e: unknown) { toast.error((e as Error).message); } finally { setLoading(false); } } async function trigger(pairId: number) { setRunning(r => ({ ...r, [pairId]: true })); try { await api(`/api/sync-pairs/${pairId}/run`, { method: 'POST' }); toast.success('Job triggered'); load(); } catch (e: unknown) { toast.error((e as Error).message); } finally { setRunning(r => ({ ...r, [pairId]: false })); } } async function handleDelete() { if (deleteId === null) return; try { await api(`/api/sync-pairs/${deleteId}`, { method: 'DELETE' }); toast.success('Sync pair deleted'); setDeleteId(null); load(); } catch (e: unknown) { toast.error((e as Error).message); } } function machineName(id: number | null) { if (!id) return 'Local server'; const m = machines.find(m => m.id === id); return m ? m.name : `Machine ${id}`; } const directionIcon = (dir: string) => { if (dir === 'push') return if (dir === 'pull') return return } return (
Add Sync Pair } />
{pairs.length === 0 ? ( } title="No sync pairs" description="Create a sync pair to define data transfer between machines" action={ } /> ) : ( Name Source Destination Direction Enabled Actions {pairs.map(p => ( {p.name} {machineName(p.source_machine_id)}:{p.source_path} {machineName(p.dest_machine_id)}:{p.dest_path}
{directionIcon(p.direction)} {p.direction}
{p.enabled ? ( ) : ( )}
))}
)}
{form.id ? 'Edit Sync Pair' : 'Add Sync Pair'} {form.id ? 'Update the configuration for this sync pair' : 'Define a new source and destination for data syncing'}
setForm({ ...form, name: e.target.value })} />

Where the data lives. Pick Local server if it's on this machine, otherwise the remote machine holding the data.

Where to copy the data. Can be the same machine (no-op) or any remote. Remote-to-remote is supported.

setForm({ ...form, source_path: e.target.value }) } />

Directory on the source machine. Its contents will be copied into the destination. Trailing / is optional.

setForm({ ...form, dest_path: e.target.value }) } />

Directory on the destination machine where the source contents will land.

setForm({ ...form, rsync_flags: e.target.value }) } />