From d800987403b2448000b3123388c0d3f00221e79a Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 02:33:20 +0800 Subject: [PATCH] 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 --- apps/web/app/api/schedules.ts | 86 ++++++ apps/web/app/components/console/nav.test.ts | 6 +- apps/web/app/components/console/nav.ts | 7 + .../schedules/agent-check-dialog.tsx | 291 ++++++++++++++++++ .../components/schedules/interval-editor.tsx | 120 ++++++++ apps/web/app/components/schedules/labels.ts | 94 ++++++ .../app/components/schedules/runs-sheet.tsx | 65 ++++ .../components/schedules/schedule-card.tsx | 205 ++++++++++++ apps/web/app/routes.ts | 1 + apps/web/app/routes/console/schedules.tsx | 151 +++++++++ apps/web/scripts/smoke.mjs | 1 + 11 files changed, 1024 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/api/schedules.ts create mode 100644 apps/web/app/components/schedules/agent-check-dialog.tsx create mode 100644 apps/web/app/components/schedules/interval-editor.tsx create mode 100644 apps/web/app/components/schedules/labels.ts create mode 100644 apps/web/app/components/schedules/runs-sheet.tsx create mode 100644 apps/web/app/components/schedules/schedule-card.tsx create mode 100644 apps/web/app/routes/console/schedules.tsx diff --git a/apps/web/app/api/schedules.ts b/apps/web/app/api/schedules.ts new file mode 100644 index 00000000..74764b4d --- /dev/null +++ b/apps/web/app/api/schedules.ts @@ -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("/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("/schedules/{id}", { method: "PATCH", pathParams: { id }, body }), + onSettled: invalidate, + }); +} + +export function useRunScheduleNow() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id }: { id: string }) => + apiData("/schedules/{id}/run-now", { method: "POST", pathParams: { id } }), + onSettled: invalidate, + }); +} + +export function useSkipSchedule() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id }: { id: string }) => + apiData("/schedules/{id}/skip", { method: "POST", pathParams: { id } }), + onSettled: invalidate, + }); +} + +export function useCreateAgentCheck() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (body: AgentCheckCreate) => apiData("/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, + }); +} diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index 25051741..c10af987 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -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); }); diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index a4b19784..269dade3 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -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: "工作流引擎", diff --git a/apps/web/app/components/schedules/agent-check-dialog.tsx b/apps/web/app/components/schedules/agent-check-dialog.tsx new file mode 100644 index 00000000..2e8022aa --- /dev/null +++ b/apps/web/app/components/schedules/agent-check-dialog.tsx @@ -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; + 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(() => initial(job)); + const [errors, setErrors] = useState>({}); + const create = useCreateAgentCheck(); + const update = useUpdateSchedule(); + const pending = create.isPending || update.isPending; + const set = (k: K, v: Draft[K]) => setD((x) => ({ ...x, [k]: v })); + const cronJob = job?.schedule.type === "cron"; + + const submit = () => { + const e: Record = {}; + 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 ( + + + + {job ? "编辑巡检" : "新建巡检"} + 到点按目标入队一次 Agent 会话,只在白名单与预算内行动。 + +
{ + e.preventDefault(); + submit(); + }} + > + + set("name", e.target.value)} + /> + + +