Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ English | [简体中文](README.zh-CN.md)

## Standalone monitoring, recommended companion

Cron Jobs lists OpenClaw automations through its read-only CLI API, cached for
30 seconds. Loading, partial and stale catalogs are labeled; missing legacy JSON
files no longer imply zero automations. AI Jobs is a separate optional provider:
without it the page shows "not connected", not an empty successful job catalog.

**Command Center works out of the box without Spacesuit.** It owns OpenClaw
sessions, health, jobs, usage and the common dashboard. For a minimal core-only
view, run `COMMAND_CENTER_MODE=core npm start`.
Expand Down
158 changes: 100 additions & 58 deletions lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,15 @@ var require_jobs = __commonJS({
async function handleJobsRequest2(req, res, pathname, query, method) {
const api = await getAPI();
if (!api) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Jobs API not available" }));
const listing = pathname === "/api/jobs" && method === "GET";
res.writeHead(listing ? 200 : 503, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "unavailable",
jobs: null,
error: "AI Jobs provider is not configured or could not be loaded"
})
);
return;
}
try {
Expand Down Expand Up @@ -1913,6 +1920,7 @@ var require_sessions = __commonJS({
var require_cron = __commonJS({
"src/cron.js"(exports2, module2) {
var fs2 = require("fs");
var { stripVTControlCharacters } = require("node:util");
var path2 = require("path");
function cronToHuman(expr) {
if (!expr || expr === "\u2014") return null;
Expand Down Expand Up @@ -1974,59 +1982,87 @@ var require_cron = __commonJS({
}
return expr;
}
function getCronJobs2(getOpenClawDir2) {
try {
const cronPath = path2.join(getOpenClawDir2(), "cron", "jobs.json");
if (fs2.existsSync(cronPath)) {
const data = JSON.parse(fs2.readFileSync(cronPath, "utf8"));
return (data.jobs || []).map((j) => {
let scheduleStr = "\u2014";
let scheduleHuman = null;
if (j.schedule) {
if (j.schedule.kind === "cron" && j.schedule.expr) {
scheduleStr = j.schedule.expr;
scheduleHuman = cronToHuman(j.schedule.expr);
} else if (j.schedule.kind === "once") {
scheduleStr = "once";
scheduleHuman = "One-time";
}
}
let nextRunStr = "\u2014";
if (j.state?.nextRunAtMs) {
const next = new Date(j.state.nextRunAtMs);
const now = /* @__PURE__ */ new Date();
const diffMs = next - now;
const diffMins = Math.round(diffMs / 6e4);
if (diffMins < 0) {
nextRunStr = "overdue";
} else if (diffMins < 60) {
nextRunStr = `${diffMins}m`;
} else if (diffMins < 1440) {
nextRunStr = `${Math.round(diffMins / 60)}h`;
} else {
nextRunStr = `${Math.round(diffMins / 1440)}d`;
}
}
return {
id: j.id,
name: j.name || j.id.slice(0, 8),
schedule: scheduleStr,
scheduleHuman,
nextRun: nextRunStr,
enabled: j.enabled !== false,
lastStatus: j.state?.lastStatus
};
});
function normalizeJobs(jobs) {
return jobs.map((j) => {
if (!j || typeof j.id !== "string") throw new Error("invalid_cron_job");
const schedule = j.schedule || {};
let scheduleStr = "\u2014", scheduleHuman = null;
if (schedule.kind === "cron" && typeof schedule.expr === "string") {
scheduleStr = schedule.expr;
scheduleHuman = cronToHuman(schedule.expr);
} else if (["once", "at"].includes(schedule.kind)) {
scheduleStr = "once";
scheduleHuman = "One-time";
} else if (schedule.kind === "every" && Number.isFinite(schedule.everyMs)) {
scheduleStr = `every ${schedule.everyMs / 1e3}s`;
}
} catch (e) {
console.error("Failed to get cron:", e.message);
const nextMs = j.nextRunAtMs ?? j.state?.nextRunAtMs;
let nextRun = "\u2014";
if (Number.isFinite(nextMs)) {
const mins = Math.round((nextMs - Date.now()) / 6e4);
nextRun = mins < 0 ? "overdue" : mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.round(mins / 60)}h` : `${Math.round(mins / 1440)}d`;
}
return {
id: j.id,
name: typeof j.name === "string" ? j.name : j.id.slice(0, 8),
schedule: scheduleStr,
scheduleHuman,
nextRun,
enabled: j.enabled !== false,
lastStatus: j.lastRunStatus ?? j.state?.lastStatus
};
});
}
function getCronJobs(getOpenClawDir2) {
try {
const file = path2.join(getOpenClawDir2(), "cron", "jobs.json");
if (fs2.existsSync(file))
return normalizeJobs(JSON.parse(fs2.readFileSync(file, "utf8")).jobs || []);
} catch {
}
return [];
}
module2.exports = {
cronToHuman,
getCronJobs: getCronJobs2
};
function createCronHost2({ run, now = Date.now, refreshMs = 3e4 }) {
let snapshot = { jobs: [], status: "loading", observedAt: null }, pending = null, nextCheck = 0;
function refresh() {
if (pending) return pending;
nextCheck = now() + refreshMs;
pending = Promise.resolve().then(async () => {
try {
const raw = await run(
["cron", "list", "--agent", "main", "--all", "--json", "--timeout", "5000"],
{ timeout: 8e3 }
);
if (typeof raw !== "string" || Buffer.byteLength(raw) > 4 * 1024 * 1024)
throw new Error("invalid_cron_catalog");
const data = JSON.parse(stripVTControlCharacters(raw).trim());
if (!Array.isArray(data.jobs) || data.jobs.length > 1e3)
throw new Error("invalid_cron_catalog");
snapshot = {
jobs: normalizeJobs(data.jobs),
status: data.hasMore || data.total > data.jobs.length ? "partial" : "available",
observedAt: now()
};
} catch {
snapshot = {
...snapshot,
status: snapshot.observedAt === null ? "unavailable" : "stale"
};
}
return snapshot;
}).finally(() => {
pending = null;
nextCheck = now() + refreshMs;
});
return pending;
}
function getState() {
if (now() >= nextCheck && !pending) void refresh();
return snapshot;
}
return { getState, refresh };
}
module2.exports = { cronToHuman, getCronJobs, normalizeJobs, createCronHost: createCronHost2 };
}
});

Expand Down Expand Up @@ -3140,7 +3176,8 @@ var require_state = __commonJS({
getOpenClawDir: getOpenClawDir2,
getSessions,
getSystemVitals: getSystemVitals2,
getCronJobs: getCronJobs2,
getCronJobs,
getCronStatus = () => "available",
loadOperators: loadOperators2,
calculateOperatorStats: calculateOperatorStats2,
getLlmUsage: getLlmUsage2,
Expand Down Expand Up @@ -3461,7 +3498,7 @@ var require_state = __commonJS({
console.error("[State] llmUsage:", e.message);
}
try {
cron = getCronJobs2();
cron = getCronJobs();
} catch (e) {
console.error("[State] cron:", e.message);
}
Expand Down Expand Up @@ -3520,6 +3557,7 @@ var require_state = __commonJS({
operators,
llmUsage,
cron,
cronStatus: getCronStatus(),
memory,
cerebro,
subagents,
Expand Down Expand Up @@ -4643,7 +4681,8 @@ var {
calculateOperatorStats
} = require_operators();
var { createSessionsModule } = require_sessions();
var { getCronJobs } = require_cron();
var { createCronHost } = require_cron();
var cronHost = createCronHost({ run: runOpenClawAsync });
var { getCerebroTopics, updateTopicStatus } = require_cerebro();
var {
getDailyTokenUsage,
Expand Down Expand Up @@ -4713,7 +4752,8 @@ var state = createStateModule({
getOpenClawDir,
getSessions: (opts) => sessions.getSessions(opts),
getSystemVitals,
getCronJobs: () => getCronJobs(getOpenClawDir),
getCronJobs: () => cronHost.getState().jobs,
getCronStatus: () => cronHost.getState().status,
loadOperators: () => loadOperators(DATA_DIR),
calculateOperatorStats,
getLlmUsage: () => getLlmUsage(PATHS.state),
Expand Down Expand Up @@ -4778,7 +4818,8 @@ function handleApi(req, res) {
const tokenStats = getTokenStats(sessionsList, capacity, CONFIG);
const data = {
sessions: sessionsList,
cron: getCronJobs(getOpenClawDir),
cron: cronHost.getState().jobs,
cronStatus: cronHost.getState().status,
system: state.getSystemStatus(),
activity: state.getRecentActivity(),
tokenStats,
Expand Down Expand Up @@ -5013,9 +5054,10 @@ var server = http.createServer(async (req, res) => {
)
);
} else if (pathname === "/api/cron") {
const cron = getCronJobs(getOpenClawDir);
const snapshot = cronHost.getState();
const cron = snapshot.jobs;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ cron }, null, 2));
res.end(JSON.stringify({ cron, cronStatus: snapshot.status }, null, 2));
} else if (pathname === "/api/operators") {
const method = req.method;
const data = loadOperators(DATA_DIR);
Expand Down
30 changes: 24 additions & 6 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2218,7 +2218,7 @@ <h2>🔒 Privacy Settings</h2>
if (state.capacity) renderCapacity(state.capacity);
if (state.operators) renderOperators(state.operators);
if (state.llmUsage) renderLlmUsage(state.llmUsage);
if (state.cron) renderCron(state.cron);
if (state.cron) renderCron(state.cron, state.cronStatus);
if (state.memory) renderMemory(state.memory);
if (state.cerebro) renderCerebro(state.cerebro);
if (state.subagents) renderSubagents(state.subagents);
Expand Down Expand Up @@ -2269,7 +2269,7 @@ <h2>🔒 Privacy Settings</h2>
if (sessions?.tokenStats) renderTokenStats(sessions.tokenStats);
if (operators) renderOperators(operators);
if (llm) renderLlmUsage(llm);
if (cron?.cron) renderCron(cron.cron);
if (cron?.cron) renderCron(cron.cron, cron.cronStatus);
if (memory?.memory) renderMemory(memory.memory);
if (cerebro) renderCerebro(cerebro);
if (subagents?.subagents) renderSubagents(subagents.subagents);
Expand Down Expand Up @@ -2475,7 +2475,21 @@ <h2>🔒 Privacy Settings</h2>
}

// Render just cron jobs (for progressive loading)
function renderCron(cron) {
function renderCron(
cron,
status = document.getElementById("cron-source-status")?.dataset.status || "available",
) {
const notice = document.getElementById("cron-source-status") || document.createElement("p");
notice.id = "cron-source-status";
notice.dataset.status = status;
notice.setAttribute("data-i18n", "cron.sourceUnavailable");
notice.textContent = (window.I18N?.t || ((_key, _params, fallback) => fallback))(
"cron.sourceUnavailable",
{},
"Scheduled-job data is loading, incomplete, or stale. Counts are not authoritative.",
);
notice.hidden = status === "available";
document.getElementById("cron-jobs").before(notice);
const cronEl = document.getElementById("cron-jobs");
cronData = cron; // Store for re-rendering after privacy changes

Expand All @@ -2491,7 +2505,11 @@ <h2>🔒 Privacy Settings</h2>
$set("cron-count", "textContent", cronCountText);
$set("nav-cron-count", "textContent", cron.length);

if (visibleCrons.length === 0) {
if (visibleCrons.length === 0 && status !== "available") {
cronEl.replaceChildren();
$set("cron-count", "textContent", "—");
$set("nav-cron-count", "textContent", "—");
} else if (visibleCrons.length === 0) {
smartUpdate(
cronEl,
`
Expand All @@ -2514,7 +2532,7 @@ <h2>🔒 Privacy Settings</h2>
<div class="cron-icon">⏰</div>
<div class="cron-info">
<div class="cron-name">${escapeHtml(c.name)}</div>
<div class="cron-schedule">${c.schedule}</div>
<div class="cron-schedule">${escapeHtml(c.schedule)}</div>
${humanSchedule}
</div>
<div class="cron-meta">
Expand Down Expand Up @@ -3273,7 +3291,7 @@ <h2>🔒 Privacy Settings</h2>
// Use the split render functions (avoids code duplication)
if (data.vitals) renderVitals(data.vitals);
if (data.sessions) renderSessions(data.sessions, data.pagination, data.statusCounts);
if (data.cron) renderCron(data.cron);
if (data.cron) renderCron(data.cron, data.cronStatus);
if (data.tokenStats) {
renderTokenStats(data.tokenStats);
// Also update the detailed stats
Expand Down
33 changes: 32 additions & 1 deletion public/jobs.html
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,9 @@ <h1 class="page-title" data-i18n="jobs.dashboard">🤖 AI Jobs Dashboard</h1>
</button>
</div>

<p id="jobs-unavailable" role="status" data-i18n="jobs.providerUnavailable" hidden>
AI Jobs is not connected. Scheduled OpenClaw automations are shown under Cron Jobs.
</p>
<!-- Jobs Grid -->
<div class="section">
<div id="jobs-loading" class="loading">
Expand Down Expand Up @@ -1185,19 +1188,46 @@ <h1 class="page-title" data-i18n="jobs.dashboard">🤖 AI Jobs Dashboard</h1>

// Set filter
function setFilter(filter) {
if (!jobsAvailable) return;
currentFilter = filter;
document.querySelectorAll(".filter-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.filter === filter);
});
renderJobs();
}

let jobsAvailable = false;
function showJobsUnavailable() {
jobsAvailable = false;
jobs = [];
document.getElementById("jobs-unavailable").hidden = false;
for (const id of ["jobs-loading", "jobs-empty", "jobs-grid"])
document.getElementById(id).style.display = "none";
for (const id of [
"stat-total",
"stat-active",
"stat-paused",
"stat-running",
"stat-success-rate",
"stat-failures",
"nav-jobs-count",
]) {
const element = document.getElementById(id);
if (element) element.textContent = "—";
}
}
// Fetch jobs from API
async function fetchJobs() {
try {
const res = await fetch("api/jobs");
const data = await res.json();
jobs = data.jobs || [];
if (!res.ok || data.status === "unavailable" || !Array.isArray(data.jobs)) {
showJobsUnavailable();
return;
}
jobsAvailable = true;
document.getElementById("jobs-unavailable").hidden = true;
jobs = data.jobs;
renderJobs();
updateStats();
document.getElementById("sidebar-updated").textContent = t(
Expand All @@ -1206,6 +1236,7 @@ <h1 class="page-title" data-i18n="jobs.dashboard">🤖 AI Jobs Dashboard</h1>
"Updated: " + new Date().toLocaleTimeString(),
);
} catch (e) {
showJobsUnavailable();
console.error("Failed to fetch jobs:", e);
showToast(t("jobs.toastLoadFailed", {}, "Failed to load jobs"), "error");
}
Expand Down
Loading
Loading