Phase A-E: stability, security, observability, and test coverage

Phase A - Stability:
- Engine.Start(): recovers orphaned jobs (running/queued/waking_up) after crash
- Engine.Stop(): graceful shutdown - cancels all in-flight jobs and waits
- Queue keyed by jobID (not syncPairID): cancel now targets exact job
- Local rsync uses jobCtx (context.Background() replaced)
- Migrations wrapped in transactions; checksums stored

Phase B - Security:
- admin/admin default removed: SYNCSERVER_ADMIN_PASSWORD required on first run
- Path validation: rejects .., leading -, null bytes in sync pair paths
- Rsync flags allowlist: dangerous flags blocked (--rsync-path, -e, --files-from)
- Shell concat in RunRemote replaced with proper sh -c escaping
- knownhosts: replaced custom parser with golang.org/x/crypto/ssh/knownhosts
- RequireAdmin wired: machine CRUD, SSH key ops, settings require admin role
- deploy-keys: uses authorized_keys only (no private key upload)
- Hardcoded /var/lib/syncserver/ssh paths replaced with cfg.SSHDir()

Phase C - Operational:
- /readyz health check: DB query + SSH dir accessibility
- /metrics endpoint: Prometheus text format (jobs, queue, machines)
- Event struct JSON tags: job_id, machine_id, type (snake_case)
- EventBus broadcast: fanned out to all subscribers
- SQLite VACUUM INTO backup: scheduled before cleanup if BackupDir set
- Filesystem job log cleanup: removes .log files for purged jobs
- Backup retention: old backups auto-purged

Phase D - Frontend:
- Schedules page: REST API + full CRUD UI for cron schedules
- Dashboard: cancel button for running/queued jobs
- JobDetail: server-side log download via API
- Settings: displays data_dir from server
- 404 page: proper NotFound component

Phase E - Tests:
- auth_test.go: JWT, bcrypt, middleware, seed (18 tests)
- models_test.go: Job, Machine, SyncPair, Schedule repos (18 tests)
- go test -race: no data races found
This commit is contained in:
2026-07-19 22:14:30 -04:00
parent 300555d35f
commit 84b185be39
33 changed files with 2398 additions and 199 deletions
+334
View File
@@ -0,0 +1,334 @@
import { useEffect, useState } from 'react';
import { api, SyncPair, Schedule } 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 {
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 { Trash2, Plus, Clock, Pencil, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type ScheduleForm = {
id: number | undefined;
sync_pair_id: number | null;
cron_expr: string;
enabled: boolean;
};
const defaultForm: ScheduleForm = {
id: undefined,
sync_pair_id: null,
cron_expr: '',
enabled: true,
};
export default function Schedules() {
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [syncPairs, setSyncPairs] = useState<SyncPair[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [form, setForm] = useState<ScheduleForm>(defaultForm);
const [loading, setLoading] = useState(false);
useEffect(() => {
load();
}, []);
async function load() {
try {
const [s, p] = await Promise.all([
api<Schedule[]>('/api/schedules'),
api<SyncPair[]>('/api/sync-pairs'),
]);
setSchedules(s);
setSyncPairs(p);
} catch {}
}
function openCreate() {
setForm(defaultForm);
setModalOpen(true);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.sync_pair_id) {
toast.error('Sync pair is required');
return;
}
if (!form.cron_expr.trim()) {
toast.error('Cron expression is required');
return;
}
setLoading(true);
try {
await api(form.id ? `/api/schedules/${form.id}` : '/api/schedules', {
method: form.id ? 'PUT' : 'POST',
body: {
sync_pair_id: form.sync_pair_id,
cron_expr: form.cron_expr,
enabled: form.enabled,
},
});
setModalOpen(false);
toast.success(form.id ? 'Schedule updated' : 'Schedule created');
load();
} catch (e: unknown) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
async function handleToggleEnabled(schedule: Schedule) {
try {
await api(`/api/schedules/${schedule.id}`, {
method: 'PUT',
body: {
cron_expr: schedule.cron_expr,
enabled: !schedule.enabled,
},
});
toast.success(`Schedule ${schedule.enabled ? 'disabled' : 'enabled'}`);
load();
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
async function handleDelete() {
if (deleteId === null) return;
try {
await api(`/api/schedules/${deleteId}`, { method: 'DELETE' });
toast.success('Schedule deleted');
setDeleteId(null);
load();
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
function syncPairName(id: number) {
const p = syncPairs.find(p => p.id === id);
return p ? p.name : `Sync Pair ${id}`;
}
function formatNextRun(nextRun: string | null) {
if (!nextRun) return 'Not scheduled';
const d = new Date(nextRun);
return d.toLocaleString();
}
return (
<div className="space-y-6">
<PageHeader
title="Schedules"
description="Automate sync pair execution with cron-based scheduling"
actions={
<Button onClick={openCreate} size="sm">
<Plus className="h-4 w-4" />
Add Schedule
</Button>
}
/>
<Card>
<div className="p-0">
{schedules.length === 0 ? (
<EmptyState
icon={<Clock className="h-5 w-5" />}
title="No schedules"
description="Create a schedule to automate sync pair execution"
action={
<Button onClick={openCreate} size="sm">
<Plus className="h-4 w-4" />
Add Schedule
</Button>
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Sync Pair</TableHead>
<TableHead>Cron Expression</TableHead>
<TableHead>Next Run</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-28">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{schedules.map(s => (
<TableRow key={s.id}>
<TableCell className="font-medium">{syncPairName(s.sync_pair_id)}</TableCell>
<TableCell>
<code className="text-xs bg-surface-raised px-2 py-1 rounded font-mono">
{s.cron_expr}
</code>
</TableCell>
<TableCell className="text-fg-muted text-sm">
{formatNextRun(s.next_run_at)}
</TableCell>
<TableCell>
<button
onClick={() => handleToggleEnabled(s)}
className={cn(
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 focus:ring-offset-2 focus:ring-offset-canvas',
s.enabled ? 'bg-emerald-500/20' : 'bg-surface-raised'
)}
>
<span
className={cn(
'inline-block h-3.5 w-3.5 transform rounded-full bg-fg-muted transition-transform',
s.enabled ? 'translate-x-4 bg-emerald-400' : 'translate-x-1 bg-fg-subtle'
)}
/>
</button>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setDeleteId(s.id)}
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</Card>
<Modal open={modalOpen} onOpenChange={setModalOpen}>
<ModalContent size="md">
<ModalHeader>
<ModalTitle>Add Schedule</ModalTitle>
<ModalDescription>
Schedule automated sync pair execution using cron syntax.
Format: &quot;minute hour day-of-month month day-of-week&quot;
</ModalDescription>
</ModalHeader>
<form onSubmit={handleSubmit}>
<ModalBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="sch-sync-pair" required>
Sync Pair
</Label>
<Select
value={form.sync_pair_id?.toString() ?? ''}
onValueChange={v => setForm({ ...form, sync_pair_id: Number(v) })}
>
<SelectTrigger id="sch-sync-pair">
<SelectValue placeholder="Select a sync pair" />
</SelectTrigger>
<SelectContent>
{syncPairs.map(p => (
<SelectItem key={p.id} value={p.id.toString()}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="sch-cron" required>
Cron Expression
</Label>
<Input
id="sch-cron"
placeholder="0 2 * * *"
value={form.cron_expr}
onChange={e => setForm({ ...form, cron_expr: e.target.value })}
className="font-mono"
/>
<p className="text-xs text-fg-subtle">
Format: &quot;m h dom mon dow&quot; (5 fields, no seconds)
<br />
Examples: &quot;0 2 * * *&quot; (daily at 2am), &quot;0 */6 * * *&quot; (every 6 hours)
</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="sch-enabled"
checked={form.enabled}
onChange={e => setForm({ ...form, enabled: e.target.checked })}
className="h-4 w-4 rounded border-border accent-accent"
/>
<Label htmlFor="sch-enabled" className="cursor-pointer mb-0">
Enable this schedule
</Label>
</div>
</ModalBody>
<ModalFooter>
<Button
type="button"
variant="secondary"
onClick={() => setModalOpen(false)}
>
Cancel
</Button>
<Button type="submit" loading={loading}>
Add Schedule
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Delete Schedule</ModalTitle>
<ModalDescription>
Are you sure you want to delete this schedule?
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button variant="danger-solid" onClick={handleDelete}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}