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:
2026-07-07 23:12:34 -04:00
parent 93c22e844e
commit 9cef7173cb
36 changed files with 4069 additions and 652 deletions
+147 -61
View File
@@ -1,18 +1,33 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Server, Activity, HardDrive, Clock, Plus } from 'lucide-react';
import { api, Machine, Job } from '../api/client';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardBody } from '@/components/ui/Card';
import { PageHeader } from '@/components/ui/PageHeader';
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/Table';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { statusVariant, statusLabel } from '@/lib/status';
import { formatRelativeTime } from '@/lib/utils';
export default function Dashboard() {
const [machines, setMachines] = useState<Machine[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api<Machine[]>('/api/machines'),
api<Job[]>('/api/jobs?limit=5'),
]).then(([m, j]) => {
setMachines(m);
setJobs(j);
}).catch(() => {});
])
.then(([m, j]) => {
setMachines(m);
setJobs(j);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const online = machines.filter(m => m.status.startsWith('online')).length;
@@ -20,67 +35,138 @@ export default function Dashboard() {
if (!j.started_at) return false;
return j.started_at.startsWith(new Date().toISOString().split('T')[0]);
}).length;
const runningJobs = jobs.filter(j =>
['running', 'queued', 'waking_up'].includes(j.status)
).length;
const kpis = [
{
label: 'Total Machines',
value: machines.length,
icon: Server,
className: 'text-sky-400',
bgClass: 'bg-sky-500/10',
},
{
label: 'Online',
value: online,
icon: Activity,
className: 'text-emerald-400',
bgClass: 'bg-emerald-500/10',
accent: online > 0,
},
{
label: 'Jobs Today',
value: todayJobs,
icon: Clock,
className: 'text-amber-400',
bgClass: 'bg-amber-500/10',
},
{
label: 'Running',
value: runningJobs,
icon: HardDrive,
className: 'text-accent',
bgClass: 'bg-accent/10',
},
];
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-gray-800 rounded-lg p-4">
<div className="text-gray-400 text-sm">Machines</div>
<div className="text-3xl font-bold">{machines.length}</div>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<div className="text-gray-400 text-sm">Online</div>
<div className="text-3xl font-bold text-green-500">{online}</div>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<div className="text-gray-400 text-sm">Jobs Today</div>
<div className="text-3xl font-bold text-blue-500">{todayJobs}</div>
</div>
<div className="space-y-6">
<PageHeader
title="Dashboard"
description="Overview of your sync infrastructure"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{loading
? Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardBody>
<Skeleton className="h-4 w-20 mb-3" />
<Skeleton className="h-8 w-12" />
</CardBody>
</Card>
))
: kpis.map(kpi => {
const Icon = kpi.icon;
return (
<Card key={kpi.label} className="transition-shadow hover:shadow-card-hover">
<CardBody>
<div className="flex items-start justify-between mb-3">
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider">
{kpi.label}
</span>
<div className={`rounded-card p-1.5 ${kpi.bgClass}`}>
<Icon className={`h-3.5 w-3.5 ${kpi.className}`} />
</div>
</div>
<p className={`text-3xl font-bold ${kpi.className}`}>
{kpi.value}
</p>
</CardBody>
</Card>
);
})}
</div>
<div className="bg-gray-800 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">Recent Jobs</h2>
{jobs.length === 0 ? <p className="text-gray-500">No jobs yet</p> : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-700">
<th className="pb-2">ID</th>
<th className="pb-2">Sync Pair</th>
<th className="pb-2">Status</th>
<th className="pb-2">Started</th>
</tr>
</thead>
<tbody>
{jobs.map(j => (
<tr key={j.id} className="border-b border-gray-700/50">
<td className="py-2">{j.id}</td>
<td className="py-2">{j.sync_pair_id}</td>
<td className="py-2">
<StatusBadge status={j.status} />
</td>
<td className="py-2">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
</tr>
<Card>
<div className="p-5 pb-0 flex items-center justify-between">
<h2 className="text-base font-semibold text-fg">Recent Jobs</h2>
<Button variant="ghost" size="sm" asChild>
<Link to="/jobs">View all</Link>
</Button>
</div>
<div className="p-5">
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</tbody>
</table>
)}
</div>
</div>
) : jobs.length === 0 ? (
<EmptyState
icon={<HardDrive className="h-5 w-5" />}
title="No jobs yet"
description="Sync pairs will appear here once jobs are executed"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Sync Pair</TableHead>
<TableHead>Status</TableHead>
<TableHead>Started</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map(j => (
<TableRow key={j.id}>
<TableCell>
<Link
to={`/jobs/${j.id}`}
className="text-accent hover:text-accent-hover font-mono text-xs"
>
#{j.id}
</Link>
</TableCell>
<TableCell className="text-fg-muted">
Pair {j.sync_pair_id}
</TableCell>
<TableCell>
<Badge variant={statusVariant(j.status)} label={statusLabel(j.status)} />
</TableCell>
<TableCell className="text-fg-muted text-xs">
{j.started_at ? formatRelativeTime(j.started_at) : '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</Card>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
queued: 'bg-gray-600',
waking_up: 'bg-yellow-600',
running: 'bg-blue-600',
success: 'bg-green-600',
failed: 'bg-red-600',
cancelled: 'bg-gray-600',
};
return (
<span className={`${colors[status] || 'bg-gray-600'} text-white text-xs px-2 py-0.5 rounded`}>
{status}
</span>
);
}
+255 -79
View File
@@ -1,6 +1,32 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api, Job, LogLine, SyncPair } from '../api/client';
import { api } from '../api/client';
import type { Job, LogLine, SyncPair } from '../api/client';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch';
import { Card } from '@/components/ui/Card';
import { Spinner } from '@/components/ui/Spinner';
import { CopyButton } from '@/components/ui/CopyButton';
import {
Modal,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalFooter,
} from '@/components/ui/Modal';
import {
ArrowLeft,
Download,
XCircle,
ScrollText,
Terminal,
} from 'lucide-react';
import { toast } from 'sonner';
import { statusVariant, statusLabel } from '@/lib/status';
import { formatDuration } from '@/lib/utils';
import { cn } from '@/lib/utils';
interface SSEEvent {
type: string;
@@ -15,22 +41,26 @@ export default function JobDetail() {
const [job, setJob] = useState<Job | null>(null);
const [pair, setPair] = useState<SyncPair | null>(null);
const [logs, setLogs] = useState<LogLine[]>([]);
const [lines, setLines] = useState<{ stream: string; text: string }[]>([]);
const [liveLines, setLiveLines] = useState<{ stream: string; text: string }[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const logEndRef = useRef<HTMLDivElement>(null);
const esRef = useRef<EventSource | null>(null);
const jobId = Number(id);
const [loading, setLoading] = useState(true);
const [cancelModal, setCancelModal] = useState(false);
useEffect(() => {
loadJob();
if (jobId) {
loadLogs(0);
const es = new EventSource(`/api/jobs/${jobId}/log/stream?job_id=${jobId}`);
const es = new EventSource(
`/api/jobs/${jobId}/log/stream?job_id=${jobId}`
);
esRef.current = es;
es.onmessage = (e) => {
const evt: SSEEvent = JSON.parse(e.data);
if (evt.type === 'log') {
setLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
setLiveLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
}
if (evt.type === 'status') {
setJob(prev => prev ? { ...prev, status: evt.status! } : prev);
@@ -44,7 +74,7 @@ export default function JobDetail() {
if (autoScroll && logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [lines, autoScroll]);
}, [liveLines, autoScroll]);
async function loadJob() {
try {
@@ -53,12 +83,17 @@ export default function JobDetail() {
const pairs = await api<SyncPair[]>('/api/sync-pairs');
const p = pairs.find((sp: SyncPair) => sp.id === j.sync_pair_id);
setPair(p || null);
} catch {}
} catch {
} finally {
setLoading(false);
}
}
async function loadLogs(offset: number) {
try {
const ls = await api<LogLine[]>(`/api/jobs/${id}/log?offset=${offset}&limit=1000`);
const ls = await api<LogLine[]>(
`/api/jobs/${id}/log?offset=${offset}&limit=1000`
);
if (offset === 0) {
setLogs(ls);
} else {
@@ -68,100 +103,241 @@ export default function JobDetail() {
}
async function cancel() {
if (!confirm('Cancel this job?')) return;
try {
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
toast.success('Job cancelled');
setCancelModal(false);
loadJob();
} catch { alert('Cancel failed'); }
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
function statusColor(s: string) {
const map: Record<string, string> = {
queued: 'bg-gray-600', waking_up: 'bg-yellow-600', running: 'bg-blue-600',
success: 'bg-green-600', failed: 'bg-red-600', cancelled: 'bg-gray-600',
};
return map[s] || 'bg-gray-600';
function downloadLog() {
const allLines = [
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
];
const blob = new Blob([allLines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `job-${id}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function duration(j: Job) {
if (!j.started_at) return '-';
const start = new Date(j.started_at).getTime();
const end = j.finished_at ? new Date(j.finished_at).getTime() : Date.now();
const secs = Math.round((end - start) / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
const rem = secs % 60;
if (mins < 60) return `${mins}m ${rem}s`;
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
const copyLog = useCallback(() => {
const text = [
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
].join('\n');
navigator.clipboard.writeText(text);
toast.success('Log copied to clipboard');
}, [logs, liveLines]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Spinner size="lg" />
</div>
);
}
if (!job) return <div className="p-6 text-gray-400">Loading...</div>;
if (!job) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
<p className="text-fg-muted">Job not found</p>
<Button variant="secondary" asChild>
<Link to="/jobs">
<ArrowLeft className="h-4 w-4" />
Back to Jobs
</Link>
</Button>
</div>
);
}
const totalLines = logs.length + liveLines.length;
return (
<div className="p-6 h-screen flex flex-col">
<div className="flex items-center gap-3 mb-4">
<Link to="/jobs" className="text-gray-400 hover:text-white text-sm"> Job History</Link>
<h1 className="text-2xl font-bold">Job #{job.id}</h1>
<span className={`${statusColor(job.status)} text-white text-xs px-2 py-0.5 rounded`}>
{job.status}
</span>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link to="/jobs">
<ArrowLeft className="h-4 w-4" />
Job History
</Link>
</Button>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-fg font-mono">#{job.id}</h1>
<Badge variant={statusVariant(job.status)} label={statusLabel(job.status)} />
</div>
</div>
<div className="bg-gray-800 rounded-lg p-4 mb-4 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-gray-400 text-xs">Sync Pair</div>
<div className="text-white font-medium">{pair?.name || `Pair ${job.sync_pair_id}`}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Trigger</div>
<div className="text-white">{job.trigger_type}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Duration</div>
<div className="text-white">{duration(job)}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Started</div>
<div className="text-white text-xs">{job.started_at ? new Date(job.started_at).toLocaleString() : '-'}</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: 'Sync Pair', value: pair?.name || `Pair ${job.sync_pair_id}` },
{ label: 'Trigger', value: job.trigger_type },
{
label: 'Duration',
value: job.started_at
? formatDuration(
(job.finished_at
? new Date(job.finished_at).getTime()
: Date.now()) -
new Date(job.started_at).getTime()
)
: '-',
},
{
label: 'Started',
value: job.started_at
? new Date(job.started_at).toLocaleString()
: '-',
},
].map(item => (
<Card key={item.label}>
<div className="p-4">
<div className="text-xs font-medium text-fg-muted uppercase tracking-wider mb-1">
{item.label}
</div>
<div className="text-sm font-semibold text-fg truncate">
{item.value}
</div>
</div>
</Card>
))}
</div>
{['queued', 'waking_up', 'running'].includes(job.status) && (
<div className="flex gap-2 mb-4">
<button onClick={cancel} className="bg-red-600 hover:bg-red-700 text-white px-4 py-1.5 rounded text-sm">
Cancel
</button>
<label className="flex items-center gap-2 text-gray-400 text-sm cursor-pointer">
<input type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)} />
Auto-scroll
</label>
<div className="flex items-center gap-3">
<Button
variant="danger"
size="sm"
onClick={() => setCancelModal(true)}
>
<XCircle className="h-4 w-4" />
Cancel Job
</Button>
<div className="flex items-center gap-2 text-sm text-fg-muted">
<Switch
id="auto-scroll"
checked={autoScroll}
onCheckedChange={setAutoScroll}
/>
<label htmlFor="auto-scroll" className="cursor-pointer">
Auto-scroll
</label>
</div>
</div>
)}
<div className="flex-1 bg-gray-900 rounded-lg overflow-hidden flex flex-col min-h-0">
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between">
<span className="text-gray-400 text-xs font-mono">Output</span>
<span className="text-gray-500 text-xs">{lines.length + logs.length} lines</span>
<Card className="flex flex-col min-h-0">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-fg-subtle" />
<span className="text-xs font-semibold text-fg-muted uppercase tracking-wider">
Output
</span>
<span className="text-xs text-fg-subtle">
{totalLines} line{totalLines !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-1">
<CopyButton
text={[
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
].join('\n')}
displayText="Copy log"
/>
<Button variant="ghost" size="icon-sm" onClick={downloadLog} title="Download log">
<Download className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5">
{logs.map(l => (
<div key={l.id} className={l.stream === 'stderr' ? 'text-red-400' : 'text-gray-300'}>
<span className="text-gray-600 mr-2">{((): string => {
const d = new Date(l.timestamp);
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}`;
})()}</span>
{l.content}
<div className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5 scrollbar-thin min-h-[300px] max-h-[60vh]">
{logs.length === 0 && liveLines.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-fg-subtle gap-2">
<ScrollText className="h-6 w-6" />
<p>No log output yet</p>
</div>
))}
{lines.map((l, i) => (
<div key={`live-${i}`} className={l.stream === 'stderr' ? 'text-red-400' : 'text-gray-300'}>
<span className="text-gray-600 mr-2">LIVE</span>
{l.text}
</div>
))}
) : (
<>
{logs.map(l => (
<LogLine
key={l.id}
stream={l.stream}
content={l.content}
timestamp={l.timestamp}
/>
))}
{liveLines.map((l, i) => (
<div
key={`live-${i}`}
className={cn(
'flex gap-2',
l.stream === 'stderr'
? 'text-rose-400'
: 'text-fg-muted'
)}
>
<span className="text-accent shrink-0">LIVE</span>
<span className="break-all">{l.text}</span>
</div>
))}
</>
)}
<div ref={logEndRef} />
</div>
</div>
</Card>
<Modal open={cancelModal} onOpenChange={setCancelModal}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Cancel Job</ModalTitle>
<ModalDescription>
Are you sure you want to cancel job #{job.id}? This action cannot
be undone.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setCancelModal(false)}>
Keep Running
</Button>
<Button variant="danger-solid" onClick={cancel}>
Cancel Job
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
function LogLine({
stream,
content,
timestamp,
}: {
stream: string;
content: string;
timestamp: string;
}) {
const d = new Date(timestamp);
const timeStr = `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`;
return (
<div
className={cn(
'flex gap-2',
stream === 'stderr' ? 'text-rose-400' : 'text-fg-muted'
)}
>
<span className="text-fg-subtle shrink-0">{timeStr}</span>
<span className="break-all">{content}</span>
</div>
);
}
+241 -102
View File
@@ -1,6 +1,25 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api, Job, SyncPair } from '../api/client';
import { Button } from '@/components/ui/Button';
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/Select';
import { Badge } from '@/components/ui/Badge';
import { PageHeader } from '@/components/ui/PageHeader';
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '@/components/ui/Table';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { History, XCircle, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { statusVariant, statusLabel } from '@/lib/status';
import { formatDuration } from '@/lib/utils';
export default function JobHistory() {
const [jobs, setJobs] = useState<Job[]>([]);
@@ -10,6 +29,7 @@ export default function JobHistory() {
const [filterRange, setFilterRange] = useState('7d');
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(true);
const limit = 50;
useEffect(() => {
@@ -21,6 +41,7 @@ export default function JobHistory() {
}, [filterStatus, filterPair, filterRange, page]);
async function load() {
setLoading(true);
try {
let url = `/api/jobs?limit=${limit}&offset=${page * limit}`;
if (filterStatus) url += `&status=${filterStatus}`;
@@ -40,19 +61,26 @@ export default function JobHistory() {
if (totalCount) setTotal(Number(totalCount));
const data = await res.json();
setJobs(data);
} catch {}
} catch {
} finally {
setLoading(false);
}
}
async function loadPairs() {
try { setPairs(await api<SyncPair[]>('/api/sync-pairs')); } catch {}
try {
setPairs(await api<SyncPair[]>('/api/sync-pairs'));
} catch {}
}
async function cancel(id: number) {
if (!confirm('Cancel this job?')) return;
try {
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
toast.success('Job cancelled');
load();
} catch { alert('Cancel failed'); }
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
function pairName(id: number) {
@@ -60,110 +88,221 @@ export default function JobHistory() {
return p ? p.name : `Pair ${id}`;
}
function statusColor(s: string) {
const map: Record<string, string> = {
queued: 'bg-gray-600', waking_up: 'bg-yellow-600', running: 'bg-blue-600',
success: 'bg-green-600', failed: 'bg-red-600', cancelled: 'bg-gray-600',
};
return map[s] || 'bg-gray-600';
}
function duration(j: Job) {
if (!j.started_at) return '-';
const start = new Date(j.started_at).getTime();
const end = j.finished_at ? new Date(j.finished_at).getTime() : Date.now();
const secs = Math.round((end - start) / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
const rem = secs % 60;
if (mins < 60) return `${mins}m ${rem}s`;
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
}
const totalPages = Math.ceil(total / limit);
const FilterChip = ({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) => (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="w-auto min-w-[140px]">
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{label}</SelectItem>
{label === 'All Pairs' &&
pairs.map(p => (
<SelectItem key={p.id} value={p.id.toString()}>
{p.name}
</SelectItem>
))}
{label === 'All Statuses' &&
[
{ value: 'queued', label: 'Queued' },
{ value: 'waking_up', label: 'Waking Up' },
{ value: 'running', label: 'Running' },
{ value: 'success', label: 'Success' },
{ value: 'failed', label: 'Failed' },
{ value: 'cancelled', label: 'Cancelled' },
].map(s => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
{label === 'Time Range' &&
[
{ value: '24h', label: 'Last 24h' },
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: 'all', label: 'All time' },
].map(r => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Job History</h1>
<div className="flex items-center gap-3 text-sm">
<select value={filterPair} onChange={e => { setFilterPair(e.target.value); setPage(0); }}
className="bg-gray-700 text-white rounded px-2 py-1.5">
<option value="">All Pairs</option>
{pairs.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<select value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(0); }}
className="bg-gray-700 text-white rounded px-2 py-1.5">
<option value="">All Statuses</option>
<option value="queued">Queued</option>
<option value="waking_up">Waking Up</option>
<option value="running">Running</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
<select value={filterRange} onChange={e => { setFilterRange(e.target.value); setPage(0); }}
className="bg-gray-700 text-white rounded px-2 py-1.5">
<option value="24h">Last 24h</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="all">All time</option>
</select>
</div>
</div>
<div className="space-y-6">
<PageHeader
title="Job History"
description={`${total} job${total !== 1 ? 's' : ''} found`}
actions={
<Button
variant="secondary"
size="sm"
onClick={() => load()}
disabled={loading}
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
Refresh
</Button>
}
/>
<div className="bg-gray-800 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-700">
<tr className="text-left text-gray-400">
<th className="p-3">ID</th>
<th className="p-3">Sync Pair</th>
<th className="p-3">Trigger</th>
<th className="p-3">Status</th>
<th className="p-3">Duration</th>
<th className="p-3">Started</th>
<th className="p-3">Finished</th>
<th className="p-3">Actions</th>
</tr>
</thead>
<tbody>
{jobs.map(j => (
<tr key={j.id} className="border-t border-gray-700 hover:bg-gray-750 cursor-pointer"
onClick={() => window.location.href = `/jobs/${j.id}`}>
<td className="p-3 text-blue-400 hover:text-blue-300">
<Link to={`/jobs/${j.id}`}>#{j.id}</Link>
</td>
<td className="p-3">{pairName(j.sync_pair_id)}</td>
<td className="p-3">{j.trigger_type}</td>
<td className="p-3">
<span className={`${statusColor(j.status)} text-white text-xs px-2 py-0.5 rounded`}>
{j.status}
</span>
</td>
<td className="p-3 text-gray-400 text-xs">{duration(j)}</td>
<td className="p-3 text-xs">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
<td className="p-3 text-xs">{j.finished_at ? new Date(j.finished_at).toLocaleString() : '-'}</td>
<td className="p-3" onClick={e => e.stopPropagation()}>
{['queued', 'waking_up', 'running'].includes(j.status) && (
<button onClick={() => cancel(j.id)} className="text-red-400 hover:text-red-300 text-xs">Cancel</button>
)}
</td>
</tr>
))}
{jobs.length === 0 && <tr><td colSpan={8} className="p-4 text-center text-gray-500">No jobs</td></tr>}
</tbody>
</table>
{totalPages > 1 && (
<div className="bg-gray-800 px-4 py-3 flex items-center justify-between border-t border-gray-700">
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
className="text-sm text-gray-400 hover:text-white disabled:opacity-50"> Previous</button>
<span className="text-gray-400 text-sm">{page + 1} / {totalPages} ({total} total)</span>
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
className="text-sm text-gray-400 hover:text-white disabled:opacity-50">Next </button>
</div>
<div className="flex flex-wrap items-center gap-3">
<FilterChip label="All Pairs" value={filterPair} onChange={v => { setFilterPair(v); setPage(0); }} />
<FilterChip label="All Statuses" value={filterStatus} onChange={v => { setFilterStatus(v); setPage(0); }} />
<FilterChip label="Time Range" value={filterRange} onChange={v => { setFilterRange(v); setPage(0); }} />
{(filterStatus || filterPair || filterRange !== '7d') && (
<Button
variant="ghost"
size="sm"
onClick={() => { setFilterStatus(''); setFilterPair(''); setFilterRange('7d'); setPage(0); }}
className="text-fg-subtle"
>
<XCircle className="h-3.5 w-3.5" />
Clear filters
</Button>
)}
</div>
<Card className="p-0">
{loading ? (
<div className="p-5 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : jobs.length === 0 ? (
<EmptyState
icon={<History className="h-5 w-5" />}
title="No jobs found"
description={
filterStatus || filterPair || filterRange !== '7d'
? 'Try adjusting your filters'
: 'Sync pairs will appear here once jobs are executed'
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Sync Pair</TableHead>
<TableHead>Trigger</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Started</TableHead>
<TableHead>Finished</TableHead>
<TableHead className="w-20">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map(j => (
<TableRow key={j.id}>
<TableCell>
<Link
to={`/jobs/${j.id}`}
className="text-accent hover:text-accent-hover font-mono text-xs"
>
#{j.id}
</Link>
</TableCell>
<TableCell className="text-fg-muted">
{pairName(j.sync_pair_id)}
</TableCell>
<TableCell>
<span className="text-xs text-fg-muted capitalize">
{j.trigger_type}
</span>
</TableCell>
<TableCell>
<Badge
variant={statusVariant(j.status)}
label={statusLabel(j.status)}
/>
</TableCell>
<TableCell className="text-fg-muted font-mono text-xs">
{j.started_at
? formatDuration(
(j.finished_at
? new Date(j.finished_at).getTime()
: Date.now()) -
new Date(j.started_at).getTime()
)
: '-'}
</TableCell>
<TableCell className="text-fg-muted text-xs">
{j.started_at
? new Date(j.started_at).toLocaleString()
: '-'}
</TableCell>
<TableCell className="text-fg-muted text-xs">
{j.finished_at
? new Date(j.finished_at).toLocaleString()
: '-'}
</TableCell>
<TableCell>
{['queued', 'waking_up', 'running'].includes(j.status) && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => cancel(j.id)}
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
title="Cancel job"
>
<XCircle className="h-3.5 w-3.5" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{totalPages > 1 && (
<div className="flex items-center justify-between px-5 py-3 border-t border-border">
<div className="text-xs text-fg-muted">
Page {page + 1} of {totalPages} ({total} total)
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="icon-sm"
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon-sm"
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</Card>
</div>
);
}
function Card({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={cn('rounded-card border border-border bg-surface shadow-card', className)}>
{children}
</div>
)
}
+87 -29
View File
@@ -1,15 +1,26 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Database, Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Card, CardBody } from '@/components/ui/Card';
import { toast } from 'sonner';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
if (!username.trim() || !password.trim()) {
toast.error('Username and password are required');
return;
}
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
@@ -21,40 +32,87 @@ export default function Login() {
navigate('/');
} else {
const data = await res.json();
setError(data.error || 'Login failed');
toast.error(data.error || 'Login failed');
}
} catch {
setError('Network error');
toast.error('Network error');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<form onSubmit={handleSubmit} className="bg-gray-900 p-8 rounded-lg w-80 shadow-xl">
<h1 className="text-2xl font-bold mb-6 text-white">SyncServer</h1>
{error && <div className="bg-red-900 text-red-200 p-2 rounded mb-4 text-sm">{error}</div>}
<div className="mb-4">
<label className="block text-gray-400 text-sm mb-1">Username</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full bg-gray-800 text-white rounded px-3 py-2 border border-gray-700 focus:border-blue-500 outline-none"
/>
<div className="min-h-screen flex items-center justify-center bg-canvas relative overflow-hidden">
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: `radial-gradient(circle at 30% 40%, #2dd4bf 0%, transparent 50%),
radial-gradient(circle at 80% 60%, #2dd4bf 0%, transparent 40%)`,
}}
/>
<div className="relative w-full max-w-sm mx-4">
<div className="flex flex-col items-center mb-8 animate-fade-in">
<div className="rounded-card bg-accent/10 p-3 mb-4">
<Database className="h-8 w-8 text-accent" />
</div>
<h1 className="text-2xl font-bold text-fg tracking-tight">SyncServer</h1>
<p className="text-fg-muted text-sm mt-1">Sign in to your account</p>
</div>
<div className="mb-6">
<label className="block text-gray-400 text-sm mb-1">Password</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-gray-800 text-white rounded px-3 py-2 border border-gray-700 focus:border-blue-500 outline-none"
/>
</div>
<button type="submit" className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded py-2 font-medium">
Sign In
</button>
</form>
<Card className="animate-scale-in" style={{ animationDelay: '50ms' }}>
<CardBody>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="username" required>
Username
</Label>
<Input
id="username"
type="text"
placeholder="admin"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="username"
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password" required>
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg-muted transition-colors"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<Button type="submit" className="w-full" loading={loading}>
Sign In
</Button>
</form>
</CardBody>
</Card>
</div>
</div>
);
}
+371 -97
View File
@@ -1,18 +1,72 @@
import { useEffect, useState } from 'react';
import { api, Machine, SSHKey } from '../api/client';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/Select';
import { Badge } from '@/components/ui/Badge';
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 { CopyButton } from '@/components/ui/CopyButton';
import { Card } from '@/components/ui/Card';
import { Pencil, Trash2, Plus, Server } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type MachineForm = {
id: number | undefined;
name: string;
host: string;
port: number;
ssh_user: string;
ssh_key_id: number | null;
mac_address: string;
wol_enabled: boolean;
wake_timeout_seconds: number;
wake_check_interval_seconds: number;
};
const defaultForm: MachineForm = {
id: undefined,
name: '',
host: '',
port: 22,
ssh_user: 'root',
ssh_key_id: null,
mac_address: '',
wol_enabled: false,
wake_timeout_seconds: 120,
wake_check_interval_seconds: 5,
};
export default function Machines() {
const [machines, setMachines] = useState<Machine[]>([]);
const [sshKeys, setSSHKeys] = useState<SSHKey[]>([]);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
id: undefined as number | undefined, name: '', host: '', port: 22, ssh_user: 'root',
ssh_key_id: null as number | null,
mac_address: '', wol_enabled: false,
wake_timeout_seconds: 120, wake_check_interval_seconds: 5,
});
const [modalOpen, setModalOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [form, setForm] = useState<MachineForm>(defaultForm);
const [loading, setLoading] = useState(false);
useEffect(() => { load(); }, []);
useEffect(() => {
load();
}, []);
async function load() {
try {
@@ -25,46 +79,78 @@ export default function Machines() {
} catch {}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
try {
const payload: Record<string, unknown> = {
id: form.id || null, name: form.name, host: form.host, port: Number(form.port),
ssh_user: form.ssh_user, ssh_key_id: form.ssh_key_id,
mac_address: form.mac_address || null,
wol_enabled: Boolean(form.wol_enabled),
wake_timeout_seconds: Number(form.wake_timeout_seconds),
wake_check_interval_seconds: Number(form.wake_check_interval_seconds),
};
if (form.mac_address && !/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/.test(form.mac_address)) {
alert('Invalid MAC address format');
return;
}
await api(form.id ? `/api/machines/${form.id}` : '/api/machines', {
method: form.id ? 'PUT' : 'POST',
body: payload,
});
setShowForm(false);
setForm({ id: undefined, name: '', host: '', port: 22, ssh_user: 'root', ssh_key_id: null, mac_address: '', wol_enabled: false, wake_timeout_seconds: 120, wake_check_interval_seconds: 5 });
load();
} catch (e: unknown) { alert((e as Error).message); }
function openCreate() {
setForm(defaultForm);
setModalOpen(true);
}
function edit(m: Machine) {
function openEdit(m: Machine) {
setForm({
id: m.id, name: m.name, host: m.host, port: m.port,
ssh_user: m.ssh_user, ssh_key_id: m.ssh_key_id,
id: m.id,
name: m.name,
host: m.host,
port: m.port,
ssh_user: m.ssh_user,
ssh_key_id: m.ssh_key_id,
mac_address: m.mac_address || '',
wol_enabled: m.wol_enabled,
wake_timeout_seconds: m.wake_timeout_seconds,
wake_check_interval_seconds: m.wake_check_interval_seconds,
});
setShowForm(true);
setModalOpen(true);
}
async function remove(id: number) {
if (!confirm('Delete machine?')) return;
try { await api(`/api/machines/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); }
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim() || !form.host.trim()) {
toast.error('Name and host are required');
return;
}
if (
form.mac_address &&
!/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/.test(form.mac_address)
) {
toast.error('Invalid MAC address format (AA:BB:CC:DD:EE:FF)');
return;
}
setLoading(true);
try {
const payload: Record<string, unknown> = {
id: form.id || null,
name: form.name,
host: form.host,
port: Number(form.port),
ssh_user: form.ssh_user,
ssh_key_id: form.ssh_key_id,
mac_address: form.mac_address || null,
wol_enabled: Boolean(form.wol_enabled),
wake_timeout_seconds: Number(form.wake_timeout_seconds),
wake_check_interval_seconds: Number(form.wake_check_interval_seconds),
};
await api(form.id ? `/api/machines/${form.id}` : '/api/machines', {
method: form.id ? 'PUT' : 'POST',
body: payload,
});
setModalOpen(false);
toast.success(form.id ? 'Machine updated' : 'Machine created');
load();
} catch (e: unknown) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
async function handleDelete() {
if (deleteId === null) return;
try {
await api(`/api/machines/${deleteId}`, { method: 'DELETE' });
toast.success('Machine deleted');
setDeleteId(null);
load();
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
function keyLabel(id: number | null) {
@@ -74,68 +160,256 @@ export default function Machines() {
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Machines</h1>
<button onClick={() => setShowForm(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
Add Machine
</button>
</div>
<div className="space-y-6">
<PageHeader
title="Machines"
description="Remote machines reachable via SSH"
actions={
<Button onClick={openCreate} size="sm">
<Plus className="h-4 w-4" />
Add Machine
</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-[480px] space-y-3">
<h2 className="text-lg font-bold">Machine</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 />
<input placeholder="Host / IP" value={form.host} onChange={e => setForm({...form, host: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
<input placeholder="SSH Port" type="number" value={form.port} onChange={e => setForm({...form, port: +e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
<input placeholder="SSH User" value={form.ssh_user} onChange={e => setForm({...form, ssh_user: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
<select value={form.ssh_key_id ?? ''} onChange={e => setForm({...form, ssh_key_id: e.target.value ? Number(e.target.value) : null})}
className="w-full bg-gray-700 rounded px-3 py-2 text-white">
<option value="">Server Key (default)</option>
{sshKeys.map(k => <option key={k.id} value={k.id}>{k.label} {k.in_use ? '(in use)' : ''}</option>)}
</select>
<input placeholder="MAC Address (AA:BB:CC:DD:EE:FF)" value={form.mac_address} onChange={e => setForm({...form, mac_address: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
<label className="flex items-center gap-2 text-gray-300">
<input type="checkbox" checked={form.wol_enabled} onChange={e => setForm({...form, wol_enabled: e.target.checked})} />
Enable Wake-on-LAN
</label>
<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)} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
</div>
</form>
<Card>
<div className="p-0">
{machines.length === 0 ? (
<EmptyState
icon={<Server className="h-5 w-5" />}
title="No machines"
description="Add a remote machine to start syncing data"
action={
<Button onClick={openCreate} size="sm">
<Plus className="h-4 w-4" />
Add Machine
</Button>
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Host</TableHead>
<TableHead>SSH Key</TableHead>
<TableHead>WoL</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{machines.map(m => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.name}</TableCell>
<TableCell>
<span className="font-mono text-xs text-fg-muted">
{m.host}:{m.port}
</span>
</TableCell>
<TableCell className="text-fg-muted text-xs">
{keyLabel(m.ssh_key_id)}
</TableCell>
<TableCell>
{m.wol_enabled ? (
<Badge variant="info" label="Yes" />
) : (
<span className="text-fg-subtle text-xs">No</span>
)}
</TableCell>
<TableCell>
<StatusBadge status={m.status} />
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => openEdit(m)}
title="Edit"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setDeleteId(m.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">Host</th>
<th className="p-3">SSH Key</th>
<th className="p-3">WoL</th>
<th className="p-3">Status</th>
<th className="p-3">Actions</th>
</tr>
</thead>
<tbody>
{machines.map(m => (
<tr key={m.id} className="border-t border-gray-700">
<td className="p-3 font-medium">{m.name}</td>
<td className="p-3">{m.host}:{m.port}</td>
<td className="p-3 text-gray-400 text-xs">{keyLabel(m.ssh_key_id)}</td>
<td className="p-3">{m.wol_enabled ? 'Yes' : 'No'}</td>
<td className="p-3 text-gray-400">{m.status}</td>
<td className="p-3">
<button onClick={() => edit(m)} className="text-blue-400 hover:text-blue-300 mr-3">Edit</button>
<button onClick={() => remove(m.id)} className="text-red-400 hover:text-red-300">Delete</button>
</td>
</tr>
))}
{machines.length === 0 && <tr><td colSpan={6} className="p-4 text-center text-gray-500">No machines</td></tr>}
</tbody>
</table>
<Modal open={modalOpen} onOpenChange={setModalOpen}>
<ModalContent size="md">
<ModalHeader>
<ModalTitle>{form.id ? 'Edit Machine' : 'Add Machine'}</ModalTitle>
<ModalDescription>
{form.id
? 'Update the configuration for this machine'
: 'Configure a new remote machine for syncing'}
</ModalDescription>
</ModalHeader>
<form onSubmit={handleSubmit}>
<ModalBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="name" required>
Name
</Label>
<Input
id="name"
placeholder="backup-nas"
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="host">Host / IP</Label>
<Input
id="host"
placeholder="192.168.1.100"
value={form.host}
onChange={e => setForm({ ...form, host: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="port">SSH Port</Label>
<Input
id="port"
type="number"
placeholder="22"
value={form.port}
onChange={e =>
setForm({ ...form, port: Number(e.target.value) })
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="ssh_user">SSH User</Label>
<Input
id="ssh_user"
placeholder="root"
value={form.ssh_user}
onChange={e =>
setForm({ ...form, ssh_user: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ssh_key_id">SSH Key</Label>
<Select
value={form.ssh_key_id?.toString() ?? ''}
onValueChange={v =>
setForm({ ...form, ssh_key_id: v ? Number(v) : null })
}
>
<SelectTrigger id="ssh_key_id">
<SelectValue placeholder="Server Key" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Server Key (default)</SelectItem>
{sshKeys.map(k => (
<SelectItem key={k.id} value={k.id.toString()}>
{k.label} {k.in_use ? '(in use)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="mac_address">MAC Address</Label>
<Input
id="mac_address"
placeholder="AA:BB:CC:DD:EE:FF"
value={form.mac_address}
onChange={e =>
setForm({ ...form, mac_address: e.target.value })
}
/>
<p className="text-xs text-fg-subtle">
Required for Wake-on-LAN
</p>
</div>
<div
className={cn(
'flex items-center gap-2 rounded-card p-3 transition-colors',
form.wol_enabled
? 'bg-accent/5 border border-accent/20'
: 'bg-surface-raised border border-border'
)}
>
<input
type="checkbox"
id="wol_enabled"
checked={form.wol_enabled}
onChange={e =>
setForm({ ...form, wol_enabled: e.target.checked })
}
className="h-4 w-4 rounded border-border accent-accent"
/>
<Label htmlFor="wol_enabled" className="cursor-pointer mb-0">
Enable Wake-on-LAN
</Label>
</div>
</ModalBody>
<ModalFooter>
<Button
type="button"
variant="secondary"
onClick={() => setModalOpen(false)}
>
Cancel
</Button>
<Button type="submit" loading={loading}>
{form.id ? 'Save Changes' : 'Add Machine'}
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Delete Machine</ModalTitle>
<ModalDescription>
Are you sure you want to delete this machine? This action cannot
be undone.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button variant="danger-solid" onClick={handleDelete}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const variant =
status === 'online'
? 'success'
: status === 'offline'
? 'neutral'
: 'info';
return <Badge variant={variant} label={status} />;
}
+289 -96
View File
@@ -1,69 +1,130 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState } from 'react';
import { api, SSHKey } 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 { Badge } from '@/components/ui/Badge';
import {
Modal,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalBody,
ModalFooter,
} from '@/components/ui/Modal';
import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardBody } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { CopyButton } from '@/components/ui/CopyButton';
import { Spinner } from '@/components/ui/Spinner';
import {
Key,
Plus,
Upload,
Download,
Trash2,
Fingerprint,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type ModalType = 'generate' | 'import' | 'delete' | null;
export default function SSHKeys() {
const [keys, setKeys] = useState<SSHKey[]>([]);
const [showGen, setShowGen] = useState(false);
const [showImport, setShowImport] = useState(false);
const [modal, setModal] = useState<ModalType>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [genLabel, setGenLabel] = useState('');
const [importLabel, setImportLabel] = useState('');
const [importPubKey, setImportPubKey] = useState('');
const [downloading, setDownloading] = useState<number | null>(null);
const [copied, setCopied] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [downloading, setDownloading] = useState<number | null>(null);
useEffect(() => { load(); }, []);
useEffect(() => {
load();
}, []);
async function load() {
try { setKeys(await api<SSHKey[]>('/api/ssh-keys')); } catch {}
try {
setKeys(await api<SSHKey[]>('/api/ssh-keys'));
} catch {}
}
async function generate() {
if (!genLabel.trim()) { alert('Label is required'); return; }
if (!genLabel.trim()) {
toast.error('Label is required');
return;
}
setLoading(true);
try {
await api('/api/ssh-keys', {
method: 'POST',
body: { label: genLabel.trim(), generate: true },
});
setShowGen(false);
setModal(null);
setGenLabel('');
toast.success('SSH key pair generated');
load();
} catch (e: unknown) { alert((e as Error).message); }
finally { setLoading(false); }
} catch (e: unknown) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
async function importKey() {
if (!importLabel.trim()) { alert('Label is required'); return; }
if (!importPubKey.trim()) { alert('Public key is required'); return; }
if (!importLabel.trim()) {
toast.error('Label is required');
return;
}
if (!importPubKey.trim()) {
toast.error('Public key is required');
return;
}
setLoading(true);
try {
await api('/api/ssh-keys', {
method: 'POST',
body: { label: importLabel.trim(), generate: false, public_key: importPubKey.trim() },
body: {
label: importLabel.trim(),
generate: false,
public_key: importPubKey.trim(),
},
});
setShowImport(false);
setModal(null);
setImportLabel('');
setImportPubKey('');
toast.success('SSH key imported');
load();
} catch (e: unknown) { alert((e as Error).message); }
finally { setLoading(false); }
} catch (e: unknown) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
async function remove(id: number) {
if (!confirm('Delete this SSH key? Machines using it will fall back to the server key.')) return;
async function handleDelete() {
if (deleteId === null) return;
try {
await api(`/api/ssh-keys/${id}`, { method: 'DELETE' });
await api(`/api/ssh-keys/${deleteId}`, { method: 'DELETE' });
toast.success('SSH key deleted');
setDeleteId(null);
load();
} catch (e: unknown) { alert((e as Error).message); }
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
async function downloadPrivate(id: number) {
try {
const res = await fetch(`/api/ssh-keys/${id}/private`, { credentials: 'include' });
const res = await fetch(`/api/ssh-keys/${id}/private`, {
credentials: 'include',
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Failed' }));
alert((err as { error: string }).error);
toast.error((err as { error: string }).error);
return;
}
const blob = await res.blob();
@@ -76,88 +137,220 @@ export default function SSHKeys() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
setDownloading(id);
toast.success('Private key downloaded');
setTimeout(() => setDownloading(null), 3000);
} catch (e: unknown) { alert((e as Error).message); }
}
async function copyPubKey(key: SSHKey) {
await navigator.clipboard.writeText(key.public_key);
setCopied(key.id);
setTimeout(() => setCopied(null), 2000);
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">SSH Keys</h1>
<div className="flex gap-2">
<button onClick={() => setShowGen(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
Generate New
</button>
<button onClick={() => setShowImport(true)} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded text-sm">
Import Public Key
</button>
</div>
</div>
{(showGen || showImport) && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-800 p-6 rounded-lg w-[500px] space-y-3">
<h2 className="text-lg font-bold">{showGen ? 'Generate SSH Key Pair' : 'Import Public Key'}</h2>
<input placeholder="Label (e.g. backup-nas)" value={genLabel} onChange={e => setGenLabel(e.target.value)}
className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
{showImport && (
<textarea placeholder="ssh-ed25519 AAAA..." value={importPubKey} onChange={e => setImportPubKey(e.target.value)}
className="w-full bg-gray-700 rounded px-3 py-2 text-white font-mono text-xs h-32" />
)}
<div className="flex gap-2">
<button onClick={showGen ? generate : importKey} disabled={loading}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1 disabled:opacity-50">
{loading ? 'Working...' : showGen ? 'Generate' : 'Import'}
</button>
<button onClick={() => { setShowGen(false); setShowImport(false); }}
className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
</div>
<div className="space-y-6">
<PageHeader
title="SSH Keys"
description="Manage SSH key pairs for authenticating with remote machines"
actions={
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setModal('import')}
>
<Upload className="h-4 w-4" />
Import Public Key
</Button>
<Button size="sm" onClick={() => setModal('generate')}>
<Plus className="h-4 w-4" />
Generate New
</Button>
</div>
}
/>
{keys.length === 0 ? (
<Card>
<CardBody className="p-0">
<EmptyState
icon={<Key className="h-5 w-5" />}
title="No SSH keys"
description="Generate a key pair or import a public key to authenticate with remote machines"
action={
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setModal('import')}
>
<Upload className="h-4 w-4" />
Import
</Button>
<Button size="sm" onClick={() => setModal('generate')}>
<Plus className="h-4 w-4" />
Generate
</Button>
</div>
}
/>
</CardBody>
</Card>
) : (
<div className="space-y-3">
{keys.map(k => (
<Card key={k.id}>
<CardBody>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-sm font-semibold text-fg truncate">
{k.label}
</h3>
{k.in_use && (
<Badge variant="success" label="In Use" />
)}
{!k.has_private_key && (
<Badge variant="neutral" label="Imported Only" />
)}
</div>
<div className="flex items-center gap-1.5 mb-3 text-xs text-fg-subtle">
<Fingerprint className="h-3.5 w-3.5" />
<span className="font-mono truncate">
{k.fingerprint}
</span>
</div>
<div className="bg-canvas-raised rounded-card p-3 font-mono text-xs text-emerald-400/80 break-all max-w-2xl">
{k.public_key}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<CopyButton
text={k.public_key}
displayText="Copy pub"
/>
{k.has_private_key && (
<Button
variant="secondary"
size="sm"
onClick={() => downloadPrivate(k.id)}
loading={downloading === k.id}
>
<Download className="h-3.5 w-3.5" />
{downloading === k.id ? 'Done' : 'Private'}
</Button>
)}
<Button
variant="danger"
size="icon-sm"
onClick={() => setDeleteId(k.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardBody>
</Card>
))}
</div>
)}
<div className="space-y-3">
{keys.map(k => (
<div key={k.id} className="bg-gray-800 rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-white">{k.label}</span>
{k.in_use && <span className="text-xs bg-green-900 text-green-400 px-2 py-0.5 rounded">In Use</span>}
{!k.has_private_key && <span className="text-xs bg-gray-700 text-gray-400 px-2 py-0.5 rounded">Imported Only</span>}
</div>
<div className="text-xs text-gray-400 mb-2">Fingerprint: {k.fingerprint}</div>
<div className="bg-gray-900 p-2 rounded font-mono text-xs text-green-400 break-all max-w-2xl">
{k.public_key}
</div>
</div>
<div className="flex gap-2 ml-4">
<button onClick={() => copyPubKey(k)}
className="text-gray-400 hover:text-white text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
{copied === k.id ? 'Copied!' : 'Copy Public'}
</button>
{k.has_private_key && (
<button onClick={() => downloadPrivate(k.id)}
className="text-yellow-400 hover:text-yellow-300 text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
{downloading === k.id ? 'Downloaded!' : 'Download Private Key'}
</button>
)}
<button onClick={() => remove(k.id)}
className="text-red-400 hover:text-red-300 text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
Delete
</button>
</div>
<Modal open={modal === 'generate'} onOpenChange={v => !v && setModal(null)}>
<ModalContent size="md">
<ModalHeader>
<ModalTitle>Generate SSH Key Pair</ModalTitle>
<ModalDescription>
Generate a new Ed25519 key pair. The private key will be
downloaded immediately and the public key stored on the server.
</ModalDescription>
</ModalHeader>
<ModalBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="gen-label" required>
Label
</Label>
<Input
id="gen-label"
placeholder="backup-nas"
value={genLabel}
onChange={e => setGenLabel(e.target.value)}
/>
</div>
</div>
))}
{keys.length === 0 && <div className="text-gray-500 text-center py-12">No SSH keys. Generate one or import a public key above.</div>}
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" onClick={() => setModal(null)}>
Cancel
</Button>
<Button onClick={generate} loading={loading}>
<Key className="h-4 w-4" />
Generate
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={modal === 'import'} onOpenChange={v => !v && setModal(null)}>
<ModalContent size="md">
<ModalHeader>
<ModalTitle>Import Public Key</ModalTitle>
<ModalDescription>
Import an existing public key. Only the public key will be stored
you must have the corresponding private key on this server.
</ModalDescription>
</ModalHeader>
<ModalBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="import-label" required>
Label
</Label>
<Input
id="import-label"
placeholder="work-server"
value={importLabel}
onChange={e => setImportLabel(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="import-pubkey" required>
Public Key
</Label>
<Textarea
id="import-pubkey"
placeholder="ssh-ed25519 AAAA..."
value={importPubKey}
onChange={e => setImportPubKey(e.target.value)}
className="font-mono text-xs h-24"
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" onClick={() => setModal(null)}>
Cancel
</Button>
<Button onClick={importKey} loading={loading}>
<Upload className="h-4 w-4" />
Import
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Delete SSH Key</ModalTitle>
<ModalDescription>
Are you sure you want to delete this SSH key? Machines using it
will fall back to the server key.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button variant="danger-solid" onClick={handleDelete}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
+94 -32
View File
@@ -1,50 +1,112 @@
import { useState, useEffect } from 'react';
import { CopyButton } from '@/components/ui/CopyButton';
import { Button } from '@/components/ui/Button';
import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardHeader, CardTitle, CardBody } from '@/components/ui/Card';
import { Key, Download, Terminal } from 'lucide-react';
export default function Settings() {
const [pubKey, setPubKey] = useState('');
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/settings/pubkey', { credentials: 'include' })
.then(r => r.ok ? r.text() : '')
.then(r => (r.ok ? r.text() : ''))
.then(t => setPubKey(t))
.catch(() => {});
.catch(() => {})
.finally(() => setLoading(false));
}, []);
function copyKey() {
navigator.clipboard.writeText(pubKey).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
function downloadPubKey() {
if (!pubKey) return;
const blob = new Blob([pubKey], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'syncserver.pub';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
<div className="space-y-6 max-w-2xl">
<PageHeader
title="Settings"
description="Server configuration and SSH key management"
/>
<div className="bg-gray-800 rounded-lg p-4 mb-6">
<h2 className="text-lg font-semibold mb-3">Server SSH Public Key</h2>
<p className="text-gray-400 text-sm mb-3">
Add this key to the <code className="bg-gray-700 px-1 rounded">~/.ssh/authorized_keys</code> file on your remote machines to allow SyncServer to connect.
</p>
<div className="bg-gray-900 p-3 rounded font-mono text-xs text-green-400 break-all mb-3">
{pubKey || 'Loading...'}
</div>
<button onClick={copyKey} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
{copied ? 'Copied!' : 'Copy to clipboard'}
</button>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="rounded-card bg-accent/10 p-1">
<Key className="h-4 w-4 text-accent" />
</div>
<CardTitle>Server SSH Public Key</CardTitle>
</div>
</CardHeader>
<CardBody className="space-y-4">
<p className="text-sm text-fg-muted">
Add this key to the{' '}
<code className="bg-surface-raised px-1.5 py-0.5 rounded text-xs text-fg font-mono">
~/.ssh/authorized_keys
</code>{' '}
file on your remote machines to allow SyncServer to connect.
</p>
<div className="bg-canvas-raised rounded-card p-4 font-mono text-xs text-emerald-400/80 break-all min-h-[4rem]">
{loading ? (
<span className="text-fg-subtle">Loading...</span>
) : pubKey ? (
pubKey
) : (
<span className="text-fg-subtle">No public key available</span>
)}
</div>
<div className="flex items-center gap-2">
<CopyButton text={pubKey} displayText="Copy public key" />
{pubKey && (
<Button variant="secondary" size="sm" onClick={downloadPubKey}>
<Download className="h-3.5 w-3.5" />
Download .pub
</Button>
)}
</div>
</CardBody>
</Card>
<div className="bg-gray-800 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">Quick Reference</h2>
<div className="text-gray-400 text-sm space-y-2">
<p><strong className="text-white">ssh-copy-id:</strong> Copy the public key above to a remote machine:</p>
<code className="block bg-gray-900 p-2 rounded text-xs">
cat ~/.ssh/id_ed25519.pub | ssh user@host 'cat &gt;&gt; ~/.ssh/authorized_keys'
</code>
<p className="mt-4"><strong className="text-white">Wake-on-LAN:</strong> Make sure your target machine BIOS/UEFI has WoL enabled and is connected to the same network layer (L2) as this server.</p>
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="rounded-card bg-surface-raised p-1">
<Terminal className="h-4 w-4 text-fg-muted" />
</div>
<CardTitle>Quick Reference</CardTitle>
</div>
</CardHeader>
<CardBody className="space-y-4">
<div className="space-y-3">
<div>
<p className="text-sm text-fg-muted mb-2">
<span className="font-semibold text-fg">ssh-copy-id:</span> Copy
the public key above to a remote machine:
</p>
<div className="bg-canvas-raised rounded-card p-3 font-mono text-xs text-fg-muted">
cat ~/.ssh/id_ed25519.pub | ssh user@host 'cat &gt;&gt;
~/.ssh/authorized_keys'
</div>
</div>
<div>
<p className="text-sm text-fg-muted">
<span className="font-semibold text-fg">Wake-on-LAN:</span>{' '}
Make sure your target machine BIOS/UEFI has WoL enabled and is
connected to the same network layer (L2) as this server.
</p>
</div>
</div>
</CardBody>
</Card>
</div>
);
}
+423 -101
View File
@@ -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&#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>
);
}