Files
move-data-nas/web/src/pages/JobDetail.tsx
T
darroyo 84b185be39 Phase A-E: stability, security, observability, and test coverage
Phase A - Stability:
- Engine.Start(): recovers orphaned jobs (running/queued/waking_up) after crash
- Engine.Stop(): graceful shutdown - cancels all in-flight jobs and waits
- Queue keyed by jobID (not syncPairID): cancel now targets exact job
- Local rsync uses jobCtx (context.Background() replaced)
- Migrations wrapped in transactions; checksums stored

Phase B - Security:
- admin/admin default removed: SYNCSERVER_ADMIN_PASSWORD required on first run
- Path validation: rejects .., leading -, null bytes in sync pair paths
- Rsync flags allowlist: dangerous flags blocked (--rsync-path, -e, --files-from)
- Shell concat in RunRemote replaced with proper sh -c escaping
- knownhosts: replaced custom parser with golang.org/x/crypto/ssh/knownhosts
- RequireAdmin wired: machine CRUD, SSH key ops, settings require admin role
- deploy-keys: uses authorized_keys only (no private key upload)
- Hardcoded /var/lib/syncserver/ssh paths replaced with cfg.SSHDir()

Phase C - Operational:
- /readyz health check: DB query + SSH dir accessibility
- /metrics endpoint: Prometheus text format (jobs, queue, machines)
- Event struct JSON tags: job_id, machine_id, type (snake_case)
- EventBus broadcast: fanned out to all subscribers
- SQLite VACUUM INTO backup: scheduled before cleanup if BackupDir set
- Filesystem job log cleanup: removes .log files for purged jobs
- Backup retention: old backups auto-purged

Phase D - Frontend:
- Schedules page: REST API + full CRUD UI for cron schedules
- Dashboard: cancel button for running/queued jobs
- JobDetail: server-side log download via API
- Settings: displays data_dir from server
- 404 page: proper NotFound component

Phase E - Tests:
- auth_test.go: JWT, bcrypt, middleware, seed (18 tests)
- models_test.go: Job, Machine, SyncPair, Schedule repos (18 tests)
- go test -race: no data races found
2026-07-19 22:14:30 -04:00

607 lines
20 KiB
TypeScript

import { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api, apiRaw } from '../api/client';
import type { Job, LogLine, SyncPair } from '../api/client';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch';
import { Card } from '@/components/ui/Card';
import { Spinner } from '@/components/ui/Spinner';
import { CopyButton } from '@/components/ui/CopyButton';
import { Textarea } from '@/components/ui/Textarea';
import { Label } from '@/components/ui/Label';
import {
Modal,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalBody,
ModalFooter,
} from '@/components/ui/Modal';
import { ErrorDetailsModal } from '@/components/ErrorDetailsModal';
import {
ArrowLeft,
Download,
XCircle,
ScrollText,
Terminal,
AlertCircle,
Ban,
ChevronDown,
Activity,
} from 'lucide-react';
import { toast } from 'sonner';
import { statusVariant, statusLabel, getErrorCodeInfo } from '@/lib/status';
import { formatDuration } from '@/lib/utils';
import { cn } from '@/lib/utils';
interface SSEProgress {
file_bytes: number;
pct: number;
speed_bps: number;
eta_seconds: number;
xfr_done: number;
xfr_total: number;
}
interface SSEEvent {
type: string;
job_id: number;
status?: string;
line?: string;
stream?: string;
progress?: SSEProgress;
totalBytes?: number;
sentBytes?: number;
}
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 [liveLines, setLiveLines] = 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);
const [loading, setLoading] = useState(true);
const [cancelModal, setCancelModal] = useState(false);
const [cancelReason, setCancelReason] = useState('');
const [errorModal, setErrorModal] = useState(false);
const [progress, setProgress] = useState<SSEProgress | null>(null);
const [finalTotals, setFinalTotals] = useState<{ totalBytes: number; sentBytes: number } | null>(null);
const [hasMoreLogs, setHasMoreLogs] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
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') {
setLiveLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
}
if (evt.type === 'status') {
setJob(prev => {
if (!prev) return prev;
const updated = { ...prev, status: evt.status! };
if (evt.line) updated.error_message = evt.line;
return updated;
});
}
if (evt.type === 'progress' && evt.progress) {
setProgress(evt.progress);
}
if (evt.type === 'progress_total' && evt.totalBytes !== undefined && evt.sentBytes !== undefined) {
setFinalTotals({ totalBytes: evt.totalBytes, sentBytes: evt.sentBytes });
setProgress(null);
}
};
}
return () => esRef.current?.close();
}, [id]);
useEffect(() => {
if (autoScroll && logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [liveLines, 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 {
} finally {
setLoading(false);
}
}
async function loadLogs(offset: number) {
try {
const ls = (await api<LogLine[]>(
`/api/jobs/${id}/log?offset=${offset}&limit=1000`
)) ?? [];
if (offset === 0) {
setLogs(ls);
setHasMoreLogs(ls.length === 1000);
} else {
setLogs(prev => [...prev, ...ls]);
setHasMoreLogs(ls.length === 1000);
}
} catch {}
}
async function cancel() {
try {
await api(`/api/jobs/${id}/cancel`, {
method: 'POST',
body: { reason: cancelReason.trim() || undefined },
});
toast.success('Job cancelled');
setCancelModal(false);
setCancelReason('');
loadJob();
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
async function downloadLog() {
try {
const resp = await apiRaw(`/api/jobs/${id}/log/download`);
if (resp.ok && resp.body) {
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `job-${id}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
fallbackDownload();
}
} catch {
fallbackDownload();
}
}
function fallbackDownload() {
const allLines = [
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
];
const blob = new Blob(allLines as string[], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `job-${id}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
const copyLog = useCallback(() => {
const text = [
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
].join('\n');
navigator.clipboard.writeText(text);
toast.success('Log copied to clipboard');
}, [logs, liveLines]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Spinner size="lg" />
</div>
);
}
if (!job) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
<p className="text-fg-muted">Job not found</p>
<Button variant="secondary" asChild>
<Link to="/jobs">
<ArrowLeft className="h-4 w-4" />
Back to Jobs
</Link>
</Button>
</div>
);
}
const totalLines = logs.length + liveLines.length;
const fullErrorLog = [
...logs.filter(l => l.stream === 'stderr').map(l => l.content),
...liveLines.filter(l => l.stream === 'stderr').map(l => l.text),
].join('\n');
const errorInfo = getErrorCodeInfo(job.error_code ?? null);
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link to="/jobs">
<ArrowLeft className="h-4 w-4" />
Job History
</Link>
</Button>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-fg font-mono">#{job.id}</h1>
<Badge variant={statusVariant(job.status)} label={statusLabel(job.status)} />
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: 'Sync Pair', value: pair?.name || `Pair ${job.sync_pair_id}` },
{ label: 'Trigger', value: job.trigger_type },
{
label: 'Duration',
value: job.started_at
? formatDuration(
(job.finished_at
? new Date(job.finished_at).getTime()
: Date.now()) -
new Date(job.started_at).getTime()
)
: '-',
},
{
label: 'Started',
value: job.started_at
? new Date(job.started_at).toLocaleString()
: '-',
},
].map(item => (
<Card key={item.label}>
<div className="p-4">
<div className="text-xs font-medium text-fg-muted uppercase tracking-wider mb-1">
{item.label}
</div>
<div className="text-sm font-semibold text-fg truncate">
{item.value}
</div>
</div>
</Card>
))}
</div>
{(job.status === 'failed' || (job.status === 'cancelled' && job.error_message)) && (
<div className={cn(
"rounded-card border p-4 space-y-2",
job.status === 'failed'
? "border-rose-500/30 bg-rose-500/10"
: "border-zinc-500/30 bg-zinc-500/10"
)}>
<div className="flex items-start gap-2">
{job.status === 'failed'
? <AlertCircle className="h-4 w-4 text-rose-400 shrink-0 mt-0.5" />
: <Ban className="h-4 w-4 text-zinc-400 shrink-0 mt-0.5" />
}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={cn(
"text-sm font-semibold",
job.status === 'failed' ? "text-rose-300" : "text-zinc-300"
)}>
{errorInfo?.title ?? (job.status === 'failed' ? 'Job failed' : 'Job cancelled')}
</span>
{job.error_code && (
<span className={cn(
"text-xs font-mono",
job.status === 'failed' ? "text-rose-400/60" : "text-zinc-400/60"
)}>
[{job.error_code}]
</span>
)}
</div>
{errorInfo?.hint && (
<p className={cn(
"text-xs mt-0.5",
job.status === 'failed' ? "text-rose-400/70" : "text-zinc-400/70"
)}>
{errorInfo.hint}
</p>
)}
<p className={cn(
"text-xs font-mono mt-1 truncate max-w-2xl",
job.status === 'failed' ? "text-rose-300/80" : "text-zinc-300/80"
)}>
{job.error_message}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setErrorModal(true)}
className={cn(
"shrink-0 border",
job.status === 'failed'
? "text-rose-300 hover:text-rose-200 border-rose-500/40 hover:border-rose-400/60"
: "text-zinc-300 hover:text-zinc-200 border-zinc-500/40 hover:border-zinc-400/60"
)}
>
{job.status === 'failed' ? <AlertCircle className="h-4 w-4" /> : <Ban className="h-4 w-4" />}
View error
</Button>
</div>
</div>
)}
{['queued', 'waking_up', 'running'].includes(job.status) && (
<div className="flex items-center gap-3">
<Button
variant="danger"
size="sm"
onClick={() => setCancelModal(true)}
>
<XCircle className="h-4 w-4" />
Cancel Job
</Button>
<div className="flex items-center gap-2 text-sm text-fg-muted">
<Switch
id="auto-scroll"
checked={autoScroll}
onCheckedChange={setAutoScroll}
/>
<label htmlFor="auto-scroll" className="cursor-pointer">
Auto-scroll
</label>
</div>
</div>
)}
{(progress || finalTotals) && (
<TransferProgress progress={progress} finalTotals={finalTotals} />
)}
<Card className="flex flex-col min-h-0">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-fg-subtle" />
<span className="text-xs font-semibold text-fg-muted uppercase tracking-wider">
Output
</span>
<span className="text-xs text-fg-subtle">
{totalLines} line{totalLines !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-1">
<CopyButton
text={[
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
].join('\n')}
displayText="Copy log"
/>
<Button variant="ghost" size="icon-sm" onClick={downloadLog} title="Download log">
<Download className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5 scrollbar-thin min-h-[300px] max-h-[60vh]">
{logs.length === 0 && liveLines.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-fg-subtle gap-2">
<ScrollText className="h-6 w-6" />
<p>No log output yet</p>
</div>
) : (
<>
{logs.map(l => (
<LogLine
key={l.id}
stream={l.stream}
content={l.content}
timestamp={l.timestamp}
/>
))}
{liveLines.map((l, i) => (
<div
key={`live-${i}`}
className={cn(
'flex gap-2',
l.stream === 'stderr'
? 'text-rose-400'
: 'text-fg-muted'
)}
>
<span className="text-accent shrink-0">LIVE</span>
<span className="break-all">{l.text}</span>
</div>
))}
</>
)}
<div ref={logEndRef} />
{hasMoreLogs && (
<div className="flex justify-center py-2">
<Button
variant="secondary"
size="sm"
onClick={async () => {
setLoadingMore(true);
await loadLogs(logs.length);
setLoadingMore(false);
}}
disabled={loadingMore}
>
<ChevronDown className="h-4 w-4" />
{loadingMore ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</div>
</Card>
<Modal open={cancelModal} onOpenChange={setCancelModal}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Cancel Job</ModalTitle>
<ModalDescription>
Are you sure you want to cancel job #{job.id}? This action cannot
be undone.
</ModalDescription>
</ModalHeader>
<form onSubmit={e => { e.preventDefault(); cancel(); }}>
<ModalBody>
<div className="space-y-1.5">
<Label htmlFor="cancel-reason">Reason (optional)</Label>
<Textarea
id="cancel-reason"
value={cancelReason}
onChange={e => setCancelReason(e.target.value)}
placeholder="e.g. wrong path, machine offline..."
rows={2}
className="font-mono text-xs"
/>
<p className="text-xs text-fg-subtle">
Adding a reason helps track why jobs are cancelled
</p>
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" type="button" onClick={() => { setCancelModal(false); setCancelReason(''); }}>
Keep Running
</Button>
<Button variant="danger-solid" type="submit">
Cancel Job
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
<ErrorDetailsModal
open={errorModal}
onOpenChange={setErrorModal}
job={job}
fullLog={fullErrorLog}
/>
</div>
);
}
function LogLine({
stream,
content,
timestamp,
}: {
stream: string;
content: string;
timestamp: string;
}) {
const d = new Date(timestamp);
const timeStr = `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`;
return (
<div
className={cn(
'flex gap-2',
stream === 'stderr' ? 'text-rose-400' : 'text-fg-muted'
)}
>
<span className="text-fg-subtle shrink-0">{timeStr}</span>
<span className="break-all">{content}</span>
</div>
);
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
function formatSpeed(bps: number): string {
if (bps === 0) return '0 B/s';
const units = ['B/s', 'kB/s', 'MB/s', 'GB/s'];
const i = Math.floor(Math.log(bps) / Math.log(1000));
return `${(bps / Math.pow(1000, i)).toFixed(1)} ${units[i]}`;
}
function TransferProgress({
progress,
finalTotals,
}: {
progress: SSEProgress | null;
finalTotals: { totalBytes: number; sentBytes: number } | null;
}) {
const globalPct = finalTotals && finalTotals.totalBytes > 0
? Math.round((finalTotals.sentBytes / finalTotals.totalBytes) * 100)
: null;
return (
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-accent" />
<span className="text-xs font-semibold text-fg-muted uppercase tracking-wider">
Transfer Progress
</span>
</div>
{progress && (
<>
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-fg-muted">
<span>Current file</span>
<span>{progress.pct}%</span>
</div>
<div className="h-2 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-accent transition-all duration-300 rounded-full"
style={{ width: `${progress.pct}%` }}
/>
</div>
</div>
<div className="flex justify-between text-xs text-fg-muted">
<span className="font-mono">
xfr#{(progress.xfr_done).toLocaleString()}/{progress.xfr_total > 0 ? progress.xfr_total.toLocaleString() : '?'}
</span>
<span className="font-mono">
{formatSpeed(progress.speed_bps)}
</span>
<span className="font-mono">
ETA {progress.eta_seconds > 0 ? `${Math.floor(progress.eta_seconds / 60)}m ${progress.eta_seconds % 60}s` : '-'}
</span>
</div>
</>
)}
{finalTotals && (
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-fg-muted">
<span>Total transferred</span>
<span>{globalPct}% {formatBytes(finalTotals.sentBytes)} / {formatBytes(finalTotals.totalBytes)}</span>
</div>
<div className="h-2 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 transition-all duration-300 rounded-full"
style={{ width: `${globalPct ?? 0}%` }}
/>
</div>
</div>
)}
</div>
</Card>
);
}