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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user