Visual redesign: design system, refined ops console aesthetic
- Full component library (Button, Input, Label, Select, Modal, Table, Badge, Card, etc.) - Tailwind design tokens: IBM Plex Sans + JetBrains Mono, teal accent, semantic status colors - NavBar with logo, responsive hamburger menu, real logout - All pages redesigned: Login, Dashboard (KPI cards), Machines, SyncPairs, JobHistory, JobDetail, SSHKeys, Settings - Fixed: hover:bg-gray-750 dead class, window.location.href navigation bug - Replaced alert()/confirm() with sonner toasts and accessible modals - Added ErrorBoundary, skip link, accessible modal dialogs (Radix) - Icons: lucide-react throughout, copy/download buttons - 1.0.5 → 1.0.6
This commit is contained in:
+423
-101
@@ -1,18 +1,73 @@
|
||||
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 } 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 [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
id: undefined as number | undefined, name: '', source_machine_id: null as number | null, source_path: '',
|
||||
dest_machine_id: null as number | null, dest_path: '',
|
||||
direction: 'push', rsync_flags: '-aP', exclude_patterns: '', enabled: true,
|
||||
});
|
||||
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(); }, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -25,41 +80,86 @@ export default function SyncPairs() {
|
||||
} 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 {
|
||||
const payload = {
|
||||
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,
|
||||
};
|
||||
await api(form.id ? `/api/sync-pairs/${form.id}` : '/api/sync-pairs', {
|
||||
method: form.id ? 'PUT' : 'POST',
|
||||
body: payload,
|
||||
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,
|
||||
},
|
||||
});
|
||||
setShowForm(false);
|
||||
resetForm();
|
||||
setModalOpen(false);
|
||||
toast.success(form.id ? 'Sync pair updated' : 'Sync pair created');
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
} 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) { alert((e as Error).message); }
|
||||
setRunning(r => ({ ...r, [pairId]: false }));
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setRunning(r => ({ ...r, [pairId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete sync pair?')) return;
|
||||
try { await api(`/api/sync-pairs/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); }
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setForm({ id: undefined, name: '', source_machine_id: null, source_path: '', dest_machine_id: null, dest_path: '', direction: 'push', rsync_flags: '-aP', exclude_patterns: '', enabled: true });
|
||||
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) {
|
||||
@@ -68,85 +168,307 @@ export default function SyncPairs() {
|
||||
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="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Sync Pairs</h1>
|
||||
<button onClick={() => setShowForm(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
|
||||
Add Sync Pair
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<form onSubmit={handleSubmit} className="bg-gray-800 p-6 rounded-lg w-[500px] space-y-3 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-lg font-bold">Sync Pair</h2>
|
||||
<input placeholder="Name" value={form.name} onChange={e => setForm({...form, name: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-gray-400 text-xs">Source Machine</label>
|
||||
<select value={form.source_machine_id ?? ''} onChange={e => setForm({...form, source_machine_id: e.target.value ? +e.target.value : null })} className="w-full bg-gray-700 rounded px-3 py-2 text-white">
|
||||
<option value="">Local server</option>
|
||||
{machines.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-gray-400 text-xs">Dest Machine</label>
|
||||
<select value={form.dest_machine_id ?? ''} onChange={e => setForm({...form, dest_machine_id: e.target.value ? +e.target.value : null })} className="w-full bg-gray-700 rounded px-3 py-2 text-white">
|
||||
<option value="">Local server</option>
|
||||
{machines.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input placeholder="Source Path" value={form.source_path} onChange={e => setForm({...form, source_path: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||
<input placeholder="Dest Path" value={form.dest_path} onChange={e => setForm({...form, dest_path: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<select value={form.direction} onChange={e => setForm({...form, direction: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white">
|
||||
<option value="push">Push</option>
|
||||
<option value="pull">Pull</option>
|
||||
<option value="mirror">Mirror</option>
|
||||
</select>
|
||||
<input placeholder="Rsync Flags" value={form.rsync_flags} onChange={e => setForm({...form, rsync_flags: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||
</div>
|
||||
<textarea placeholder="Exclude Patterns (one per line)" value={form.exclude_patterns} onChange={e => setForm({...form, exclude_patterns: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white font-mono text-sm" rows={3} />
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1">Save</button>
|
||||
<button type="button" onClick={() => { setShowForm(false); resetForm(); }} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<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={() => 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>
|
||||
|
||||
<table className="w-full text-sm bg-gray-800 rounded-lg overflow-hidden">
|
||||
<thead className="bg-gray-700">
|
||||
<tr className="text-left text-gray-400">
|
||||
<th className="p-3">Name</th>
|
||||
<th className="p-3">Source</th>
|
||||
<th className="p-3">Dest</th>
|
||||
<th className="p-3">Direction</th>
|
||||
<th className="p-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pairs.map(p => (
|
||||
<tr key={p.id} className="border-t border-gray-700">
|
||||
<td className="p-3 font-medium">{p.name}</td>
|
||||
<td className="p-3 font-mono text-xs">{machineName(p.source_machine_id)}:{p.source_path}</td>
|
||||
<td className="p-3 font-mono text-xs">{machineName(p.dest_machine_id)}:{p.dest_path}</td>
|
||||
<td className="p-3">{p.direction}</td>
|
||||
<td className="p-3">
|
||||
<button onClick={() => trigger(p.id)} disabled={running[p.id]} className="text-green-400 hover:text-green-300 mr-3 disabled:opacity-50">
|
||||
{running[p.id] ? 'Running...' : 'Run'}
|
||||
</button>
|
||||
<button onClick={() => remove(p.id)} className="text-red-400 hover:text-red-300">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{pairs.length === 0 && <tr><td colSpan={5} className="p-4 text-center text-gray-500">No sync pairs</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<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 .git *.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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user