Initial commit with translated description

This commit is contained in:
2026-03-29 10:19:19 +08:00
commit 5aa1f324c6
81 changed files with 27526 additions and 0 deletions

261
src/jobs.js Normal file
View File

@@ -0,0 +1,261 @@
/**
* Jobs Dashboard API Handler
*
* Wraps the jobs API for the dashboard server.
* Uses dynamic imports to bridge CommonJS server with ESM jobs modules.
*/
const path = require("path");
const { CONFIG } = require("./config");
// Jobs directory (from config with auto-detection)
const JOBS_DIR = CONFIG.paths.jobs;
const JOBS_STATE_DIR = path.join(CONFIG.paths.state, "jobs");
let apiInstance = null;
let forceApiUnavailable = false; // For testing
/**
* Initialize the jobs API (lazy-loaded due to ESM)
*/
async function getAPI() {
if (forceApiUnavailable) return null;
if (apiInstance) return apiInstance;
try {
const { createJobsAPI } = await import(path.join(JOBS_DIR, "lib/api.js"));
apiInstance = createJobsAPI({
definitionsDir: path.join(JOBS_DIR, "definitions"),
stateDir: JOBS_STATE_DIR,
});
return apiInstance;
} catch (e) {
console.error("Failed to load jobs API:", e.message);
return null;
}
}
/**
* Reset API state for testing purposes
* @param {Object} options - Reset options
* @param {boolean} options.forceUnavailable - If true, getAPI() will return null
*/
function _resetForTesting(options = {}) {
apiInstance = null;
forceApiUnavailable = options.forceUnavailable || false;
}
/**
* Format relative time
*/
function formatRelativeTime(isoString) {
if (!isoString) return null;
const date = new Date(isoString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.round(diffMs / 60000);
if (diffMins < 0) {
const futureMins = Math.abs(diffMins);
if (futureMins < 60) return `in ${futureMins}m`;
if (futureMins < 1440) return `in ${Math.round(futureMins / 60)}h`;
return `in ${Math.round(futureMins / 1440)}d`;
}
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffMins < 1440) return `${Math.round(diffMins / 60)}h ago`;
return `${Math.round(diffMins / 1440)}d ago`;
}
/**
* Handle jobs API requests
*/
async function handleJobsRequest(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" }));
return;
}
try {
// Scheduler status: GET /api/jobs/scheduler/status (before single job route)
if (pathname === "/api/jobs/scheduler/status" && method === "GET") {
const status = await api.getSchedulerStatus();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(status, null, 2));
return;
}
// Aggregate stats: GET /api/jobs/stats (before single job route)
if (pathname === "/api/jobs/stats" && method === "GET") {
const stats = await api.getAggregateStats();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(stats, null, 2));
return;
}
// Clear cache: POST /api/jobs/cache/clear (before single job route)
if (pathname === "/api/jobs/cache/clear" && method === "POST") {
api.clearCache();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true, message: "Cache cleared" }));
return;
}
// List all jobs: GET /api/jobs
if (pathname === "/api/jobs" && method === "GET") {
const jobs = await api.listJobs();
// Enhance with relative times
const enhanced = jobs.map((job) => ({
...job,
lastRunRelative: formatRelativeTime(job.lastRun),
nextRunRelative: formatRelativeTime(job.nextRun),
}));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ jobs: enhanced, timestamp: Date.now() }, null, 2));
return;
}
// Get single job: GET /api/jobs/:id
const jobMatch = pathname.match(/^\/api\/jobs\/([^/]+)$/);
if (jobMatch && method === "GET") {
const jobId = decodeURIComponent(jobMatch[1]);
const job = await api.getJob(jobId);
if (!job) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Job not found" }));
return;
}
// Enhance with relative times
job.lastRunRelative = formatRelativeTime(job.lastRun);
job.nextRunRelative = formatRelativeTime(job.nextRun);
if (job.recentRuns) {
job.recentRuns = job.recentRuns.map((run) => ({
...run,
startedAtRelative: formatRelativeTime(run.startedAt),
completedAtRelative: formatRelativeTime(run.completedAt),
}));
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(job, null, 2));
return;
}
// Get job history: GET /api/jobs/:id/history
const historyMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/history$/);
if (historyMatch && method === "GET") {
const jobId = decodeURIComponent(historyMatch[1]);
const limit = parseInt(query.get("limit") || "50", 10);
const runs = await api.getJobHistory(jobId, limit);
const enhanced = runs.map((run) => ({
...run,
startedAtRelative: formatRelativeTime(run.startedAt),
completedAtRelative: formatRelativeTime(run.completedAt),
}));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ runs: enhanced, timestamp: Date.now() }, null, 2));
return;
}
// Run job: POST /api/jobs/:id/run
const runMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/run$/);
if (runMatch && method === "POST") {
const jobId = decodeURIComponent(runMatch[1]);
const result = await api.runJob(jobId);
res.writeHead(result.success ? 200 : 400, { "Content-Type": "application/json" });
res.end(JSON.stringify(result, null, 2));
return;
}
// Pause job: POST /api/jobs/:id/pause
const pauseMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/pause$/);
if (pauseMatch && method === "POST") {
const jobId = decodeURIComponent(pauseMatch[1]);
// Parse body for reason
let body = "";
await new Promise((resolve) => {
req.on("data", (chunk) => (body += chunk));
req.on("end", resolve);
});
let reason = null;
try {
const parsed = JSON.parse(body || "{}");
reason = parsed.reason;
} catch (_e) {
/* ignore parse errors */
}
const result = await api.pauseJob(jobId, {
by: req.authUser?.login || "dashboard",
reason,
});
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(result, null, 2));
return;
}
// Resume job: POST /api/jobs/:id/resume
const resumeMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/resume$/);
if (resumeMatch && method === "POST") {
const jobId = decodeURIComponent(resumeMatch[1]);
const result = await api.resumeJob(jobId);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(result, null, 2));
return;
}
// Skip job: POST /api/jobs/:id/skip
const skipMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/skip$/);
if (skipMatch && method === "POST") {
const jobId = decodeURIComponent(skipMatch[1]);
const result = await api.skipJob(jobId);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(result, null, 2));
return;
}
// Kill job: POST /api/jobs/:id/kill
const killMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/kill$/);
if (killMatch && method === "POST") {
const jobId = decodeURIComponent(killMatch[1]);
const result = await api.killJob(jobId);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(result, null, 2));
return;
}
// Not found
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
} catch (e) {
console.error("Jobs API error:", e);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: e.message }));
}
}
/**
* Check if a request should be handled by jobs API
*/
function isJobsRoute(pathname) {
return pathname.startsWith("/api/jobs");
}
module.exports = { handleJobsRequest, isJobsRoute, _resetForTesting };