336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
|
|
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>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|