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:
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { api, Job, LogLine, SyncPair } from '../api/client';
|
||||
|
||||
interface SSEEvent {
|
||||
type: string;
|
||||
job_id: number;
|
||||
status?: string;
|
||||
line?: string;
|
||||
stream?: string;
|
||||
}
|
||||
|
||||
export default function JobDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [pair, setPair] = useState<SyncPair | null>(null);
|
||||
const [logs, setLogs] = useState<LogLine[]>([]);
|
||||
const [lines, setLines] = useState<{ stream: string; text: string }[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const jobId = Number(id);
|
||||
|
||||
useEffect(() => {
|
||||
loadJob();
|
||||
if (jobId) {
|
||||
loadLogs(0);
|
||||
const es = new EventSource(`/api/jobs/${jobId}/log/stream?job_id=${jobId}`);
|
||||
esRef.current = es;
|
||||
es.onmessage = (e) => {
|
||||
const evt: SSEEvent = JSON.parse(e.data);
|
||||
if (evt.type === 'log') {
|
||||
setLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
|
||||
}
|
||||
if (evt.type === 'status') {
|
||||
setJob(prev => prev ? { ...prev, status: evt.status! } : prev);
|
||||
}
|
||||
};
|
||||
}
|
||||
return () => esRef.current?.close();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [lines, autoScroll]);
|
||||
|
||||
async function loadJob() {
|
||||
try {
|
||||
const j = await api<Job>(`/api/jobs/${id}`);
|
||||
setJob(j);
|
||||
const pairs = await api<SyncPair[]>('/api/sync-pairs');
|
||||
const p = pairs.find((sp: SyncPair) => sp.id === j.sync_pair_id);
|
||||
setPair(p || null);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadLogs(offset: number) {
|
||||
try {
|
||||
const ls = await api<LogLine[]>(`/api/jobs/${id}/log?offset=${offset}&limit=1000`);
|
||||
if (offset === 0) {
|
||||
setLogs(ls);
|
||||
} else {
|
||||
setLogs(prev => [...prev, ...ls]);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (!confirm('Cancel this job?')) return;
|
||||
try {
|
||||
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
||||
loadJob();
|
||||
} catch { alert('Cancel failed'); }
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
if (!job) return <div className="p-6 text-gray-400">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 h-screen flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link to="/jobs" className="text-gray-400 hover:text-white text-sm">← Job History</Link>
|
||||
<h1 className="text-2xl font-bold">Job #{job.id}</h1>
|
||||
<span className={`${statusColor(job.status)} text-white text-xs px-2 py-0.5 rounded`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-4 mb-4 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs">Sync Pair</div>
|
||||
<div className="text-white font-medium">{pair?.name || `Pair ${job.sync_pair_id}`}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs">Trigger</div>
|
||||
<div className="text-white">{job.trigger_type}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs">Duration</div>
|
||||
<div className="text-white">{duration(job)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs">Started</div>
|
||||
<div className="text-white text-xs">{job.started_at ? new Date(job.started_at).toLocaleString() : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{['queued', 'waking_up', 'running'].includes(job.status) && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button onClick={cancel} className="bg-red-600 hover:bg-red-700 text-white px-4 py-1.5 rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<label className="flex items-center gap-2 text-gray-400 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)} />
|
||||
Auto-scroll
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 bg-gray-900 rounded-lg overflow-hidden flex flex-col min-h-0">
|
||||
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-gray-400 text-xs font-mono">Output</span>
|
||||
<span className="text-gray-500 text-xs">{lines.length + logs.length} lines</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5">
|
||||
{logs.map(l => (
|
||||
<div key={l.id} className={l.stream === 'stderr' ? 'text-red-400' : 'text-gray-300'}>
|
||||
<span className="text-gray-600 mr-2">{((): string => {
|
||||
const d = new Date(l.timestamp);
|
||||
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}`;
|
||||
})()}</span>
|
||||
{l.content}
|
||||
</div>
|
||||
))}
|
||||
{lines.map((l, i) => (
|
||||
<div key={`live-${i}`} className={l.stream === 'stderr' ? 'text-red-400' : 'text-gray-300'}>
|
||||
<span className="text-gray-600 mr-2">LIVE</span>
|
||||
{l.text}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user