183 lines
6.3 KiB
TypeScript
183 lines
6.3 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { api, JobRun } from "../api/haixun";
|
||
import { Badge, Button } from "./ui";
|
||
import { formatNano } from "../lib/format";
|
||
import { jobStatusBadgeClass, jobStatusLabel } from "../lib/jobStatus";
|
||
import { AcIcon } from "./AcIcon";
|
||
|
||
const ACTIVE_STATUSES = ["running", "queued", "pending", "waiting_worker", "cancel_requested"];
|
||
|
||
export function JobMonitor() {
|
||
const [jobs, setJobs] = useState<JobRun[]>([]);
|
||
const [open, setOpen] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const activeJobs = jobs.filter((j) => ACTIVE_STATUSES.includes(j.status));
|
||
|
||
async function load() {
|
||
try {
|
||
const data = await api.jobs(1);
|
||
setJobs(data.list || []);
|
||
} catch {
|
||
// silent fail for background monitor
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
const timer = window.setInterval(() => void load(), 4000);
|
||
return () => window.clearInterval(timer);
|
||
}, []);
|
||
|
||
async function cancel(job: JobRun) {
|
||
setLoading(true);
|
||
try {
|
||
await api.cancelJob(job.id);
|
||
await load();
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
const count = activeJobs.length;
|
||
|
||
return (
|
||
<>
|
||
{/* Floating trigger button (always visible) */}
|
||
<button
|
||
onClick={() => setOpen(!open)}
|
||
className="job-fab"
|
||
style={{
|
||
position: "fixed",
|
||
bottom: "calc(4.5rem + env(safe-area-inset-bottom, 0px))",
|
||
right: "1rem",
|
||
zIndex: 110,
|
||
background: count > 0 ? "var(--hx-brand)" : "var(--hx-surface-muted)",
|
||
color: count > 0 ? "white" : "var(--hx-muted)",
|
||
border: count > 0 ? "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} 個任務進行中` : "查看任務狀態"}
|
||
>
|
||
<AcIcon name="job" size={17} />
|
||
<span>
|
||
{count > 0 ? `${count} 進行中` : "任務"}
|
||
</span>
|
||
</button>
|
||
|
||
{/* Floating panel */}
|
||
{open && (
|
||
<div
|
||
style={{
|
||
position: "fixed",
|
||
bottom: "calc(6.5rem + env(safe-area-inset-bottom, 0px))",
|
||
right: "1rem",
|
||
zIndex: 120,
|
||
width: "min(340px, 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 ? (
|
||
<p className="muted" style={{ padding: "0.5rem 0.3rem" }}>目前沒有進行中的任務。</p>
|
||
) : (
|
||
activeJobs.map((job) => (
|
||
<div
|
||
key={job.id}
|
||
style={{
|
||
border: "1px solid 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" }}>{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>
|
||
)}
|
||
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.72rem" }}>
|
||
<span className="muted">{formatNano(job.update_at)}</span>
|
||
|
||
{["running", "queued", "pending"].includes(job.status) && (
|
||
<Button
|
||
variant="danger"
|
||
onClick={() => void cancel(job)}
|
||
disabled={loading}
|
||
style={{ fontSize: "0.7rem", padding: "0.15rem 0.5rem", minHeight: "1.6rem" }}
|
||
>
|
||
取消
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ fontSize: "0.65rem", color: "var(--hx-subtle)", marginTop: "0.2rem", wordBreak: "break-all" }}>
|
||
{job.id}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|