Files
codecraft/web/src/components/Jobs.tsx
T

204 lines
9.1 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 { del, get, patch, post } from "../api";
import { fmtRelative, useT } from "../i18n";
import { navigate } from "../router";
import { confirmDialog, toast } from "../dialogs";
import { Icon } from "./Icon";
import { Spinner } from "./Activity";
import { NewJobDialog } from "./NewJobDialog";
export interface JobInfo {
id: string;
path: string;
enabled: boolean;
last_run?: number | null;
last_session_id?: string | null;
last_status?: string | null;
next_run?: number | null;
error?: string;
webhook?: boolean;
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; notify?: boolean; prompt: string; assistant_session?: string | null };
}
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
/** Automations: agents started on a schedule with the instructions from a Markdown file. */
export function Jobs({ onMenu }: { onMenu: () => void }) {
const t = useT();
const [jobs, setJobs] = useState<JobInfo[]>();
const [path, setPath] = useState("");
const [busy, setBusy] = useState<string>();
const [hook, setHook] = useState<{ id: string; url: string; secret: string }>();
const [templates, setTemplates] = useState(false);
const reload = () =>
get<JobInfo[]>("/jobs")
.then(setJobs)
.catch((e) => toast(e.message));
useEffect(() => {
void reload();
const h = setInterval(reload, 15000);
return () => clearInterval(h);
}, []);
const act = async (id: string, f: () => Promise<unknown>) => {
setBusy(id);
try {
await f();
await reload();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(undefined);
}
};
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("nav.jobs")}</h1>
</div>
</div>
<div className="page">
<div className="page-inner" style={{ maxWidth: 980 }}>
<div className="notice">{t("jobs.intro")}</div>
<div className="row" style={{ margin: "12px 0" }}>
<button className="primary" onClick={() => setTemplates(true)}>
<Icon name="plus" /> {t("jobs.new")}
</button>
</div>
{templates && <NewJobDialog onClose={() => setTemplates(false)} />}
{!jobs && <div className="empty"><Spinner /></div>}
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
{jobs?.map((j) => (
<div key={j.id} className={`panel job ${j.enabled ? "" : "off"}`}>
<div className="job-head">
<Icon name="clock" className="job-icon" />
<div style={{ flex: 1, minWidth: 0 }}>
<h3>
<button className="link-btn job-title" onClick={() => navigate(`/jobs/${j.id}`)}>
{j.spec?.name ?? j.path.split("/").pop()}
</button>
</h3>
<div className="hint mono">{j.path}</div>
</div>
<label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}>
<input type="checkbox" checked={j.enabled} onChange={(e) => act(j.id, () => patch(`/jobs/${j.id}`, { enabled: e.target.checked }))} />
<span>{t(j.enabled ? "jobs.on" : "jobs.off")}</span>
</label>
</div>
{j.error ? (
<div className="notice err">{j.error}</div>
) : (
<div className="job-grid">
<span>{t("jobs.schedule")}</span>
<code>{j.spec?.schedule}</code>
<span>{t("jobs.next")}</span>
<span>{j.enabled ? fmtDate(j.next_run) : "–"}</span>
<span>{t("jobs.last")}</span>
<span>
{j.last_run ? `${fmtRelative(j.last_run)} · ` : "–"}
{j.last_status && <span className={`chip ${j.last_status === "done" ? "ok" : j.last_status === "running" ? "" : "warn"}`}>{t(`jobs.status.${j.last_status.startsWith("error") ? "error" : j.last_status}` as any)}</span>}
{j.last_session_id && (
<button className="link-btn" style={{ marginLeft: 8 }} onClick={() => navigate(`/s/${j.last_session_id}`)}>
{t("jobs.openRun")}
</button>
)}
</span>
<span>{t("jobs.mode")}</span>
<span>
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {t(`permissionMode.${j.spec?.permission_mode ?? "auto"}` as any)} · max {j.spec?.timeout_minutes} min
</span>
</div>
)}
{j.last_status?.startsWith("error") && <div className="notice err">{j.last_status}</div>}
{j.spec && (
<details className="job-prompt">
<summary>{t("jobs.prompt")}</summary>
<pre>{j.spec.prompt}</pre>
</details>
)}
<div className="job-hook">
<span>
<Icon name="external" /> {t("jobs.webhook")}: {j.webhook ? t("jobs.on") : t("jobs.off")}
</span>
<button
className="small ghost"
onClick={() =>
act(j.id, async () => {
const r = await post<{ url?: string; secret?: string }>(`/jobs/${j.id}/webhook`, { enable: !j.webhook });
if (r.url && r.secret) setHook({ id: j.id, url: r.url, secret: r.secret });
})
}
>
{j.webhook ? t("jobs.webhookOff") : t("jobs.webhookOn")}
</button>
{j.webhook && (
<button
className="small ghost"
onClick={() =>
act(j.id, async () => {
const r = await post<{ url: string; secret: string }>(`/jobs/${j.id}/webhook`, { enable: true });
setHook({ id: j.id, url: r.url, secret: r.secret });
})
}
>
{t("jobs.webhookRenew")}
</button>
)}
</div>
{hook?.id === j.id && (
<div className="notice ok">
<div>{t("jobs.webhookShown")}</div>
<div className="mono" style={{ marginTop: 6, wordBreak: "break-all" }}>URL: {hook.url}</div>
<div className="mono" style={{ wordBreak: "break-all" }}>Secret: {hook.secret}</div>
<div className="hint" style={{ marginTop: 6 }}>{t("jobs.webhookHelp")}</div>
<pre className="job-example">{`curl -X POST ${hook.url} \\\n -H "X-CodeCraft-Secret: ${hook.secret}" \\\n -H "Content-Type: application/json" -d '{"event":"test"}'`}</pre>
</div>
)}
<div className="row" style={{ gap: 8, marginTop: 10 }}>
<button className="small" disabled={busy === j.id || !!j.error} onClick={() => act(j.id, async () => {
const r = await post<{ session_id: string }>(`/jobs/${j.id}/run`, {});
navigate(`/s/${r.session_id}`);
})}>
{busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")}
</button>
<button className="small" onClick={() => navigate(`/jobs/${j.id}`)}>
<Icon name="edit" /> {t("jobs.edit")}
</button>
<span className="grow" />
<button
className="small ghost danger"
onClick={async () => {
if (await confirmDialog({ message: t("jobs.deleteConfirm", { name: j.spec?.name ?? j.path }), danger: true })) void act(j.id, () => del(`/jobs/${j.id}`));
}}
>
<Icon name="trash" /> {t("jobs.remove")}
</button>
</div>
</div>
))}
<div className="panel">
<h3>{t("jobs.register")}</h3>
<div className="hint" style={{ marginBottom: 8 }}>{t("jobs.registerHint")}</div>
<div className="row" style={{ gap: 8 }}>
<input className="mono" style={{ flex: 1, minWidth: 0 }} value={path} placeholder="/workspace/project/.codecraft/jobs/nightly.md" onChange={(e) => setPath(e.target.value)} />
<button disabled={!path.trim()} onClick={() => act("new", async () => {
const j = await post<JobInfo>("/jobs", { path: path.trim() });
setPath("");
navigate(`/jobs/${j.id}`);
})}>
{t("jobs.registerBtn")}
</button>
</div>
</div>
</div>
</div>
</>
);
}