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
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, useState, useRef } from 'react';
import { api, SSHKey } from '../api/client';
export default function SSHKeys() {
const [keys, setKeys] = useState<SSHKey[]>([]);
const [showGen, setShowGen] = useState(false);
const [showImport, setShowImport] = useState(false);
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);
useEffect(() => { load(); }, []);
async function load() {
try { setKeys(await api<SSHKey[]>('/api/ssh-keys')); } catch {}
}
async function generate() {
if (!genLabel.trim()) { alert('Label is required'); return; }
setLoading(true);
try {
await api('/api/ssh-keys', {
method: 'POST',
body: { label: genLabel.trim(), generate: true },
});
setShowGen(false);
setGenLabel('');
load();
} catch (e: unknown) { alert((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; }
setLoading(true);
try {
await api('/api/ssh-keys', {
method: 'POST',
body: { label: importLabel.trim(), generate: false, public_key: importPubKey.trim() },
});
setShowImport(false);
setImportLabel('');
setImportPubKey('');
load();
} catch (e: unknown) { alert((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;
try {
await api(`/api/ssh-keys/${id}`, { method: 'DELETE' });
load();
} catch (e: unknown) { alert((e as Error).message); }
}
async function downloadPrivate(id: number) {
try {
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);
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ssh-key-${id}.key`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setDownloading(id);
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);
}
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>
</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>
</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>
</div>
);
}