feat: complete SyncServer implementation

Full-stack Go monolith with embedded React frontend for orchestrating
rsync-over-SSH file synchronization with Wake-on-LAN support.

Features:
- JWT auth (HS256) with bcrypt password hashing
- CRUD for machines (with WoL config) and sync_pairs
- Ed25519 SSH key generation and known_hosts management
- WoL magic packet sender + TCP-connect waiter with backoff
- Sync engine: rsync subprocess, per-pair job queue, progress parsing
- Homebrew cron parser for scheduled syncs
- SSE stream for live job status (queued/waking_up/running/success/failed)
- React+TS+Vite+Tailwind SPA embedded via embed.FS
- Debian packaging with systemd unit, postinst/prerm/postrm

Tech stack:
- Go 1.22+ (CGO_ENABLED=0, pure SQLite via modernc.org/sqlite)
- chi router for HTTP API
- TypeScript + React 18 + Tailwind CSS frontend
- Cross-compiled to Linux amd64 for Proxmox LXC deployment

Tests: wol (MAC parsing, magic packet), syncengine/queue, scheduler/cron
This commit is contained in:
2026-07-07 15:03:22 -04:00
parent 1a66ac58cd
commit 8e08c73f60
69 changed files with 7949 additions and 152 deletions
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useState, useRef } from 'react';
import { api, Job, SyncPair } from '../api/client';
interface SSEEvent {
type: string;
job_id: number;
status?: string;
line?: string;
stream?: string;
}
export default function JobHistory() {
const [jobs, setJobs] = useState<Job[]>([]);
const [pairs, setPairs] = useState<SyncPair[]>([]);
const esRef = useRef<EventSource | null>(null);
useEffect(() => {
load();
const es = new EventSource('/api/jobs/stream');
esRef.current = es;
es.onmessage = (e) => {
const evt: SSEEvent = JSON.parse(e.data);
if (evt.type === 'status') {
setJobs(prev => prev.map(j => j.id === evt.job_id ? { ...j, status: evt.status! } : j));
}
};
return () => es.close();
}, []);
async function load() {
try {
const [j, p] = await Promise.all([
api<Job[]>('/api/jobs?limit=100'),
api<SyncPair[]>('/api/sync-pairs'),
]);
setJobs(j);
setPairs(p);
} catch {}
}
async function cancel(id: number) {
try {
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
load();
} catch { alert('Cancel failed'); }
}
function pairName(id: number) {
const p = pairs.find(p => p.id === id);
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';
}
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Job History</h1>
<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">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">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">
<td className="p-3">{j.id}</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">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
<td className="p-3">{j.finished_at ? new Date(j.finished_at).toLocaleString() : '-'}</td>
<td className="p-3">
{['queued', 'waking_up', 'running'].includes(j.status) && (
<button onClick={() => cancel(j.id)} className="text-red-400 hover:text-red-300">Cancel</button>
)}
</td>
</tr>
))}
{jobs.length === 0 && <tr><td colSpan={7} className="p-4 text-center text-gray-500">No jobs</td></tr>}
</tbody>
</table>
</div>
);
}