import { useCallback, useEffect, useRef, useState } from "react"; import { api, JobRun } from "../api/haixun"; import { Badge, Button } from "./ui"; import { formatNano } from "../lib/format"; import { canCancelJob, canRetryJob } from "../lib/jobActions"; import { jobTemplateLabel } from "../lib/jobLabels"; import { jobStatusBadgeClass, jobStatusLabel } from "../lib/jobStatus"; import { AcIcon } from "./AcIcon"; import type { JobCreatedDetail } from "../lib/jobEvents"; const ACTIVE_STATUSES = ["running", "queued", "pending", "waiting_worker", "cancel_requested"]; const PANEL_STATUSES = [...ACTIVE_STATUSES, "failed"]; function nowNano() { return Date.now() * 1_000_000; } function optimisticJob(detail: JobCreatedDetail): JobRun { return { id: detail.jobId, template_type: detail.templateType || detail.label || "任務", status: detail.status || "queued", progress: { percentage: 0, summary: "剛建立,等待執行…" }, create_at: nowNano(), update_at: nowNano() }; } function JobMonitorItem({ job, actionLoading, onCancel, onRetry, showError }: { job: JobRun; actionLoading: boolean; onCancel: () => void; onRetry: () => void; showError: boolean; }) { return (
{jobTemplateLabel(job.template_type)} {jobStatusLabel(job.status)}
{job.progress ? (
{job.progress.percentage ?? 0}% {job.progress.summary ? ` · ${job.progress.summary}` : ""}
) : null} {showError && job.error?.trim() ? (
{job.error.trim()}
) : null}
{formatNano(job.update_at)}
{canCancelJob(job) ? ( ) : null} {canRetryJob(job) ? ( ) : null}
); } export function JobMonitor() { const [jobs, setJobs] = useState([]); const [open, setOpen] = useState(false); const [actionJobId, setActionJobId] = useState(""); const [hot, setHot] = useState(false); const pendingRef = useRef>(new Map()); const mergeWithPending = useCallback((serverList: JobRun[]) => { const serverIds = new Set(serverList.map((j) => j.id)); for (const id of [...pendingRef.current.keys()]) { if (serverIds.has(id)) pendingRef.current.delete(id); } const pending = [...pendingRef.current.values()].filter((j) => !serverIds.has(j.id)); return [...pending, ...serverList]; }, []); const load = useCallback(async () => { try { const data = await api.jobs(1, 20); setJobs(mergeWithPending(data.list || [])); } catch { // 背景浮層靜默失敗,完整錯誤到任務中心看 } }, [mergeWithPending]); const burstRefresh = useCallback(() => { void load(); window.setTimeout(() => void load(), 350); window.setTimeout(() => void load(), 900); window.setTimeout(() => void load(), 1800); }, [load]); useEffect(() => { void load(); }, [load]); const panelJobs = jobs.filter((j) => PANEL_STATUSES.includes(j.status)); const activeJobs = panelJobs.filter((j) => ACTIVE_STATUSES.includes(j.status)); const failedJobs = panelJobs.filter((j) => j.status === "failed").slice(0, 3); const hasActive = activeJobs.length > 0; const hasFailed = failedJobs.length > 0; useEffect(() => { const intervalMs = hasActive ? 1000 : 4500; const timer = window.setInterval(() => void load(), intervalMs); return () => window.clearInterval(timer); }, [hasActive, load]); useEffect(() => { function onJobCreated(event: Event) { const detail = (event as CustomEvent).detail; if (!detail?.jobId) return; const job = optimisticJob(detail); pendingRef.current.set(detail.jobId, job); setJobs((prev) => { if (prev.some((j) => j.id === detail.jobId)) return prev; return [job, ...prev]; }); setOpen(true); setHot(true); burstRefresh(); window.setTimeout(() => setHot(false), 2400); } function onRefresh() { burstRefresh(); } window.addEventListener("haixun:job-created", onJobCreated); window.addEventListener("haixun:jobs-refresh", onRefresh); return () => { window.removeEventListener("haixun:job-created", onJobCreated); window.removeEventListener("haixun:jobs-refresh", onRefresh); }; }, [burstRefresh]); async function cancel(job: JobRun) { setActionJobId(job.id); try { await api.cancelJob(job.id); pendingRef.current.delete(job.id); await load(); window.dispatchEvent(new CustomEvent("haixun:jobs-refresh")); } catch { // 錯誤細節到任務中心看 } finally { setActionJobId(""); } } async function retry(job: JobRun) { setActionJobId(job.id); try { await api.retryJob(job.id); pendingRef.current.delete(job.id); await load(); window.dispatchEvent(new CustomEvent("haixun:jobs-refresh")); } catch { // 錯誤細節到任務中心看 } finally { setActionJobId(""); } } const count = activeJobs.length; return ( <> {open && (
任務狀態 {count > 0 ? `(${count} 進行中)` : ""}
{activeJobs.length === 0 && failedJobs.length === 0 ? (

目前沒有進行中或失敗的任務。

) : null} {activeJobs.map((job) => ( void cancel(job)} onRetry={() => void retry(job)} showError={false} /> ))} {failedJobs.length > 0 ? ( <>

最近失敗

{failedJobs.map((job) => ( void cancel(job)} onRetry={() => void retry(job)} showError /> ))} ) : null}
)} ); }