Files
platform/src/apps/officer-web/Screens/Dashboard/Jobs/ScriptJobDetail.tsx
T
pastilhas 0b85d42197 jobs keyed (73 -> 21)
The three job detail panes and the jobs page. Status labels share one jobs.status.*
group across all three panes rather than three copies of "Running".

statusBadge in two of the panes is a module-level helper, not a component, so it
takes `t` as a parameter -- the alternative is calling a hook outside a component
body. ScriptJobDetail's version built "Failed (exit N)" by string concatenation;
that is a key with a parameter now.
2026-08-23 05:30:45 +00:00

245 lines
8.1 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, Link } from 'react-router';
import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { toast } from 'sonner';
import { PhaseBar, type DownloadProgress } from './DownloadJobDetail';
import { useTranslation } from '@/lib/i18n';
type TFn = ReturnType<typeof useTranslation>['t'];
type ScriptJob = {
id: string;
mode: string;
taskDirName: string;
taskName: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted';
exitCode: number | null;
error: string | null;
progress: DownloadProgress | null;
createdAt: string;
startedAt: string | null;
completedAt: string | null;
};
const LOG_POLL_MS = 1500;
const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted';
// A script may publish counter-style progress via the `@@officer:progress@@` sentinel (e.g. the
// download-media permission). When shaped like that, render the two phase bars above the log.
const isDownloadProgress = (p: unknown): p is DownloadProgress =>
!!p && typeof p === 'object' && 'meta' in p && 'dl' in p;
// Takes `t` because it is a module-level helper, not a component.
const statusBadge = (status: string, exitCode: number | null, t: TFn) => {
switch (status) {
case 'running':
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
label: t('jobs.status.running'),
cls: 'text-amber-600 dark:text-amber-500',
};
case 'pending':
return {
icon: <Clock className="h-4 w-4" />,
label: t('jobs.status.queued'),
cls: 'text-blue-600 dark:text-blue-400',
};
case 'completed':
return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
case 'failed':
return {
icon: <XCircle className="h-4 w-4" />,
label: exitCode != null ? t('jobs.failedWithCode', { code: exitCode }) : t('jobs.status.failed'),
cls: 'text-red-600 dark:text-red-400',
};
case 'stopped':
return {
icon: <Ban className="h-4 w-4" />,
label: t('jobs.status.stopped'),
cls: 'text-duck-dark/50 dark:text-foreground/50',
};
default:
return {
icon: <XCircle className="h-4 w-4" />,
label: t('jobs.status.interrupted'),
cls: 'text-orange-600 dark:text-orange-400',
};
}
};
export const ScriptJobDetail = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const client = useClient();
const [job, setJob] = useState<ScriptJob | null>(null);
const [output, setOutput] = useState('');
const [loading, setLoading] = useState(true);
const offsetRef = useRef(0);
const preRef = useRef<HTMLPreElement | null>(null);
const autoFollow = useRef(true);
// Append the next slice of the log from the tracked offset.
const pullLog = useCallback(async () => {
if (!id) return;
try {
const res = await client.get<{ text: string; offset: number; size: number }>(
`/jobs/${id}/log?offset=${offsetRef.current}`,
);
if (res.text) {
offsetRef.current = res.offset;
setOutput((prev) => prev + res.text);
}
} catch {
// transient
}
}, [id]);
// Initial load: job + full log so far.
useEffect(() => {
if (!id) return;
let cancelled = false;
(async () => {
try {
const j = await client.get<ScriptJob>(`/jobs/${id}`);
if (cancelled) return;
setJob(j);
await pullLog();
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [id]);
// While live, poll the log + status until the job reaches a terminal state.
useEffect(() => {
if (!id || !job || isTerminal(job.status)) return;
const timer = setInterval(async () => {
await pullLog();
try {
const j = await client.get<ScriptJob>(`/jobs/${id}`);
setJob(j);
if (isTerminal(j.status)) {
clearInterval(timer);
await pullLog(); // final catch-up
}
} catch {
// transient
}
}, LOG_POLL_MS);
return () => clearInterval(timer);
}, [id, job?.status]);
// Auto-scroll to the bottom unless the user scrolled up.
useEffect(() => {
const el = preRef.current;
if (el && autoFollow.current) el.scrollTop = el.scrollHeight;
}, [output]);
const onScroll = () => {
const el = preRef.current;
if (!el) return;
autoFollow.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
};
const stop = async () => {
if (!id) return;
try {
await client.post(`/jobs/${id}/stop`, {});
toast.success(t('jobs.stopRequested'));
} catch {
toast.error(t('jobs.stopFailed'));
}
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center text-duck-dark/50 dark:text-foreground/50">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
);
}
if (!job) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
<span>{t('jobs.notFound')}</span>
<Link to="/jobs" className="text-duck-teal hover:underline">
{t('jobs.backToJobs')}
</Link>
</div>
);
}
const badge = statusBadge(job.status, job.exitCode, t);
const running = !isTerminal(job.status);
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-3">
<Link
to="/jobs"
className="text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium text-duck-dark dark:text-foreground truncate">{job.taskName}</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{job.taskDirName}</span>
</div>
<span className={`ml-auto flex items-center gap-1.5 text-sm ${badge.cls}`}>
{badge.icon} {badge.label}
</span>
{running && (
<button
onClick={stop}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
>
<Square className="h-3 w-3" /> {t('jobs.stop')}
</button>
)}
</div>
{isDownloadProgress(job.progress) && (
<div className="px-5 py-4 border-b border-duck-dark/10 flex flex-col gap-4">
<PhaseBar
label={t('jobs.titles')}
c={job.progress.meta}
active={job.progress.phase === 'metadata'}
savedLabel="found"
failedLabel="skipped"
/>
<PhaseBar
label={t('jobs.download')}
c={job.progress.dl}
active={job.progress.phase === 'download'}
savedLabel="saved"
failedLabel="failed"
/>
{running && job.progress.phase === 'download' && job.progress.current && (
<div className="truncate text-xs text-duck-dark/50 dark:text-foreground/50" title={job.progress.current}>
<Loader2 className="mr-1.5 inline h-3 w-3 animate-spin" />
{job.progress.current}
</div>
)}
</div>
)}
<pre
ref={preRef}
onScroll={onScroll}
className="flex-1 min-h-0 overflow-auto m-0 p-4 text-xs font-mono leading-relaxed text-duck-dark/80 dark:text-foreground/80 bg-duck-dark/[0.03] dark:bg-foreground/[0.03] whitespace-pre-wrap break-words"
>
{output || (running ? '…' : '(no output)')}
</pre>
{job.error && !running && (
<div className="px-5 py-2 border-t border-duck-dark/10 text-xs text-red-600 dark:text-red-400">{job.error}</div>
)}
</div>
);
};