Files
move-data-nas/web/src/pages/SyncPairs.tsx
T

484 lines
17 KiB
TypeScript

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<SyncPair[]>([]);
const [machines, setMachines] = useState<Machine[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [form, setForm] = useState<SyncPairForm>(defaultForm);
const [running, setRunning] = useState<Record<number, boolean>>({});
const [loading, setLoading] = useState(false);
useEffect(() => {
load();
}, []);
async function load() {
try {
const [p, m] = await Promise.all([
api<SyncPair[]>('/api/sync-pairs'),
api<Machine[]>('/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;
}
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 <ArrowRight className="h-3 w-3" />
if (dir === 'pull') return <ArrowLeft className="h-3 w-3" />
return <GitCompare className="h-3 w-3" />
}
return (
<div className="space-y-6">
<PageHeader
title="Sync Pairs"
description="Define source and destination for rsync operations"
actions={
<Button onClick={openCreate} size="sm">
<Plus className="h-4 w-4" />
Add Sync Pair
</Button>
}
/>
<Card>
<div className="p-0">
{pairs.length === 0 ? (
<EmptyState
icon={<GitCompare className="h-5 w-5" />}
title="No sync pairs"
description="Create a sync pair to define data transfer between machines"
action={
<Button onClick={openCreate} size="sm">
<Plus className="h-4 w-4" />
Add Sync Pair
</Button>
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Source</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Enabled</TableHead>
<TableHead className="w-28">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pairs.map(p => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell>
<span className="font-mono text-xs text-fg-muted">
{machineName(p.source_machine_id)}:{p.source_path}
</span>
</TableCell>
<TableCell>
<span className="font-mono text-xs text-fg-muted">
{machineName(p.dest_machine_id)}:{p.dest_path}
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
{directionIcon(p.direction)}
<span className="text-xs capitalize">{p.direction}</span>
</div>
</TableCell>
<TableCell>
{p.enabled ? (
<Badge variant="success" label="Active" />
) : (
<Badge variant="neutral" label="Disabled" />
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => openEdit(p)}
className="text-fg-muted hover:text-fg hover:bg-surface-raised"
title="Edit"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => trigger(p.id)}
disabled={running[p.id]}
loading={running[p.id]}
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10"
title="Run now"
>
<Play className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setDeleteId(p.id)}
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</Card>
<Modal open={modalOpen} onOpenChange={setModalOpen}>
<ModalContent size="lg">
<ModalHeader>
<ModalTitle>{form.id ? 'Edit Sync Pair' : 'Add Sync Pair'}</ModalTitle>
<ModalDescription>
{form.id
? 'Update the configuration for this sync pair'
: 'Define a new source and destination for data syncing'}
</ModalDescription>
</ModalHeader>
<form onSubmit={handleSubmit}>
<ModalBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="sp-name" required>
Name
</Label>
<Input
id="sp-name"
placeholder="backup-photos"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="sp-source-machine">Source Machine</Label>
<Select
value={form.source_machine_id?.toString() ?? ''}
onValueChange={v =>
setForm({ ...form, source_machine_id: v ? Number(v) : null })
}
>
<SelectTrigger id="sp-source-machine">
<SelectValue placeholder="Local server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Local server</SelectItem>
{machines.map(m => (
<SelectItem key={m.id} value={m.id.toString()}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="sp-dest-machine">Dest Machine</Label>
<Select
value={form.dest_machine_id?.toString() ?? ''}
onValueChange={v =>
setForm({ ...form, dest_machine_id: v ? Number(v) : null })
}
>
<SelectTrigger id="sp-dest-machine">
<SelectValue placeholder="Local server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Local server</SelectItem>
{machines.map(m => (
<SelectItem key={m.id} value={m.id.toString()}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="sp-source-path" required>
Source Path
</Label>
<Input
id="sp-source-path"
placeholder="/data/photos"
value={form.source_path}
onChange={e =>
setForm({ ...form, source_path: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sp-dest-path" required>
Dest Path
</Label>
<Input
id="sp-dest-path"
placeholder="/backup/photos"
value={form.dest_path}
onChange={e =>
setForm({ ...form, dest_path: e.target.value })
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="sp-direction">Direction</Label>
<Select
value={form.direction}
onValueChange={(v: 'push' | 'pull' | 'mirror') =>
setForm({ ...form, direction: v })
}
>
<SelectTrigger id="sp-direction">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="push">Push</SelectItem>
<SelectItem value="pull">Pull</SelectItem>
<SelectItem value="mirror">Mirror</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="sp-rsync-flags">Rsync Flags</Label>
<Input
id="sp-rsync-flags"
placeholder="-aP"
value={form.rsync_flags}
onChange={e =>
setForm({ ...form, rsync_flags: e.target.value })
}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="sp-exclude">Exclude Patterns</Label>
<Textarea
id="sp-exclude"
placeholder="node_modules&#10;.git&#10;*.tmp"
value={form.exclude_patterns}
onChange={e =>
setForm({ ...form, exclude_patterns: e.target.value })
}
className="font-mono text-xs"
rows={3}
/>
<p className="text-xs text-fg-subtle">
One pattern per line
</p>
</div>
<div
className={cn(
'flex items-center gap-2 rounded-card p-3 transition-colors',
form.enabled
? 'bg-accent/5 border border-accent/20'
: 'bg-surface-raised border border-border'
)}
>
<input
type="checkbox"
id="sp-enabled"
checked={form.enabled}
onChange={e => setForm({ ...form, enabled: e.target.checked })}
className="h-4 w-4 rounded border-border accent-accent"
/>
<Label htmlFor="sp-enabled" className="cursor-pointer mb-0">
Enable this sync pair
</Label>
</div>
</ModalBody>
<ModalFooter>
<Button
type="button"
variant="secondary"
onClick={() => setModalOpen(false)}
>
Cancel
</Button>
<Button type="submit" loading={loading}>
{form.id ? 'Save Changes' : 'Add Sync Pair'}
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Delete Sync Pair</ModalTitle>
<ModalDescription>
Are you sure you want to delete this sync pair? All associated
job history will remain.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button variant="danger-solid" onClick={handleDelete}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}