Add SSH key management, job history persistence, and live streaming

- SSH key management: generate ed25519 keypairs or import public keys
  from UI (/ssh-keys), per-machine key selection in Machines form,
  one-time private key download with hash verification
- Fix engine to use machine-specific SSH key (was hardcoded to server key)
- Job log persistence: write to job_logs table (DB) with batched inserts,
  buffer of 50 lines; GetAllFiltered with status/pair/date range filters
- EventBus refactor: per-job subscriber channels, global channel, non-blocking
- SSE endpoints: /jobs/stream (all), /jobs/:id/log/stream (per-job live)
- JobDetail page: live log streaming, auto-scroll, cancel, duration
- JobHistory: filters (pair, status, date range), pagination, link to detail
- Cleanup scheduler: daily purge of job_logs and finished jobs older than
  SYNCSERVER_RETENTION_DAYS (default 30)
- Migration 0002: indexes on job_logs(job_id), jobs(status,created_at),
  jobs(sync_pair_id)
This commit is contained in:
2026-07-07 20:36:11 -04:00
parent 5374ab81cc
commit bfa006f4ab
22 changed files with 1424 additions and 136 deletions
+36 -8
View File
@@ -1,11 +1,14 @@
import { useEffect, useState } from 'react';
import { api, Machine } from '../api/client';
import { Link } from 'react-router-dom';
import { api, Machine, SSHKey } from '../api/client';
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,
});
@@ -13,7 +16,14 @@ export default function Machines() {
useEffect(() => { load(); }, []);
async function load() {
try { setMachines(await api<Machine[]>('/api/machines')); } catch {}
try {
const [ms, ks] = await Promise.all([
api<Machine[]>('/api/machines'),
api<SSHKey[]>('/api/ssh-keys'),
]);
setMachines(ms);
setSSHKeys(ks);
} catch {}
}
async function handleSubmit(e: React.FormEvent) {
@@ -21,7 +31,8 @@ export default function Machines() {
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, mac_address: form.mac_address || null,
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),
@@ -35,7 +46,7 @@ export default function Machines() {
body: payload,
});
setShowForm(false);
setForm({ id: undefined, name: '', host: '', port: 22, ssh_user: 'root', mac_address: '', wol_enabled: false as boolean, wake_timeout_seconds: 120, wake_check_interval_seconds: 5 });
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); }
}
@@ -43,7 +54,8 @@ export default function Machines() {
function edit(m: Machine) {
setForm({
id: m.id, name: m.name, host: m.host, port: m.port,
ssh_user: m.ssh_user, mac_address: m.mac_address || '',
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,
@@ -56,10 +68,19 @@ export default function Machines() {
try { await api(`/api/machines/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); }
}
function keyLabel(id: number | null) {
if (!id) return 'Server Key';
const k = sshKeys.find(k => k.id === id);
return k ? k.label : `Key #${id}`;
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Machines</h1>
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">Machines</h1>
<Link to="/ssh-keys" className="text-sm text-blue-400 hover:text-blue-300">Manage SSH Keys</Link>
</div>
<button onClick={() => setShowForm(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
Add Machine
</button>
@@ -67,12 +88,17 @@ export default function Machines() {
{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-96 space-y-3">
<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})} />
@@ -91,6 +117,7 @@ export default function Machines() {
<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>
@@ -101,6 +128,7 @@ export default function Machines() {
<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">
@@ -109,7 +137,7 @@ export default function Machines() {
</td>
</tr>
))}
{machines.length === 0 && <tr><td colSpan={5} className="p-4 text-center text-gray-500">No machines</td></tr>}
{machines.length === 0 && <tr><td colSpan={6} className="p-4 text-center text-gray-500">No machines</td></tr>}
</tbody>
</table>
</div>