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

183 lines
6.3 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 { 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>
)}
</>
);
}