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:
+129
-61
@@ -1,44 +1,54 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
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);
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterPair, setFilterPair] = useState('');
|
||||
const [filterRange, setFilterRange] = useState('7d');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
useEffect(() => {
|
||||
loadPairs();
|
||||
}, []);
|
||||
|
||||
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();
|
||||
}, []);
|
||||
}, [filterStatus, filterPair, filterRange, page]);
|
||||
|
||||
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);
|
||||
let url = `/api/jobs?limit=${limit}&offset=${page * limit}`;
|
||||
if (filterStatus) url += `&status=${filterStatus}`;
|
||||
if (filterPair) url += `&sync_pair_id=${filterPair}`;
|
||||
if (filterRange === '24h') {
|
||||
const from = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
|
||||
url += `&from=${encodeURIComponent(from)}`;
|
||||
} else if (filterRange === '7d') {
|
||||
const from = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();
|
||||
url += `&from=${encodeURIComponent(from)}`;
|
||||
} else if (filterRange === '30d') {
|
||||
const from = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString();
|
||||
url += `&from=${encodeURIComponent(from)}`;
|
||||
}
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
const totalCount = res.headers.get('X-Total-Count');
|
||||
if (totalCount) setTotal(Number(totalCount));
|
||||
const data = await res.json();
|
||||
setJobs(data);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadPairs() {
|
||||
try { setPairs(await api<SyncPair[]>('/api/sync-pairs')); } catch {}
|
||||
}
|
||||
|
||||
async function cancel(id: number) {
|
||||
if (!confirm('Cancel this job?')) return;
|
||||
try {
|
||||
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
||||
load();
|
||||
@@ -58,44 +68,102 @@ export default function JobHistory() {
|
||||
return map[s] || 'bg-gray-600';
|
||||
}
|
||||
|
||||
function duration(j: Job) {
|
||||
if (!j.started_at) return '-';
|
||||
const start = new Date(j.started_at).getTime();
|
||||
const end = j.finished_at ? new Date(j.finished_at).getTime() : Date.now();
|
||||
const secs = Math.round((end - start) / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
const rem = secs % 60;
|
||||
if (mins < 60) return `${mins}m ${rem}s`;
|
||||
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
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>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Job History</h1>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<select value={filterPair} onChange={e => { setFilterPair(e.target.value); setPage(0); }}
|
||||
className="bg-gray-700 text-white rounded px-2 py-1.5">
|
||||
<option value="">All Pairs</option>
|
||||
{pairs.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(0); }}
|
||||
className="bg-gray-700 text-white rounded px-2 py-1.5">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="waking_up">Waking Up</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select value={filterRange} onChange={e => { setFilterRange(e.target.value); setPage(0); }}
|
||||
className="bg-gray-700 text-white rounded px-2 py-1.5">
|
||||
<option value="24h">Last 24h</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="all">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<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">Duration</th>
|
||||
<th className="p-3">Started</th>
|
||||
<th className="p-3">Finished</th>
|
||||
<th className="p-3">Actions</th>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && <tr><td colSpan={7} className="p-4 text-center text-gray-500">No jobs</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(j => (
|
||||
<tr key={j.id} className="border-t border-gray-700 hover:bg-gray-750 cursor-pointer"
|
||||
onClick={() => window.location.href = `/jobs/${j.id}`}>
|
||||
<td className="p-3 text-blue-400 hover:text-blue-300">
|
||||
<Link to={`/jobs/${j.id}`}>#{j.id}</Link>
|
||||
</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 text-gray-400 text-xs">{duration(j)}</td>
|
||||
<td className="p-3 text-xs">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
|
||||
<td className="p-3 text-xs">{j.finished_at ? new Date(j.finished_at).toLocaleString() : '-'}</td>
|
||||
<td className="p-3" onClick={e => e.stopPropagation()}>
|
||||
{['queued', 'waking_up', 'running'].includes(j.status) && (
|
||||
<button onClick={() => cancel(j.id)} className="text-red-400 hover:text-red-300 text-xs">Cancel</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && <tr><td colSpan={8} className="p-4 text-center text-gray-500">No jobs</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-gray-800 px-4 py-3 flex items-center justify-between border-t border-gray-700">
|
||||
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="text-sm text-gray-400 hover:text-white disabled:opacity-50">← Previous</button>
|
||||
<span className="text-gray-400 text-sm">{page + 1} / {totalPages} ({total} total)</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
|
||||
className="text-sm text-gray-400 hover:text-white disabled:opacity-50">Next →</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user