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
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api, Machine, Job } from '../api/client';
export default function Dashboard() {
const [machines, setMachines] = useState<Machine[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
useEffect(() => {
Promise.all([
api<Machine[]>('/api/machines'),
api<Job[]>('/api/jobs?limit=5'),
]).then(([m, j]) => {
setMachines(m);
setJobs(j);
}).catch(() => {});
}, []);
const online = machines.filter(m => m.status.startsWith('online')).length;
const todayJobs = jobs.filter(j => {
if (!j.started_at) return false;
return j.started_at.startsWith(new Date().toISOString().split('T')[0]);
}).length;
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>
<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>
))}
</tbody>
</table>
)}
</div>
<div className="mt-4 flex gap-4">
<Link to="/machines" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">Machines</Link>
<Link to="/sync-pairs" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">Sync Pairs</Link>
<Link to="/jobs" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">All Jobs</Link>
</div>
</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>
);
}