feat(web): scheduled jobs board (/console/schedules)
- cards grouped by collect / download / organize / maintain / agent checks: switch, in-place interval or cron edit, last result and duration, next-run countdown, failure reason, run now, skip next, reset to default, history drawer - top stats (enabled / failed / running / total) and a scheduler-offline notice; refreshed by the schedule.changed SSE event with a 30s fallback - agent check cards show goal template, trigger, budget and allowlist size; create / edit dialog (read-only capabilities, budget, trigger, interval, report to inbox) - nav entry in the system group, route and smoke route Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a68fcea13b
commit
d800987403
11 files changed
+1024
-3
No files matched your search
@@ -0,0 +1,86 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
AgentCheckCreate,
|
||||
ScheduleBoard,
|
||||
ScheduledJob,
|
||||
ScheduledJobRun,
|
||||
ScheduleRunNowResult,
|
||||
ScheduleUpdate,
|
||||
} from "@umt/contracts";
|
||||
import { apiData } from "~/lib/api.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
|
||||
// 定时任务看板(W7-C):GET /schedules 一次取全部卡片;写操作成功后整块失效(SSE schedule.changed 也会触发)。
|
||||
|
||||
const AREA = "schedules";
|
||||
|
||||
export function useScheduleBoard() {
|
||||
return useQuery({
|
||||
queryKey: qk.list(AREA),
|
||||
queryFn: ({ signal }) => apiData<ScheduleBoard>("/schedules", { signal }),
|
||||
// SSE 是主路径,30 秒兜底
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduleRuns(id: string | null, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: qk.sub(AREA, "runs", id ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiData<{ items: ScheduledJobRun[] }>("/schedules/{id}/runs", {
|
||||
pathParams: { id: id ?? "" },
|
||||
params: { limit: 50 },
|
||||
signal,
|
||||
}),
|
||||
enabled: !!id && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidate() {
|
||||
const qc = useQueryClient();
|
||||
return () => qc.invalidateQueries({ queryKey: qk.all(AREA) });
|
||||
}
|
||||
|
||||
export function useUpdateSchedule() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: ScheduleUpdate }) =>
|
||||
apiData<ScheduledJob>("/schedules/{id}", { method: "PATCH", pathParams: { id }, body }),
|
||||
onSettled: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunScheduleNow() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: ({ id }: { id: string }) =>
|
||||
apiData<ScheduleRunNowResult>("/schedules/{id}/run-now", { method: "POST", pathParams: { id } }),
|
||||
onSettled: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSkipSchedule() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: ({ id }: { id: string }) =>
|
||||
apiData<ScheduledJob>("/schedules/{id}/skip", { method: "POST", pathParams: { id } }),
|
||||
onSettled: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAgentCheck() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: (body: AgentCheckCreate) => apiData<ScheduledJob>("/schedules", { method: "POST", body }),
|
||||
onSettled: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSchedule() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: ({ id }: { id: string }) =>
|
||||
apiData<{ ok: true }>("/schedules/{id}", { method: "DELETE", pathParams: { id } }),
|
||||
onSettled: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import { consolePath, findNavItem, NAV_ITEMS } from "./nav.ts";
|
||||
|
||||
describe("控制台导航", () => {
|
||||
it("12 个工作区 + 引擎面板 + 审计,路径唯一且都在 /console 下", () => {
|
||||
expect(NAV_ITEMS).toHaveLength(15);
|
||||
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(15);
|
||||
it("12 个工作区 + 定时任务 + 引擎面板 + 审计,路径唯一且都在 /console 下", () => {
|
||||
expect(NAV_ITEMS).toHaveLength(16);
|
||||
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(16);
|
||||
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ScrollText,
|
||||
Settings,
|
||||
Tags,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
|
||||
export type NavItem = { to: string; label: string; icon: LucideIcon; description: string };
|
||||
@@ -122,6 +123,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
icon: Settings,
|
||||
description: "下载器与媒体服务器实例、路径映射、API Key、Pi 上游与自动化开关",
|
||||
},
|
||||
{
|
||||
to: "/console/schedules",
|
||||
label: "定时任务",
|
||||
icon: Timer,
|
||||
description: "周期任务与 Agent 巡检的开关、间隔、立即执行与历史",
|
||||
},
|
||||
{
|
||||
to: "/console/engine",
|
||||
label: "工作流引擎",
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import {
|
||||
AgentCheckConfig,
|
||||
type AgentCheckTrigger,
|
||||
CAPABILITY_CATALOG,
|
||||
type KnownCapabilityId,
|
||||
MIN_INTERVAL_MS,
|
||||
type ScheduledJob,
|
||||
} from "@umt/contracts";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useCreateAgentCheck, useUpdateSchedule } from "~/api/schedules.ts";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { CHECK_TRIGGER_LABEL, splitInterval, UNIT_MS } from "./labels.ts";
|
||||
|
||||
/** 巡检只开放只读 / 预览 / 提案类能力 */
|
||||
const READONLY_CAPS = Object.values(CAPABILITY_CATALOG).filter((c) =>
|
||||
(["read", "preview", "propose"] as string[]).includes(c.kind),
|
||||
);
|
||||
const PLACEHOLDERS = "{{now}} {{date}} {{openDecisions}} {{stalledDownloads}} {{activeSubscriptions}}";
|
||||
|
||||
type Unit = "m" | "h";
|
||||
type Draft = {
|
||||
name: string;
|
||||
goal: string;
|
||||
caps: string[];
|
||||
maxToolCalls: string;
|
||||
maxDurationSec: string;
|
||||
trigger: AgentCheckTrigger;
|
||||
every: string;
|
||||
unit: Unit;
|
||||
reportAsDecision: boolean;
|
||||
allowRecover: boolean;
|
||||
};
|
||||
|
||||
function initial(job: ScheduledJob | null): Draft {
|
||||
const c = (job?.config ?? {}) as Partial<AgentCheckConfig>;
|
||||
let every = "6";
|
||||
let unit: Unit = "h";
|
||||
if (job?.schedule.type === "interval") {
|
||||
const s = splitInterval(job.schedule.everyMs);
|
||||
if (s.unit === "h") every = String(s.value);
|
||||
else {
|
||||
every = String(Math.max(1, Math.round(job.schedule.everyMs / UNIT_MS.m)));
|
||||
unit = "m";
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: job?.name ?? "",
|
||||
goal: c.goalTemplate ?? "",
|
||||
caps: c.capabilityAllowlist ?? [],
|
||||
maxToolCalls: c.budget?.maxToolCalls ? String(c.budget.maxToolCalls) : "20",
|
||||
maxDurationSec: c.budget?.maxDurationSec ? String(c.budget.maxDurationSec) : "300",
|
||||
trigger: c.triggerCondition ?? "always",
|
||||
every,
|
||||
unit,
|
||||
reportAsDecision: c.reportAsDecision ?? false,
|
||||
allowRecover: c.allowRecover ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/** 新建 / 编辑 Agent 巡检。内置巡检名称不可改,自定义巡检可改名 */
|
||||
export function AgentCheckDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
job,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
job: ScheduledJob | null;
|
||||
}) {
|
||||
const [d, setD] = useState<Draft>(() => initial(job));
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const create = useCreateAgentCheck();
|
||||
const update = useUpdateSchedule();
|
||||
const pending = create.isPending || update.isPending;
|
||||
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setD((x) => ({ ...x, [k]: v }));
|
||||
const cronJob = job?.schedule.type === "cron";
|
||||
|
||||
const submit = () => {
|
||||
const e: Record<string, string> = {};
|
||||
if ((!job || !job.builtin) && !d.name.trim()) e.name = "名称不能为空";
|
||||
const everyMs = Math.round(Number(d.every) * UNIT_MS[d.unit]);
|
||||
if (!cronJob && (!Number.isFinite(everyMs) || everyMs < MIN_INTERVAL_MS)) e.every = "填正数";
|
||||
const tools = Number(d.maxToolCalls);
|
||||
const secs = Number(d.maxDurationSec);
|
||||
if (!Number.isInteger(tools) || tools <= 0) e.maxToolCalls = "填正整数";
|
||||
if (!Number.isInteger(secs) || secs <= 0) e.maxDurationSec = "填正整数";
|
||||
const cfg = AgentCheckConfig.safeParse({
|
||||
goalTemplate: d.goal,
|
||||
capabilityAllowlist: d.caps,
|
||||
budget: { maxToolCalls: tools, maxDurationSec: secs },
|
||||
triggerCondition: d.trigger,
|
||||
allowRecover: d.allowRecover,
|
||||
reportAsDecision: d.reportAsDecision,
|
||||
});
|
||||
if (!cfg.success && cfg.error.issues.some((i) => i.path[0] === "goalTemplate")) e.goal = "目标至少 4 个字";
|
||||
if (Object.keys(e).length || !cfg.success) {
|
||||
setErrors(Object.keys(e).length ? e : { goal: cfg.error?.issues[0]?.message ?? "配置不合法" });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
const schedule = { type: "interval" as const, everyMs };
|
||||
const done = {
|
||||
onSuccess: () => {
|
||||
toast.success(job ? "已保存" : "巡检已创建");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err: unknown) => toast.error(errorMessage(err)),
|
||||
};
|
||||
if (job)
|
||||
update.mutate(
|
||||
{
|
||||
id: job.id,
|
||||
body: {
|
||||
config: cfg.data,
|
||||
...(cronJob ? {} : { schedule }),
|
||||
...(!job.builtin && d.name.trim() !== job.name ? { name: d.name.trim() } : {}),
|
||||
},
|
||||
},
|
||||
done,
|
||||
);
|
||||
else create.mutate({ name: d.name.trim(), schedule, config: cfg.data, enabled: false }, done);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{job ? "编辑巡检" : "新建巡检"}</DialogTitle>
|
||||
<DialogDescription>到点按目标入队一次 Agent 会话,只在白名单与预算内行动。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<FormField label="名称" htmlFor="ac-name" required={!job?.builtin} error={errors.name}>
|
||||
<Input
|
||||
id="ac-name"
|
||||
value={d.name}
|
||||
disabled={!!job?.builtin}
|
||||
maxLength={60}
|
||||
aria-invalid={!!errors.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="目标" htmlFor="ac-goal" required error={errors.goal} hint={`可用占位符:${PLACEHOLDERS}`}>
|
||||
<Textarea
|
||||
id="ac-goal"
|
||||
rows={4}
|
||||
value={d.goal}
|
||||
aria-invalid={!!errors.goal}
|
||||
placeholder="检查 {{openDecisions}} 条待处理事项,给出处理建议"
|
||||
onChange={(e) => set("goal", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="能力白名单" hint="不选即只读类全部">
|
||||
<div className="grid max-h-48 gap-1 overflow-y-auto rounded-tight border border-border/70 px-2.5 py-2 sm:grid-cols-2">
|
||||
{READONLY_CAPS.map((c) => {
|
||||
const on = d.caps.includes(c.id);
|
||||
return (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex cursor-pointer items-center gap-2 text-caption"
|
||||
title={c.description}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-current"
|
||||
checked={on}
|
||||
onChange={() =>
|
||||
set("caps", on ? d.caps.filter((x) => x !== c.id) : [...d.caps, c.id as KnownCapabilityId])
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{c.title}</span>
|
||||
<code className="truncate text-meta text-muted-foreground">{c.id}</code>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormField>
|
||||
<div className="grid gap-x-3 sm:grid-cols-2">
|
||||
<FormField label="最多工具调用" htmlFor="ac-tools" error={errors.maxToolCalls}>
|
||||
<Input
|
||||
id="ac-tools"
|
||||
type="number"
|
||||
min={1}
|
||||
value={d.maxToolCalls}
|
||||
aria-invalid={!!errors.maxToolCalls}
|
||||
onChange={(e) => set("maxToolCalls", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="最长时长(秒)" htmlFor="ac-secs" error={errors.maxDurationSec}>
|
||||
<Input
|
||||
id="ac-secs"
|
||||
type="number"
|
||||
min={1}
|
||||
value={d.maxDurationSec}
|
||||
aria-invalid={!!errors.maxDurationSec}
|
||||
onChange={(e) => set("maxDurationSec", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="触发条件" htmlFor="ac-trigger" hint="条件不满足时跳过,不唤醒模型">
|
||||
<Select value={d.trigger} onValueChange={(v) => set("trigger", v as AgentCheckTrigger)}>
|
||||
<SelectTrigger id="ac-trigger" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{(Object.keys(CHECK_TRIGGER_LABEL) as AgentCheckTrigger[]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{CHECK_TRIGGER_LABEL[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="间隔"
|
||||
htmlFor="ac-every"
|
||||
error={errors.every}
|
||||
hint={cronJob ? "cron 调度请在卡片上修改" : undefined}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="ac-every"
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={cronJob}
|
||||
value={d.every}
|
||||
aria-invalid={!!errors.every}
|
||||
onChange={(e) => set("every", e.target.value)}
|
||||
/>
|
||||
<Select value={d.unit} disabled={cronJob} onValueChange={(v) => set("unit", v as Unit)}>
|
||||
<SelectTrigger className="w-24" aria-label="单位">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="m">分钟</SelectItem>
|
||||
<SelectItem value="h">小时</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="grid gap-2 pb-2">
|
||||
<label htmlFor="ac-report" className="flex items-center justify-between gap-3 text-caption">
|
||||
<span>
|
||||
写入待处理
|
||||
<span className="ml-2 text-meta text-muted-foreground">结果汇总成一条待处理事项</span>
|
||||
</span>
|
||||
<Switch id="ac-report" checked={d.reportAsDecision} onCheckedChange={(v) => set("reportAsDecision", v)} />
|
||||
</label>
|
||||
<label htmlFor="ac-recover" className="flex items-center justify-between gap-3 text-caption">
|
||||
<span>
|
||||
允许授权内恢复
|
||||
<span className="ml-2 text-meta text-muted-foreground">需有效持续授权,否则只读</span>
|
||||
</span>
|
||||
<Switch id="ac-recover" checked={d.allowRecover} onCheckedChange={(v) => set("allowRecover", v)} />
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
{job ? "保存" : "创建巡检"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { MIN_INTERVAL_MS, type ScheduleSpec, type ScheduleSpecInput } from "@umt/contracts";
|
||||
import { Check, Pencil, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { describeSchedule, type IntervalUnit, splitInterval, UNIT_LABEL, UNIT_MS } from "./labels.ts";
|
||||
|
||||
/** 卡片里的间隔 / cron:点一下就地改,回车保存,Esc 取消 */
|
||||
export function IntervalEditor({
|
||||
schedule,
|
||||
disabled,
|
||||
pending,
|
||||
onSave,
|
||||
}: {
|
||||
schedule: ScheduleSpec;
|
||||
disabled?: boolean;
|
||||
pending?: boolean;
|
||||
onSave: (next: ScheduleSpecInput) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const init = schedule.type === "interval" ? splitInterval(schedule.everyMs) : { value: 1, unit: "m" as IntervalUnit };
|
||||
const [value, setValue] = useState(String(init.value));
|
||||
const [unit, setUnit] = useState<IntervalUnit>(init.unit);
|
||||
const [cron, setCron] = useState(schedule.type === "cron" ? schedule.cron : "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const open = () => {
|
||||
const s = schedule.type === "interval" ? splitInterval(schedule.everyMs) : init;
|
||||
setValue(String(s.value));
|
||||
setUnit(s.unit);
|
||||
setCron(schedule.type === "cron" ? schedule.cron : "");
|
||||
setError(null);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (schedule.type === "cron") {
|
||||
const c = cron.trim();
|
||||
if (c.split(/\s+/).length !== 5) return setError("cron 需要 5 段:分 时 日 月 周");
|
||||
onSave({ type: "cron", cron: c });
|
||||
} else {
|
||||
const n = Number(value);
|
||||
const ms = Math.round(n * UNIT_MS[unit]);
|
||||
if (!Number.isFinite(n) || n <= 0) return setError("填正数");
|
||||
if (ms < MIN_INTERVAL_MS) return setError("最短 10 秒");
|
||||
onSave({ type: "interval", everyMs: ms });
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || pending}
|
||||
onClick={open}
|
||||
className="group inline-flex cursor-pointer items-center gap-1 rounded-tight text-caption text-foreground underline-offset-2 hover:underline disabled:cursor-default disabled:no-underline disabled:opacity-60"
|
||||
title="修改间隔"
|
||||
>
|
||||
{describeSchedule(schedule)}
|
||||
<Pencil className="h-3 w-3 text-muted-foreground opacity-0 transition-opacity duration-micro group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
}}
|
||||
>
|
||||
{schedule.type === "cron" ? (
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label="cron 表达式"
|
||||
className="h-8 w-40 font-mono text-caption"
|
||||
value={cron}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label="间隔"
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-8 w-20 text-caption"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
<Select value={unit} onValueChange={(v) => setUnit(v as IntervalUnit)}>
|
||||
<SelectTrigger size="sm" className="h-8 w-20" aria-label="单位">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{(Object.keys(UNIT_LABEL) as IntervalUnit[]).map((u) => (
|
||||
<SelectItem key={u} value={u}>
|
||||
{UNIT_LABEL[u]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
<Button type="submit" size="icon-sm" variant="ghost" aria-label="保存">
|
||||
<Check />
|
||||
</Button>
|
||||
<Button type="button" size="icon-sm" variant="ghost" aria-label="取消" onClick={() => setEditing(false)}>
|
||||
<X />
|
||||
</Button>
|
||||
{error ? <span className="w-full text-meta text-destructive">{error}</span> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type {
|
||||
AgentCheckTrigger,
|
||||
ScheduleGroup,
|
||||
ScheduleRunStatus,
|
||||
ScheduleSpec,
|
||||
ScheduleTrigger,
|
||||
} from "@umt/contracts";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
|
||||
// 定时任务看板的标签映射(只在本页用,不放 lib/labels.ts)
|
||||
|
||||
export const GROUP_ORDER: ScheduleGroup[] = ["collect", "download", "organize", "maintain", "agent"];
|
||||
|
||||
export const GROUP_LABEL: Record<ScheduleGroup, string> = {
|
||||
collect: "采集",
|
||||
download: "下载",
|
||||
organize: "整理",
|
||||
maintain: "维护",
|
||||
agent: "Agent 巡检",
|
||||
};
|
||||
|
||||
export const RUN_STATUS: Record<ScheduleRunStatus, { label: string; tone: StatusTone }> = {
|
||||
ok: { label: "成功", tone: "success" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
skipped: { label: "已跳过", tone: "warning" },
|
||||
running: { label: "运行中", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const TRIGGER_LABEL: Record<ScheduleTrigger, string> = {
|
||||
schedule: "定时",
|
||||
manual: "手动",
|
||||
skip: "跳过",
|
||||
};
|
||||
|
||||
export const CHECK_TRIGGER_LABEL: Record<AgentCheckTrigger, string> = {
|
||||
always: "每次都跑",
|
||||
open_decisions: "有待处理时",
|
||||
stalled_downloads: "有下载无进展时",
|
||||
subscriptions_active: "有追踪订阅时",
|
||||
};
|
||||
|
||||
export type IntervalUnit = "s" | "m" | "h";
|
||||
export const UNIT_MS: Record<IntervalUnit, number> = { s: 1000, m: 60_000, h: 3600_000 };
|
||||
export const UNIT_LABEL: Record<IntervalUnit, string> = { s: "秒", m: "分钟", h: "小时" };
|
||||
|
||||
/** 毫秒 → 最大整除单位 */
|
||||
export function splitInterval(ms: number): { value: number; unit: IntervalUnit } {
|
||||
if (ms % UNIT_MS.h === 0) return { value: ms / UNIT_MS.h, unit: "h" };
|
||||
if (ms % UNIT_MS.m === 0) return { value: ms / UNIT_MS.m, unit: "m" };
|
||||
return { value: Math.max(1, Math.round(ms / 1000)), unit: "s" };
|
||||
}
|
||||
|
||||
/** 调度的简短描述:"每 30 秒" / "cron 0 3 * * *" */
|
||||
export function describeSchedule(s: ScheduleSpec): string {
|
||||
if (s.type === "cron") return `cron ${s.cron}`;
|
||||
if (s.everyMs < 1000) return `每 ${s.everyMs} 毫秒`;
|
||||
const { value, unit } = splitInterval(s.everyMs);
|
||||
return `每 ${value} ${UNIT_LABEL[unit]}`;
|
||||
}
|
||||
|
||||
/** 毫秒耗时:"320 毫秒" / "2.4 秒" / "3 分 5 秒" */
|
||||
export function formatMs(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined) return "—";
|
||||
if (ms < 1000) return `${ms} 毫秒`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} 秒`;
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)} 分 ${sec % 60} 秒`;
|
||||
return `${Math.floor(sec / 3600)} 小时 ${Math.floor((sec % 3600) / 60)} 分`;
|
||||
}
|
||||
|
||||
/** 倒计时:"12 秒后" / "3 分 5 秒后" / "已到期" */
|
||||
export function countdown(iso: string, now: number): string {
|
||||
const diff = Math.round((new Date(iso).getTime() - now) / 1000);
|
||||
if (Number.isNaN(diff)) return "—";
|
||||
if (diff <= 0) return "即将运行";
|
||||
if (diff < 60) return `${diff} 秒后`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} 分 ${diff % 60} 秒后`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时 ${Math.floor((diff % 3600) / 60)} 分后`;
|
||||
return `${Math.floor(diff / 86400)} 天 ${Math.floor((diff % 86400) / 3600)} 小时后`;
|
||||
}
|
||||
|
||||
export function formatClock(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ScheduledJob } from "@umt/contracts";
|
||||
import { History } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { useScheduleRuns } from "~/api/schedules.ts";
|
||||
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { agentRunLabel } from "~/lib/labels.ts";
|
||||
import { describeSchedule, formatClock, formatMs, RUN_STATUS, TRIGGER_LABEL } from "./labels.ts";
|
||||
|
||||
/** 历史抽屉:最近 50 次运行(新→旧) */
|
||||
export function RunsSheet({ job, onClose }: { job: ScheduledJob | null; onClose: () => void }) {
|
||||
const runs = useScheduleRuns(job?.id ?? null);
|
||||
const items = runs.data?.items ?? [];
|
||||
return (
|
||||
<DetailSheet
|
||||
open={!!job}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={job ? `${job.name} · 历史` : "历史"}
|
||||
description={
|
||||
job ? `${describeSchedule(job.schedule)} · 累计 ${job.runCount} 次,失败 ${job.failCount} 次` : undefined
|
||||
}
|
||||
>
|
||||
{runs.isPending ? (
|
||||
<LoadingState rows={5} />
|
||||
) : runs.isError ? (
|
||||
<ErrorState plain error={runs.error} onRetry={() => runs.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState plain icon={History} title="还没有运行记录" />
|
||||
) : (
|
||||
<ol className="grid gap-2">
|
||||
{items.map((r) => {
|
||||
const s = RUN_STATUS[r.status];
|
||||
return (
|
||||
<li key={r.id} className="grid gap-1 rounded-tight border border-border/70 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
|
||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||||
<span>{TRIGGER_LABEL[r.trigger]}</span>
|
||||
<span className="tabular-nums" title={r.startedAt}>
|
||||
{formatClock(r.startedAt)}
|
||||
</span>
|
||||
<span className="ml-auto tabular-nums">{formatMs(r.durationMs)}</span>
|
||||
</div>
|
||||
{r.error ? (
|
||||
<p className="text-caption break-words text-destructive">{r.error}</p>
|
||||
) : r.summary ? (
|
||||
<p className="text-caption break-words text-foreground">{r.summary}</p>
|
||||
) : null}
|
||||
{r.agentRunId ? (
|
||||
<Link
|
||||
to={consolePath("agent", r.agentRunId)}
|
||||
className="w-fit text-meta text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
查看 Agent 会话{r.agentStatus ? `(${agentRunLabel(r.agentStatus).label})` : ""}
|
||||
</Link>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { AgentCheckConfig, ScheduledJob, ScheduleSpecInput } from "@umt/contracts";
|
||||
import { History, LoaderCircle, Pencil, Play, RotateCcw, SkipForward, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useDeleteSchedule, useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { IntervalEditor } from "./interval-editor.tsx";
|
||||
import { CHECK_TRIGGER_LABEL, countdown, formatMs, RUN_STATUS } from "./labels.ts";
|
||||
|
||||
/** 下次运行一栏:运行中 > 排队中 > 已停用 > 倒计时 */
|
||||
function NextRun({ job, now }: { job: ScheduledJob; now: number }) {
|
||||
if (job.runningSince)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-foreground">
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||
运行中 · 已 {formatMs(Math.max(0, now - new Date(job.runningSince).getTime()))}
|
||||
</span>
|
||||
);
|
||||
if (job.runRequestedAt) return <span className="text-foreground">排队中</span>;
|
||||
if (!job.available) return <span>未配置</span>;
|
||||
if (!job.enabled) return <span>已停用</span>;
|
||||
if (!job.nextRunAt) return <span>待调度</span>;
|
||||
return <span className="tabular-nums text-foreground">{countdown(job.nextRunAt, now)}</span>;
|
||||
}
|
||||
|
||||
function AgentCheckSummary({ config }: { config: Partial<AgentCheckConfig> }) {
|
||||
const budget = config.budget ?? {};
|
||||
const allow = config.capabilityAllowlist ?? [];
|
||||
return (
|
||||
<div className="grid gap-1.5 rounded-tight border border-border/70 px-2.5 py-2 text-meta text-muted-foreground">
|
||||
<p className="line-clamp-2 text-caption text-foreground" title={config.goalTemplate}>
|
||||
{config.goalTemplate ?? "—"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
<span>条件:{CHECK_TRIGGER_LABEL[config.triggerCondition ?? "always"]}</span>
|
||||
<span>
|
||||
预算:{budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : "默认"}
|
||||
{budget.maxDurationSec ? ` · ${budget.maxDurationSec} 秒` : ""}
|
||||
</span>
|
||||
<span>能力:{allow.length ? `${allow.length} 项` : "只读全部"}</span>
|
||||
{config.allowRecover ? <span>允许授权内恢复</span> : null}
|
||||
{config.reportAsDecision ? <span>写入待处理</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleCard({
|
||||
job,
|
||||
now,
|
||||
onHistory,
|
||||
onEdit,
|
||||
}: {
|
||||
job: ScheduledJob;
|
||||
now: number;
|
||||
onHistory: (job: ScheduledJob) => void;
|
||||
onEdit: (job: ScheduledJob) => void;
|
||||
}) {
|
||||
const update = useUpdateSchedule();
|
||||
const runNow = useRunScheduleNow();
|
||||
const skip = useSkipSchedule();
|
||||
const del = useDeleteSchedule();
|
||||
const busy = !!job.runningSince || !!job.runRequestedAt;
|
||||
const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null;
|
||||
|
||||
const patch = (body: Parameters<typeof update.mutate>[0]["body"], ok: string) =>
|
||||
update.mutate(
|
||||
{ id: job.id, body },
|
||||
{ onSuccess: () => toast.success(ok), onError: (e) => toast.error(errorMessage(e)) },
|
||||
);
|
||||
|
||||
return (
|
||||
<article className="pg-slab grid min-w-0 gap-3 rounded-xl px-4 py-3.5" aria-label={job.name}>
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-lead font-medium text-foreground" title={job.name}>
|
||||
{job.name}
|
||||
</h3>
|
||||
{job.description ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-meta text-muted-foreground">{job.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={job.enabled ? "停用" : "启用"}
|
||||
checked={job.enabled && job.available}
|
||||
disabled={!job.available || update.isPending}
|
||||
onCheckedChange={(v) => patch({ enabled: v }, v ? "已启用" : "已停用")}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{job.kind === "agent_check" ? <AgentCheckSummary config={job.config as Partial<AgentCheckConfig>} /> : null}
|
||||
|
||||
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1.5 text-meta text-muted-foreground">
|
||||
<dt>间隔</dt>
|
||||
<dd className="min-w-0">
|
||||
<IntervalEditor
|
||||
schedule={job.schedule}
|
||||
disabled={!job.available}
|
||||
pending={update.isPending}
|
||||
onSave={(schedule: ScheduleSpecInput) => patch({ schedule }, "间隔已更新")}
|
||||
/>
|
||||
</dd>
|
||||
<dt>上次</dt>
|
||||
<dd className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : <span>未运行</span>}
|
||||
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
||||
{job.lastDurationMs !== null ? <span>耗时 {formatMs(job.lastDurationMs)}</span> : null}
|
||||
</dd>
|
||||
<dt>下次</dt>
|
||||
<dd className="min-w-0">
|
||||
<NextRun job={job} now={now} />
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{job.lastStatus === "failed" && job.lastError ? (
|
||||
<details className="group rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta text-destructive">
|
||||
<summary className="cursor-pointer truncate group-open:whitespace-normal">{job.lastError}</summary>
|
||||
<p className="mt-1 break-words whitespace-pre-wrap">{job.lastError}</p>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<footer className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!job.available || busy || runNow.isPending}
|
||||
onClick={() =>
|
||||
runNow.mutate(
|
||||
{ id: job.id },
|
||||
{
|
||||
onSuccess: (r) => toast.success(r.queued ? "已排队执行" : "已在排队或运行中"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{runNow.isPending ? <LoaderCircle className="animate-spin" /> : <Play />}
|
||||
立即执行
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!job.available || !job.enabled || !job.nextRunAt || skip.isPending}
|
||||
onClick={() =>
|
||||
skip.mutate(
|
||||
{ id: job.id },
|
||||
{ onSuccess: () => toast.success("已跳过本次"), onError: (e) => toast.error(errorMessage(e)) },
|
||||
)
|
||||
}
|
||||
>
|
||||
<SkipForward />
|
||||
跳过本次
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onHistory(job)}>
|
||||
<History />
|
||||
查看历史
|
||||
</Button>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{job.customized ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="恢复默认"
|
||||
title="恢复默认"
|
||||
disabled={update.isPending}
|
||||
onClick={() => patch({ reset: true }, "已恢复默认")}
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
) : null}
|
||||
{job.kind === "agent_check" ? (
|
||||
<Button size="icon-sm" variant="ghost" aria-label="编辑巡检" title="编辑巡检" onClick={() => onEdit(job)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
) : null}
|
||||
{!job.builtin ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button size="icon-sm" variant="ghost" aria-label="删除巡检" title="删除巡检">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
title={`删除「${job.name}」?`}
|
||||
description="运行历史一并删除,不影响已产生的 Agent 会话。"
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={() =>
|
||||
del.mutateAsync({ id: job.id }).then(
|
||||
() => toast.success("已删除"),
|
||||
(e) => {
|
||||
toast.error(errorMessage(e));
|
||||
throw e;
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export default [
|
||||
route("engine/:runId", "routes/console/engine.$runId.tsx"),
|
||||
route("audit", "routes/console/audit.tsx"),
|
||||
route("settings", "routes/console/settings.tsx"),
|
||||
route("schedules", "routes/console/schedules.tsx"),
|
||||
]),
|
||||
]),
|
||||
route("*", "routes/not-found.tsx"),
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { SCHEDULE_CHANGED_EVENT, type ScheduledJob, type ScheduleGroup } from "@umt/contracts";
|
||||
import { AlertTriangle, CalendarClock, CircleCheck, LoaderCircle, Plus, RotateCw, XCircle } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useScheduleBoard } from "~/api/schedules.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||||
import { AgentCheckDialog } from "~/components/schedules/agent-check-dialog.tsx";
|
||||
import { GROUP_LABEL, GROUP_ORDER } from "~/components/schedules/labels.ts";
|
||||
import { RunsSheet } from "~/components/schedules/runs-sheet.tsx";
|
||||
import { ScheduleCard } from "~/components/schedules/schedule-card.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
|
||||
export const meta = () => [{ title: "定时任务 · UHD 媒体自动化控制台" }];
|
||||
|
||||
/** 每秒一跳的当前时间(倒计时用) */
|
||||
function useNow(ms = 1000) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(Date.now()), ms);
|
||||
return () => clearInterval(t);
|
||||
}, [ms]);
|
||||
return now;
|
||||
}
|
||||
|
||||
export default function SchedulesPage() {
|
||||
const qc = useQueryClient();
|
||||
const board = useScheduleBoard();
|
||||
const now = useNow();
|
||||
const [history, setHistory] = useState<ScheduledJob | null>(null);
|
||||
const [editing, setEditing] = useState<{ job: ScheduledJob | null; key: number } | null>(null);
|
||||
|
||||
// SSE:调度器每次开始/结束一次运行都会发 schedule.changed;1 秒节流
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useSse([SCHEDULE_CHANGED_EVENT], () => {
|
||||
if (timer.current) return;
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
qc.invalidateQueries({ queryKey: qk.all("schedules") });
|
||||
}, 1000);
|
||||
});
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const data = board.data;
|
||||
const items = data?.items ?? [];
|
||||
const byGroup = new Map<ScheduleGroup, ScheduledJob[]>();
|
||||
for (const g of GROUP_ORDER) byGroup.set(g, []);
|
||||
for (const j of items) byGroup.get(j.group)?.push(j);
|
||||
const stats = data?.stats;
|
||||
const val = (n: number | undefined) => (board.isPending ? "…" : board.isError ? "—" : (n ?? 0));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="定时任务"
|
||||
description="周期任务与 Agent 巡检的开关、间隔和运行记录。"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => qc.invalidateQueries({ queryKey: qk.all("schedules") })}
|
||||
disabled={board.isFetching}
|
||||
>
|
||||
<RotateCw className={board.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setEditing({ job: null, key: Date.now() })}>
|
||||
<Plus />
|
||||
新建巡检
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-3 grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<StatTile label="已启用" icon={CircleCheck} value={val(stats?.enabled)} />
|
||||
<StatTile
|
||||
label="上次失败"
|
||||
icon={XCircle}
|
||||
tone={(stats?.failed ?? 0) > 0 ? "destructive" : "neutral"}
|
||||
value={val(stats?.failed)}
|
||||
/>
|
||||
<StatTile label="运行中" icon={LoaderCircle} value={val(stats?.running)} />
|
||||
<StatTile label="全部" icon={CalendarClock} value={val(stats?.total)} />
|
||||
</div>
|
||||
|
||||
{data && !data.schedulerOnline ? (
|
||||
<p
|
||||
role="status"
|
||||
className="mb-4 flex items-center gap-2 rounded-tight border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
调度器未在线,任务不会按时运行。
|
||||
</p>
|
||||
) : (
|
||||
<div className="mb-4" />
|
||||
)}
|
||||
|
||||
{board.isPending ? (
|
||||
<LoadingState rows={6} />
|
||||
) : board.isError ? (
|
||||
<ErrorState error={board.error} onRetry={() => board.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState icon={CalendarClock} title="还没有登记的定时任务" description="worker 启动后会自动登记。" />
|
||||
) : (
|
||||
<div className="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
{GROUP_ORDER.map((g) => {
|
||||
const jobs = byGroup.get(g) ?? [];
|
||||
return (
|
||||
<section key={g} aria-label={GROUP_LABEL[g]} className="grid min-w-0 gap-2.5">
|
||||
<h2 className="flex items-baseline justify-between px-1 text-caption font-medium text-muted-foreground">
|
||||
<span>{GROUP_LABEL[g]}</span>
|
||||
<span className="tabular-nums">{jobs.length}</span>
|
||||
</h2>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border/70 px-4 py-6 text-center text-meta text-muted-foreground">
|
||||
{g === "agent" ? "暂无巡检,点「新建巡检」添加。" : "暂无任务。"}
|
||||
</p>
|
||||
) : (
|
||||
jobs.map((j) => (
|
||||
<ScheduleCard
|
||||
key={j.id}
|
||||
job={j}
|
||||
now={now}
|
||||
onHistory={setHistory}
|
||||
onEdit={(job) => setEditing({ job, key: Date.now() })}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RunsSheet job={history} onClose={() => setHistory(null)} />
|
||||
{editing ? (
|
||||
<AgentCheckDialog key={editing.key} open job={editing.job} onOpenChange={(o) => !o && setEditing(null)} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ const STATIC_ROUTES = [
|
||||
"/console/agent?mock=1",
|
||||
"/console/engine",
|
||||
"/console/audit",
|
||||
"/console/schedules",
|
||||
"/console/settings",
|
||||
"/console/settings?tab=pi",
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user