oclaw/interfaces/admin/static/js/pages/scheduled-jobs.js
oliver f6f129931f Ship Cursor-only MCP admin and modular Admin/Chat UI.
Plugins/Skills install and edit via mcpServers JSON, with clearer row actions, soft reloads, and instant loading placeholders; also drop MCP market and harden related scheduler/logging/exec guards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 19:38:26 +08:00

517 lines
22 KiB
JavaScript

import { t, tf, el, tdCell, apiGet, apiPost, apiRequest, resolveAdminApiUrl, getStoredAuthToken, formatSystemLocalDateTime } from "../core.js";
import { hasPermission } from "./authz.js";
function formatScheduledJobDelivery(job) {
const d = (job && job.delivery && typeof job.delivery === "object") ? job.delivery : {};
const parts = [];
if (d.weixin && d.weixin.enabled) parts.push(t("scheduledJobs.deliveryWeixin"));
if (d.whatsapp && d.whatsapp.enabled) parts.push(t("scheduledJobs.deliveryWhatsapp"));
return parts.length ? parts.join(" + ") : "—";
}
function formatScheduledJobPlaybook(job) {
const steps = Number(job && job.steps_n != null ? job.steps_n : 0) || 0;
if (job && job.playbook) return tf("scheduledJobs.playbookYes", { n: String(steps) });
if (job && job.has_recipe) return tf("scheduledJobs.playbookPartial", { n: String(steps) });
return t("scheduledJobs.playbookNo");
}
function buildScheduledJobDeliveryPayload(job, waChatId) {
const existing = (job && job.delivery && typeof job.delivery === "object") ? job.delivery : {};
const wa = existing.whatsapp && typeof existing.whatsapp === "object" ? { ...existing.whatsapp } : {};
const wx =
existing.weixin && typeof existing.weixin === "object"
? { ...existing.weixin }
: { enabled: true, fixed: true };
const chat = String(waChatId || "").trim();
wa.enabled = Boolean(chat);
wa.chat_id = chat;
wa.target_type = chat.includes("@g.us") ? "group" : "direct";
if (wx.enabled === undefined) wx.enabled = true;
return { whatsapp: wa, weixin: wx };
}
async function renderScheduledJobs() {
let scheduledJobMenuEl = null;
let editingJob = null;
const canWrite = hasPermission("admin:runtime:write");
const closeScheduledJobMenu = () => {
if (scheduledJobMenuEl && scheduledJobMenuEl.parentNode) {
scheduledJobMenuEl.remove();
}
scheduledJobMenuEl = null;
};
const status = el("select", { class: "input input--compact" }, [
el("option", { value: "", text: t("scheduledJobs.all") }),
el("option", { value: "active", text: t("scheduledJobs.statusActive") }),
el("option", { value: "paused", text: t("scheduledJobs.statusPaused") }),
]);
const tbody = el("tbody", {});
const runsTbody = el("tbody", {});
const runsEmpty = el("div", { class: "muted", text: t("scheduledJobs.noRuns") });
const runsTable = el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("scheduledJobs.colJob") }),
el("th", { text: t("scheduledJobs.colRunStatus") }),
el("th", { text: t("scheduledJobs.colFailClass") }),
el("th", { text: t("scheduledJobs.colFinished") }),
el("th", { text: t("scheduledJobs.colError") }),
]),
]),
runsTbody,
]),
]);
const runsBox = el("div", {}, [runsEmpty, runsTable]);
runsTable.style.display = "none";
const statsBox = el("div", { class: "muted", text: "" });
const attentionBox = el("div", { class: "muted", text: "" });
const msg = el("div", { class: "muted", text: "" });
const weixinInfo = el("div", { class: "muted", text: "" });
const failClassLabel = (fc) => {
const key = `scheduledJobs.failClass.${String(fc || "").trim()}`;
const labeled = t(key);
return labeled === key ? String(fc || "—") : labeled;
};
const renderRunsTable = (items, jobNameFallback) => {
const rows = Array.isArray(items) ? items : [];
runsTbody.replaceChildren();
if (!rows.length) {
runsEmpty.style.display = "";
runsTable.style.display = "none";
runsEmpty.textContent = t("scheduledJobs.noRuns");
return;
}
runsEmpty.style.display = "none";
runsTable.style.display = "";
for (const run of rows.slice(0, 30)) {
const st = String(run.status || "");
const fc = String(run.failure_class || "");
const err = String(run.error || "").trim();
const name = String(run.job_name || jobNameFallback || run.job_id || "—");
const finished = run.finished_at
? formatSystemLocalDateTime(String(run.finished_at))
: (run.created_at ? formatSystemLocalDateTime(String(run.created_at)) : "—");
const statusCell = el("td", { text: st || "—" });
if (st.toLowerCase() === "failed" || st.toLowerCase() === "error") {
statusCell.style.color = "var(--danger, #c44)";
} else if (st.toLowerCase() === "success") {
statusCell.style.color = "var(--success, #2a7)";
}
runsTbody.appendChild(
el("tr", {}, [
el("td", { text: name.slice(0, 40) }),
statusCell,
el("td", { text: fc ? failClassLabel(fc) : "—" }),
el("td", { text: finished }),
el("td", { text: err ? err.slice(0, 120) : "—" }),
])
);
}
};
const loadStats = async () => {
try {
const stats = await apiGet("/admin/api/scheduled-jobs/stats?recent_limit=80");
const finished = Number(stats.recent_success || 0) + Number(stats.recent_failed || 0);
const pct = Number(stats.recent_fail_rate_pct || 0);
const classes = stats.recent_fail_classes || {};
const classParts = Object.keys(classes).map((k) => `${failClassLabel(k)} ${classes[k]}`);
statsBox.textContent = [
tf("scheduledJobs.statsFailRate", {
pct: String(pct),
failed: String(stats.recent_failed || 0),
finished: String(finished),
}),
classParts.length
? `${t("scheduledJobs.statsClasses")}: ${classParts.join(" · ")}`
: t("scheduledJobs.statsNone"),
tf("scheduledJobs.statsJobsFailed", { n: String((stats.jobs_last_failed || []).length) }),
].join(" | ");
const failedJobs = stats.jobs_last_failed || [];
if (!failedJobs.length) {
attentionBox.textContent = "";
} else {
attentionBox.textContent =
`${t("scheduledJobs.attentionTitle")}: ` +
failedJobs
.slice(0, 8)
.map((j) => `${j.name || j.id}${j.last_run_at ? ` @ ${formatSystemLocalDateTime(String(j.last_run_at))}` : ""}`)
.join(" · ");
}
const fails = stats.recent_failures || [];
if (fails.length) {
renderRunsTable(fails, "");
return true;
}
return false;
} catch (e) {
statsBox.textContent = String(e && e.message ? e.message : e);
return false;
}
};
const field = (label, control, wide) =>
el("div", { class: wide ? "form-field form-field--full" : "form-field" }, [
el("label", { class: "form-field__label", text: label }),
control,
]);
const editModal = el("div", { class: "session-monitor-modal u-hidden" });
const editTitle = el("div", { class: "card__title", text: t("scheduledJobs.editTitle") });
const editNameInput = el("input", { class: "input", placeholder: t("scheduledJobs.phName") });
const editKindInput = el("select", { class: "input" }, [
el("option", { value: "cron", text: "cron" }),
el("option", { value: "once", text: "once" }),
el("option", { value: "interval", text: "interval" }),
]);
const editExprInput = el("input", { class: "input", placeholder: t("scheduledJobs.phExpr") });
const editPromptInput = el("textarea", { class: "input", rows: "5", placeholder: t("scheduledJobs.phPrompt") });
const editSpecialistInput = el("input", { class: "input", placeholder: t("scheduledJobs.phSpecialist") });
const editWaChatInput = el("input", { class: "input", placeholder: t("scheduledJobs.phWhatsapp") });
const editWeixinInfo = el("div", { class: "muted", text: "" });
const closeEditModal = () => {
editingJob = null;
editModal.style.display = "none";
};
const openEditModal = (job) => {
if (!job || !job.id) return;
editingJob = job;
editTitle.textContent = `${t("scheduledJobs.editTitle")}: ${String(job.name || job.id)}`;
editNameInput.value = String(job.name || "");
editKindInput.value = String(job.schedule_kind || "cron");
editExprInput.value = String(job.schedule_expr || "");
editPromptInput.value = String(job.prompt_text || "");
editSpecialistInput.value = String(job.specialist || "generalist");
const wa = job.delivery && job.delivery.whatsapp ? job.delivery.whatsapp : {};
editWaChatInput.value = String(wa.chat_id || "");
const wx = job.delivery && job.delivery.weixin ? job.delivery.weixin : {};
editWeixinInfo.textContent = wx.external_user_id
? tf("scheduledJobs.weixinFixed", { id: String(wx.external_user_id) })
: weixinInfo.textContent || t("scheduledJobs.weixinMissing");
editModal.style.display = "flex";
setTimeout(() => editNameInput.focus(), 0);
};
editModal.addEventListener("click", (e) => {
if (e.target === editModal) closeEditModal();
});
const editSaveBtn = el("button", {
class: "btn btn--primary",
text: t("action.save"),
onclick: async () => {
if (!editingJob || !editingJob.id) return;
const name = String(editNameInput.value || "").trim();
const promptText = String(editPromptInput.value || "").trim();
const scheduleExpr = String(editExprInput.value || "").trim();
if (!name || !promptText || !scheduleExpr) {
msg.textContent = t("scheduledJobs.requiredFields");
return;
}
try {
await apiRequest("PATCH", `/admin/api/scheduled-jobs/${encodeURIComponent(String(editingJob.id))}`, {
name,
schedule_kind: editKindInput.value,
schedule_expr: scheduleExpr,
prompt_text: promptText,
specialist: String(editSpecialistInput.value || "generalist").trim() || "generalist",
delivery: buildScheduledJobDeliveryPayload(editingJob, editWaChatInput.value),
});
closeEditModal();
msg.textContent = t("scheduledJobs.updated");
await loadJobs();
} catch (e) {
msg.textContent = String(e && e.message ? e.message : e);
}
},
});
const editModalCard = el("div", { class: "card session-monitor-modal__card", style: "width:min(640px,96vw);" }, [
editTitle,
editWeixinInfo,
el("div", { class: "form-stack" }, [
field(t("scheduledJobs.fieldName"), editNameInput, true),
el("div", { class: "form-grid" }, [
field(t("scheduledJobs.fieldKind"), editKindInput),
field(t("scheduledJobs.fieldExpr"), editExprInput),
]),
field(t("scheduledJobs.fieldPrompt"), editPromptInput, true),
el("div", { class: "form-grid" }, [
field(t("scheduledJobs.fieldSpecialist"), editSpecialistInput),
field(t("scheduledJobs.fieldWhatsapp"), editWaChatInput),
]),
el("div", { class: "form-actions" }, [
el("button", { class: "btn", text: t("scheduledJobs.cancel"), onclick: closeEditModal }),
editSaveBtn,
]),
]),
]);
editModal.appendChild(editModalCard);
async function loadLatestRuns(items) {
const jobs = Array.isArray(items) ? items : [];
if (!jobs.length) {
renderRunsTable([], "");
return;
}
const withLast = jobs.filter((j) => String(j.last_run_at || "").trim());
const target = withLast.length ? withLast[0] : jobs[0];
if (!target || !target.id) {
renderRunsTable([], "");
return;
}
const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(String(target.id))}/runs?limit=10`);
const runItems = (runs.items || []).map((r) => ({ ...r, job_name: target.name || target.id }));
renderRunsTable(runItems, String(target.name || target.id || ""));
}
async function loadJobs() {
closeScheduledJobMenu();
msg.textContent = t("scheduledJobs.loading");
const st = String(status.value || "").trim();
const q = st ? `?status=${encodeURIComponent(st)}` : "";
const resp = await apiGet(`/admin/api/scheduled-jobs${q}`);
tbody.replaceChildren();
for (const job of resp.items || []) {
const jobId = String(job.id || "");
const btnMore = el("button", {
class: "chat-sess-more",
text: "⋯",
title: t("scheduledJobs.menuTitle"),
onclick: (ev) => {
ev.stopPropagation();
closeScheduledJobMenu();
const menu = el("div", { class: "chat-sess-menu-pop u-overlay-fixed" }, [
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.viewRuns"),
onclick: async () => {
closeScheduledJobMenu();
const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/runs`);
const runItems = (runs.items || []).map((r) => ({ ...r, job_name: job.name || jobId }));
renderRunsTable(runItems, String(job.name || jobId));
},
}),
...(canWrite
? [
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.edit"),
onclick: () => {
closeScheduledJobMenu();
openEditModal(job);
},
}),
]
: []),
el("button", {
class: "chat-sess-menu-item",
text: job.status === "active" ? t("scheduledJobs.pause") : t("scheduledJobs.resume"),
onclick: async () => {
closeScheduledJobMenu();
const path =
job.status === "active"
? `/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/pause`
: `/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/resume`;
await apiPost(path, {});
await loadJobs();
},
}),
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.runNow"),
onclick: async () => {
closeScheduledJobMenu();
const out = await apiPost(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/run-now`, {});
if (out && out.skipped) {
msg.textContent = t("scheduledJobs.skippedOverlap");
} else {
msg.textContent = t("scheduledJobs.triggered");
}
await loadJobs();
},
}),
...(canWrite
? [
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.delete"),
onclick: async () => {
closeScheduledJobMenu();
if (!window.confirm(t("scheduledJobs.deleteConfirm"))) return;
await fetch(resolveAdminApiUrl(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}`), {
method: "DELETE",
headers: { authorization: `Bearer ${getStoredAuthToken()}`, accept: "application/json" },
});
await loadJobs();
},
}),
]
: []),
]);
const rect = ev.currentTarget.getBoundingClientRect();
document.body.appendChild(menu);
const mrect = menu.getBoundingClientRect();
const pad = 8;
let left = rect.left - 120;
let top = rect.bottom + 4;
if (top + mrect.height > window.innerHeight - pad) {
top = rect.top - 4 - mrect.height;
}
left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width));
top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
scheduledJobMenuEl = menu;
const close = (e) => {
if (!menu.contains(e.target)) {
closeScheduledJobMenu();
document.removeEventListener("click", close);
}
};
setTimeout(() => document.addEventListener("click", close), 0);
},
});
const lastStatus = String(job.last_run_status || "").trim();
const lastRun = job.last_run_at
? `${formatSystemLocalDateTime(String(job.last_run_at))}${lastStatus ? ` (${lastStatus})` : ""}`
: "—";
const lastCell = el("td", { text: lastRun });
if (lastStatus.toLowerCase() === "failed") {
lastCell.style.color = "var(--danger, #c44)";
} else if (lastStatus.toLowerCase() === "success") {
lastCell.style.color = "var(--success, #2a7)";
}
const tr = el("tr", {}, [
tdCell(job.name || "", 24),
tdCell(`${job.schedule_kind}:${job.schedule_expr}`, 28),
tdCell(formatScheduledJobPlaybook(job), 18),
tdCell(job.status || "", 10),
tdCell(job.next_run_at || "—", 20),
lastCell,
tdCell(job.specialist || "", 14),
tdCell(formatScheduledJobDelivery(job), 12),
el("td", { class: "table__cell--actions" }, [
el("div", { class: "table__cell-actions" }, [btnMore]),
]),
]);
tbody.appendChild(tr);
}
msg.textContent = tf("scheduledJobs.count", { count: String((resp.items || []).length) });
const showedFails = await loadStats();
if (!showedFails) {
await loadLatestRuns(resp.items || []);
}
}
const nameInput = el("input", { class: "input", placeholder: t("scheduledJobs.phName") });
const kindInput = el("select", { class: "input" }, [
el("option", { value: "cron", text: "cron" }),
el("option", { value: "once", text: "once" }),
el("option", { value: "interval", text: "interval" }),
]);
const exprInput = el("input", { class: "input", placeholder: t("scheduledJobs.phExpr") });
const promptInput = el("textarea", { class: "input", rows: "5", placeholder: t("scheduledJobs.phPrompt") });
const specialistInput = el("input", { class: "input", placeholder: t("scheduledJobs.phSpecialist"), value: "generalist" });
const waChatInput = el("input", { class: "input", placeholder: t("scheduledJobs.phWhatsapp") });
async function loadMeta() {
const meta = await apiGet("/admin/api/scheduled-jobs/meta/targets");
const wx = meta.weixin_binding || {};
weixinInfo.textContent = wx.external_user_id
? tf("scheduledJobs.weixinFixed", { id: String(wx.external_user_id) })
: t("scheduledJobs.weixinMissing");
}
await loadMeta();
await loadJobs();
const createCardChildren = [
el("div", { class: "card__title", text: t("scheduledJobs.createTitle") }),
weixinInfo,
el("div", { class: "form-stack" }, [
field(t("scheduledJobs.fieldName"), nameInput, true),
el("div", { class: "form-grid" }, [
field(t("scheduledJobs.fieldKind"), kindInput),
field(t("scheduledJobs.fieldExpr"), exprInput),
]),
field(t("scheduledJobs.fieldPrompt"), promptInput, true),
el("div", { class: "form-grid" }, [
field(t("scheduledJobs.fieldSpecialist"), specialistInput),
field(t("scheduledJobs.fieldWhatsapp"), waChatInput),
]),
el("div", { class: "form-actions" }, [
el("button", {
class: "btn btn--primary",
text: t("scheduledJobs.create"),
onclick: async () => {
await apiPost("/admin/api/scheduled-jobs", {
name: nameInput.value,
schedule_kind: kindInput.value,
schedule_expr: exprInput.value,
prompt_text: promptInput.value,
specialist: specialistInput.value,
whatsapp_chat_id: waChatInput.value,
delivery: buildScheduledJobDeliveryPayload(null, waChatInput.value),
});
await loadJobs();
},
}),
]),
]),
];
return el("div", {}, [
editModal,
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.scheduledJobs") }),
el("div", { class: "row", style: "gap:8px;align-items:center;flex-wrap:wrap;" }, [
status,
el("button", { class: "btn", text: t("action.refresh"), onclick: () => loadJobs() }),
msg,
]),
el("div", { class: "card__title", style: "margin-top:10px;font-size:13px;", text: t("scheduledJobs.statsTitle") }),
statsBox,
attentionBox,
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("colgroup", {}, [
el("col", { style: "width:14%" }),
el("col", { style: "width:14%" }),
el("col", { class: "u-w-12p" }),
el("col", { style: "width:8%" }),
el("col", { class: "u-w-12p" }),
el("col", { class: "u-w-12p" }),
el("col", { style: "width:8%" }),
el("col", { style: "width:10%" }),
el("col", { style: "width:10%" }),
]),
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("scheduledJobs.colName") }),
el("th", { text: t("scheduledJobs.colSchedule") }),
el("th", { text: t("scheduledJobs.colPlaybook") }),
el("th", { text: t("scheduledJobs.colStatus") }),
el("th", { text: t("scheduledJobs.colNextRun") }),
el("th", { text: t("scheduledJobs.colLastRun") }),
el("th", { text: t("scheduledJobs.colSpecialist") }),
el("th", { text: t("scheduledJobs.colDelivery") }),
el("th", { text: t("scheduledJobs.colActions") }),
]),
]),
tbody,
]),
]),
]),
...(canWrite ? [el("div", { class: "card" }, createCardChildren)] : []),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: `${t("scheduledJobs.runHistory")} · ${t("scheduledJobs.runHistoryHint")}` }),
runsBox,
]),
]);
}
export { formatScheduledJobDelivery, formatScheduledJobPlaybook, buildScheduledJobDeliveryPayload, renderScheduledJobs };