Build a Cron Job Dashboard with Schedules and Run History
A web dashboard that manages cron schedules in a database, executes jobs with isolated workers, captures run history and logs, and alerts on failures and missed runs.
Project goal
Build a cron dashboard that stores job definitions and schedules in SQLite, fires them on time with a single scheduler loop, executes each job in an isolated child process with a timeout, and records every run outcome with logs for the dashboard to display.
Final features
- Job CRUD from the UI with cron expressions parsed and validated with a next-run preview
- A scheduler loop that fires due jobs and tolerates clock skew and missed intervals
- Each run executes in a child process with a hard timeout and captured exit code
- Run history with duration, exit status, and the last 200 lines of output per run
- Failure and missed-run detection surfaced as an unhealthy badge and a JSON health endpoint
Architecture
A scheduler loop computes the next fire time per job from a parsed cron expression, a runner spawns each execution as a child process with a timeout and streams output to the runs table, and a small HTTP server serves the dashboard UI and a JSON API. The database is the only shared state, so the UI is a pure reader and the scheduler is the only writer, which keeps the concurrency model one-page simple.
Prerequisites
- Node.js 20 LTS or later
- Understanding of cron expression fields and time zones
- Familiarity with child processes and stream piping
Dependencies
- node >=20 — Scheduler loop, child process execution, and HTTP server
- better-sqlite3 ^11.0.0 — Job definitions, schedules, and run history storage
- cron-parser ^4.9.0 — Cron expression parsing and next-run computation
- vitest ^2.1.0 — Scheduler, runner, and API tests with an injected clock
Directory structure
cron-dashboard/
├── src/
│ ├── scheduler.js
│ ├── runner.js
│ ├── server.js
│ ├── store.js
│ └── ui/
│ └── index.html
├── test/
│ ├── scheduler.test.js
│ └── runner.test.js
├── package.json
└── README.md
Phases
1. Job store and schedule computation
Create the jobs and runs tables, validate cron expressions on write, and compute the next fire time from a parsed expression.
Files: src/store.js
function createJob(db, { name, cron, command, timeoutMs }) {
const interval = parser.parseExpression(cron, { tz: jobTz });
const nextRun = interval.next().toDate().toISOString();
return db.prepare(`
INSERT INTO jobs (name, cron, command, timeout_ms, next_run, enabled)
VALUES (?, ?, ?, ?, ?, 1)`).run(name, cron, command, timeoutMs, nextRun);
}Expected behavior: Invalid cron expressions are rejected with the parser error surfaced verbatim, and valid ones store a next_run computed in the configured time zone.
2. Scheduler loop
Tick every second, claim jobs whose next_run has passed, and reschedule them from the intended fire time rather than the observed one so drift does not accumulate.
Files: src/scheduler.js
async function tick(now = new Date()) {
for (const job of store.dueJobs(now)) {
store.markRunning(job.id);
const interval = parser.parseExpression(job.cron);
store.setNextRun(job.id, interval.next().toDate());
runner.run(job).catch((err) => store.recordFailure(job.id, err.message));
}
}Expected behavior: A missed interval (simulated by an injected clock jump) fires once and reschedules from the missed time, so a five-minute outage does not fire the job five times.
3. Isolated runner
Spawn each job as a child process, enforce a timeout by killing the process tree, and stream stdout and stderr into the run record.
Files: src/runner.js
const child = spawn(job.command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
const timer = setTimeout(() => child.kill('SIGKILL'), job.timeout_ms);
child.stdout.on('data', (c) => buffer.append(c));
child.on('exit', (code) => {
clearTimeout(timer);
store.finishRun(runId, { code, durationMs, output: buffer.lastLines(200) });
});Expected behavior: A job that exceeds its timeout is killed and recorded as timed out, and output is capped to the configured tail size so a chatty job cannot bloat the database.
4. Dashboard API and UI
Serve a jobs list with health, a runs timeline per job, and an endpoint to toggle, edit, and trigger jobs manually.
Files: src/server.js, src/ui/index.html
app.get('/api/jobs', (req, res) => {
res.json(store.listJobs().map((j) => ({
...j,
health: healthOf(j),
lastRun: store.lastRun(j.id),
})));
});Expected behavior: The dashboard lists jobs with last outcome, next fire time, and a health badge, and the JSON health endpoint reports failures and missed runs for monitoring.
Testing
Run npx vitest. Scheduler tests use an injected clock to advance time in jumps and assert due-job claiming, reschedule-from-intended-time semantics, and single-fire-per-missed-interval. Runner tests execute real short-lived commands plus one that sleeps past its timeout, asserting exit codes, timeout status, and output tailing. API tests boot the server against a temp database and drive job creation, manual trigger, and the health endpoint end to end.
Troubleshooting
Jobs fire multiple times after a sleep
Claiming must be transactional and reschedule immediately on claim, before execution; the tick must never re-claim a job whose next_run was already advanced.
Timed-out jobs leave orphan processes
Spawn with detached false and kill with SIGKILL on timeout; a shell:true child needs the process group signaled, which shell kill does not do.
Clock skew makes next_run drift into the past permanently
Always recompute next_run from the expression iterator, never from Date.now arithmetic on the stored value.
Deployment
Run as a single systemd service or container with the SQLite file on persistent storage. Front the dashboard with an authenticating proxy, since job commands execute arbitrary shell by design. Point monitoring at the JSON health endpoint, and back up the database file with the online sqlite3 backup command rather than a mid-write copy.
Known limitations
- One scheduler process: running two instances doubles every fire unless the due-job claim uses a database-level lock.
- Cron expressions are evaluated in one configured time zone; DST transitions can skip or repeat a fire depending on the parser mode.
Project overview
Cron is fifty years old and still everywhere, but the classic crontab is invisible: no history, no logs after the terminal closes, no answer to “did it run while I was asleep.” A dashboard closes the gap, and building one forces the genuinely hard scheduling problems into the open.
The three hard problems this project solves are drift, isolation, and observability. Drift, because a scheduler that reschedules from the wrong moment slowly disagrees with the calendar. Isolation, because a hung job must not hang the scheduler. Observability, because the entire value of the dashboard is the run history.
The finished product delivers five capabilities:
- Job CRUD from the UI with cron expressions parsed and validated, plus a next-run preview before you save.
- A scheduler loop that fires due jobs and tolerates clock skew and missed intervals without double-firing.
- Isolated execution — each run is a child process with a hard timeout and a captured exit code.
- Run history with duration, exit status, and the last 200 lines of output per run.
- Failure and missed-run detection surfaced as an unhealthy badge and a JSON health endpoint for monitoring.
The architecture keeps the concurrency model one-page simple: a scheduler loop computes the next fire time per job from a parsed cron expression, a runner spawns each execution as a child process with a timeout and streams output to the runs table, and a small HTTP server serves the dashboard UI and the JSON API. The SQLite database is the only shared state — the UI is a pure reader and the scheduler is the only writer, so there is no locking protocol to design and no cache to invalidate.
Setup
Verify the prerequisites before starting: Node.js 20 LTS or later, an understanding of cron expression fields and time zones, and familiarity with child processes and stream piping.
The dependency footprint is three runtime packages plus the test runner:
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | Scheduler loop, child process execution, and HTTP server |
| better-sqlite3 | ^11.0.0 | Job definitions, schedules, and run history storage |
| cron-parser | ^4.9.0 | Cron expression parsing and next-run computation |
| vitest | ^2.1.0 | Scheduler, runner, and API tests with an injected clock |
Create the project and install:
mkdir cron-dashboard && cd cron-dashboard
npm init -y
npm install better-sqlite3@^11.0.0 cron-parser@^4.9.0
npm install -D vitest@^2.1.0
Configure the environment in package.json — the server entry, the test script, and the database location via the CRON_DB environment variable (a temp file in tests, persistent storage in production):
{
"name": "cron-dashboard",
"scripts": { "start": "node src/server.js", "test": "vitest run" }
}
The directory structure mirrors the three responsibilities: scheduler.js owns time, runner.js owns child processes, and server.js owns HTTP — the store sits underneath all three as the single source of truth.
Phase 1: Job store and schedule computation
The store creates the jobs and runs tables, validates cron expressions on write, and computes the next fire time from a parsed expression.
// src/store.js
import Database from 'better-sqlite3';
import parser from 'cron-parser';
export function createStore(dbPath) {
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY, name TEXT UNIQUE, cron TEXT, command TEXT,
timeout_ms INTEGER, next_run TEXT, enabled INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY, job_id INTEGER, started_at TEXT, finished_at TEXT,
exit_code INTEGER, timed_out INTEGER DEFAULT 0, duration_ms INTEGER, output TEXT
);
`);
function createJob({ name, cron, command, timeoutMs, tz }) {
const interval = parser.parseExpression(cron, { tz });
const nextRun = interval.next().toDate().toISOString();
return db.prepare(`
INSERT INTO jobs (name, cron, command, timeout_ms, next_run, enabled)
VALUES (?, ?, ?, ?, ?, 1)`).run(name, cron, command, timeoutMs, nextRun);
}
function dueJobs(now) {
return db.prepare('SELECT * FROM jobs WHERE enabled = 1 AND next_run <= ?').all(now.toISOString());
}
function setNextRun(id, date) {
db.prepare('UPDATE jobs SET next_run = ? WHERE id = ?').run(date.toISOString(), id);
}
return { db, createJob, dueJobs, setNextRun, markRunning: /* ... */ markRunningImpl(db) };
}
The design decision of this phase is validate on write, compute on write: an invalid cron expression is rejected with the parser’s error surfaced verbatim — “Expression has 4 fields” is a better error message than anything hand-rolled — and the next_run is computed in the job’s configured time zone at insert time, so the scheduler never parses an expression on the read path. Parsing is validation, and doing it once at the write boundary keeps every later tick cheap.
The dueJobs query is deliberately an index-friendly range scan on next_run: the scheduler tick asks “what is due” in one statement, rather than loading all jobs and filtering in JavaScript.
Acceptance for this phase: invalid cron expressions are rejected with the parser error surfaced verbatim, and valid ones store a next_run computed in the configured time zone.
Phase 2: Scheduler loop
The scheduler ticks every second, claims jobs whose next_run has passed, and reschedules them from the intended fire time rather than the observed one so drift does not accumulate.
// src/scheduler.js
export function createScheduler({ store, runner, tickMs = 1000, now = () => new Date() }) {
let timer = null;
async function tick() {
const current = now();
for (const job of store.dueJobs(current)) {
store.markRunning(job.id);
const interval = parser.parseExpression(job.cron);
store.setNextRun(job.id, interval.next().toDate());
runner.run(job).catch((err) => store.recordFailure(job.id, err.message));
}
}
return {
start() { timer = setInterval(tick, tickMs); },
stop() { clearInterval(timer); },
tick, // exported for tests
};
}
The subtle bug in naive schedulers is rescheduling from when the job finished: a job that takes two minutes on a five-minute schedule drifts two minutes per run forever. The fix here is rescheduling from the intended fire time using the cron iterator, so the next fire is anchored to the calendar rather than to the previous run.
Missed intervals get the same treatment, and the ordering inside the loop is the enforcement: the job is claimed and its next_run advanced before execution starts, inside the same tick. After a five-minute outage, the tick finds one due job, advances it to the next calendar slot, and fires it once — not five times. A scheduler that re-claims a job whose next_run was already advanced is the classic double-fire bug, and the transactional claim closes it.
Note the injected now function in the constructor: production passes () => new Date(), and the tests pass a controllable fake so a three-hour clock jump is one property assignment rather than a real sleep.
Acceptance for this phase: a missed interval (simulated by an injected clock jump) fires once and reschedules from the missed time.
Phase 3: Isolated runner
The runner spawns each job as a child process, enforces a timeout by killing the process, and streams stdout and stderr into the run record.
// src/runner.js
import { spawn } from 'node:child_process';
export function createRunner({ store }) {
function run(job) {
return new Promise((resolve) => {
const startedAt = new Date();
const runId = store.startRun(job.id, startedAt);
const child = spawn(job.command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
const buffer = new TailBuffer(200);
const timer = setTimeout(() => child.kill('SIGKILL'), job.timeout_ms);
child.stdout.on('data', (c) => buffer.append(c));
child.stderr.on('data', (c) => buffer.append(c));
child.on('exit', (code) => {
clearTimeout(timer);
store.finishRun(runId, {
code,
timedOut: code === null,
durationMs: Date.now() - startedAt.getTime(),
output: buffer.tail(),
});
resolve({ code, timedOut: code === null });
});
});
}
return { run };
}
Two isolation properties are load-bearing. The scheduler loop never awaits a job directly — runner.run returns a promise the scheduler ignores, so a wedged command costs one run record, not the whole dashboard. And the timeout path uses SIGKILL, not SIGTERM: a shell-spawned child that ignores polite signals still dies, and code === null is recorded as a timeout rather than a crash, which keeps the health badge honest about what actually happened.
Output is capped through a tail buffer before it ever touches the database — the last 200 lines, in memory, appended as chunks arrive. A job that logs a gigabyte writes a bounded number of lines, because an unbounded runs table is a slow-motion outage that looks like everything working.
Acceptance for this phase: a job that exceeds its timeout is killed and recorded as timed out, and output is capped to the configured tail size.
Phase 4: Dashboard API and UI
The server exposes a jobs list with health, a runs timeline per job, and endpoints to toggle, edit, and trigger jobs manually.
// src/server.js
import { createServer } from 'node:http';
export function createApp({ store, scheduler, runner }) {
function healthOf(job, lastRun) {
if (lastRun && (lastRun.exit_code !== 0 || lastRun.timed_out)) return 'failing';
if (!lastRun && new Date(job.next_run) < new Date()) return 'missed';
return 'healthy';
}
const server = createServer(async (req, res) => {
if (req.url === '/api/jobs') {
const jobs = store.listJobs().map((j) => ({
...j,
health: healthOf(j, store.lastRun(j.id)),
lastRun: store.lastRun(j.id),
}));
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify(jobs));
}
if (req.url === '/api/health') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify(store.healthSummary()));
}
if (req.method === 'POST' && req.url.startsWith('/api/jobs/') && req.url.endsWith('/trigger')) {
const id = Number(req.url.split('/')[3]);
runner.run(store.jobById(id));
res.writeHead(202);
return res.end();
}
res.writeHead(404);
res.end();
});
return server;
}
Health is a derived fact, never a stored one: a job is unhealthy when its last run failed, when it has no runs but its next_run is in the past, or when missed intervals were detected. Nothing is persisted that could disagree with the history, so the dashboard cannot lie by getting out of sync with itself — the badge is recomputed from the runs table on every request, and the JSON health endpoint reports the same derivation for monitoring systems that prefer numbers to colors.
The manual trigger endpoint answers “will this job work if I enable it” without waiting for the next calendar slot — it runs the exact same runner path as a scheduled fire, so a green manual run is genuine evidence about the scheduled one.
Acceptance for this phase: the dashboard lists jobs with last outcome, next fire time, and a health badge, and the JSON health endpoint reports failures and missed runs.
Design trade-offs
SQLite as the only shared state over a message queue. A job broker would scale horizontally, but one scheduler process with a database keeps the concurrency model explainable in a paragraph: the UI reads, the scheduler writes, the runner appends. The limitation is documented rather than hidden — running two instances doubles every fire unless the claim takes a database-level lock, and for one team’s worth of jobs, one instance is the right size.
Child processes over in-process handlers. Running job code in-process would be faster and would share state; it would also let one job’s memory leak or unhandled rejection take down the scheduler. The process boundary is the isolation contract, and the timeout enforcement is only honest because the boundary exists.
Derived health over stored health. A health column would be faster to query; it would also be a second source of truth to maintain. Deriving on read costs one join and buys the invariant that the dashboard can never contradict the run history.
200-line output tails over full logs. Full logs belong in a log system; the dashboard needs “what happened at the end,” which is where failures actually appear. Bounding the tail keeps the database small enough that the runs timeline stays fast forever.
Testing strategy
Run npx vitest. Scheduler tests use the injected clock to advance time in jumps and assert three behaviors: due-job claiming, reschedule-from-intended-time semantics, and single-fire-per-missed-interval — the clock jump test is the one that catches the double-fire regression, because it fails only when claiming and advancing separate. Runner tests execute real short-lived commands plus one that sleeps past its timeout, asserting exit codes, timeout status, and output tailing against actual process output.
API tests boot the server against a temp database and drive job creation, manual trigger, and the health endpoint end to end — the health assertions deliberately include a failing job and a never-run job so both unhealthy derivations are covered. The whole suite runs in seconds because nothing sleeps: timeouts are set to milliseconds, the clock is fake, and the only real child processes are commands that exit immediately.
Extending the project
The seams are visible in the module boundaries. Alerting is a consumer of the health derivation: a small poller on the JSON endpoint that pages when a job flips to failing — no core changes. Multi-node scheduling needs the documented lock on the due-job claim: one BEGIN IMMEDIATE around claim-and-advance turns the single-instance limitation into a safe deployment, at the cost of the database being the serialization point. Log streaming is an SSE endpoint on the run’s output buffer, which already accumulates chunks in memory before the tail write.
For deployment, run as a single systemd service or container with the SQLite file on persistent storage, and back up the database with the online sqlite3 backup command rather than a mid-write copy. Front the dashboard with an authenticating proxy — job commands execute arbitrary shell by design, so the UI must never be exposed unauthenticated. Point monitoring at the JSON health endpoint; it is the earliest signal that a schedule is quietly broken.
Troubleshooting playbook
Jobs fire multiple times after a sleep. The machine suspends for an hour, wakes, and the hourly job runs twice in the same second. The cause is the claim window: if the tick claims due jobs and the next tick starts before next_run has been advanced, both ticks claim the same row. The fix is the ordering from the scheduler phase — claiming and advancing next_run happen together, before execution starts, and the tick never re-claims a job whose next_run already moved. The clock-jump test exists specifically to fail when someone separates those two steps.
Timed-out jobs leave orphan processes. The run record says timed out, but ps shows the command still running. The cause is the shell: with shell: true, the signal goes to the shell, not necessarily to the command it spawned, and a shell that forwards nothing leaves orphans. The fix from the runner phase is killing with SIGKILL — which the shell cannot intercept or defer — and keeping detached off so the child dies with its parent if the dashboard itself goes down.
Clock skew makes next_run drift into the past permanently. After a timezone or NTP correction, a job’s next fire time is always in the past, it fires every tick, and the history fills with duplicate runs. The cause is Date.now arithmetic on the stored value — adding an interval to the observed time compounds every skew. The fix is the iterator rule: next_run is always recomputed from the cron expression’s own iterator, which anchors to the calendar, never to the wall clock that observed the previous fire.