thread-master/backend/web/src/components/JobMonitor.tsx

336 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div
className="job-panel-item"
style={{
border: `1px solid ${job.status === "failed" ? "color-mix(in srgb, var(--hx-danger) 40%, var(--hx-line))" : "var(--hx-line)"}`,
borderRadius: "var(--radius-md)",
padding: "0.55rem 0.7rem",
marginBottom: "0.5rem",
background: "var(--hx-surface)"
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.25rem" }}>
<strong style={{ fontSize: "0.9rem" }}>{jobTemplateLabel(job.template_type)}</strong>
<Badge variant={jobStatusBadgeClass(job.status)}>{jobStatusLabel(job.status)}</Badge>
</div>
{job.progress ? (
<div style={{ fontSize: "0.8rem", color: "var(--hx-ink-secondary)", marginBottom: "0.2rem" }}>
{job.progress.percentage ?? 0}%
{job.progress.summary ? ` · ${job.progress.summary}` : ""}
</div>
) : null}
{showError && job.error?.trim() ? (
<div
style={{
fontSize: "0.78rem",
color: "var(--hx-danger)",
marginBottom: "0.35rem",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
lineHeight: 1.45
}}
>
{job.error.trim()}
</div>
) : null}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.35rem", fontSize: "0.72rem", flexWrap: "wrap" }}>
<span className="muted">{formatNano(job.update_at)}</span>
<div className="button-row" style={{ gap: "0.35rem" }}>
{canCancelJob(job) ? (
<Button
variant="danger"
onClick={onCancel}
loading={actionLoading}
style={{ fontSize: "0.7rem", padding: "0.15rem 0.5rem", minHeight: "1.6rem" }}
>
</Button>
) : null}
{canRetryJob(job) ? (
<Button
variant="primary"
onClick={onRetry}
loading={actionLoading}
style={{ fontSize: "0.7rem", padding: "0.15rem 0.5rem", minHeight: "1.6rem" }}
>
</Button>
) : null}
</div>
</div>
</div>
);
}
export function JobMonitor() {
const [jobs, setJobs] = useState<JobRun[]>([]);
const [open, setOpen] = useState(false);
const [actionJobId, setActionJobId] = useState("");
const [hot, setHot] = useState(false);
const pendingRef = useRef<Map<string, JobRun>>(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<JobCreatedDetail>).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 (
<>
<button
onClick={() => setOpen(!open)}
className={`job-fab${hot || count > 0 || hasFailed ? " job-fab--hot" : ""}`}
style={{
position: "fixed",
bottom: "calc(4.5rem + env(safe-area-inset-bottom, 0px))",
right: "1rem",
zIndex: 110,
background: count > 0 || hasFailed ? "var(--hx-brand)" : "var(--hx-surface-muted)",
color: count > 0 || hasFailed ? "white" : "var(--hx-muted)",
border: count > 0 || hasFailed ? "none" : "1px solid var(--hx-line)",
borderRadius: "999px",
padding: "0.4rem 0.85rem",
fontSize: "0.78rem",
fontWeight: 700,
display: "flex",
alignItems: "center",
gap: "0.4rem",
boxShadow: "var(--shadow-card)",
cursor: "pointer"
}}
title={count > 0 ? `${count} 個任務進行中` : hasFailed ? "有任務失敗" : "查看任務狀態"}
>
<AcIcon name="job" size={17} />
<span>
{count > 0 ? `${count} 進行中` : hasFailed ? "有失敗" : "任務"}
</span>
</button>
{open && (
<div
className="job-panel"
style={{
position: "fixed",
bottom: "calc(6.5rem + env(safe-area-inset-bottom, 0px))",
right: "1rem",
zIndex: 120,
width: "min(360px, calc(100vw - 2rem))",
maxHeight: "58vh",
background: "var(--hx-surface)",
border: "1px solid var(--hx-line)",
borderRadius: "var(--radius-lg)",
boxShadow: "var(--shadow-card)",
display: "flex",
flexDirection: "column",
overflow: "hidden"
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.6rem 0.85rem",
borderBottom: "1px solid var(--hx-line)",
background: "var(--hx-surface-muted)"
}}
>
<div style={{ fontWeight: 700, display: "flex", alignItems: "center", gap: "0.4rem" }}>
<AcIcon name="job" size={16} />
{count > 0 ? `${count} 進行中)` : ""}
</div>
<button
onClick={() => setOpen(false)}
style={{ background: "transparent", border: "none", fontSize: "1.1rem", lineHeight: 1, cursor: "pointer", color: "var(--hx-muted)" }}
aria-label="關閉"
>
×
</button>
</div>
<div style={{ overflowY: "auto", padding: "0.5rem", flex: 1 }}>
{activeJobs.length === 0 && failedJobs.length === 0 ? (
<p className="muted" style={{ padding: "0.5rem 0.3rem" }}></p>
) : null}
{activeJobs.map((job) => (
<JobMonitorItem
key={job.id}
job={job}
actionLoading={actionJobId === job.id}
onCancel={() => void cancel(job)}
onRetry={() => void retry(job)}
showError={false}
/>
))}
{failedJobs.length > 0 ? (
<>
<p className="muted hx-text-sm" style={{ margin: "0.35rem 0.15rem 0.45rem", fontWeight: 700 }}></p>
{failedJobs.map((job) => (
<JobMonitorItem
key={job.id}
job={job}
actionLoading={actionJobId === job.id}
onCancel={() => void cancel(job)}
onRetry={() => void retry(job)}
showError
/>
))}
</>
) : null}
</div>
<div style={{ padding: "0.4rem 0.7rem", borderTop: "1px solid var(--hx-line)", background: "var(--hx-surface-muted)" }}>
<Button
onClick={() => {
window.location.hash = "jobs";
setOpen(false);
}}
style={{ width: "100%", fontSize: "0.75rem" }}
>
</Button>
</div>
</div>
)}
</>
);
}