The simplest dashboard. One HTML file, a tiny Bun server, no framework.
The Server
Create src/dashboard-server.ts — 15 lines that proxy CLI commands as HTTP:
import { join, resolve } from "node:path";
const ROOT = resolve(import.meta.dir, "..");
const CLI = join(ROOT, "src/cli.ts");
Bun.serve({
port: 3847,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname.startsWith("/api/")) {
const args = url.pathname.replace("/api/", "").split("/").filter(Boolean);
const proc = Bun.spawnSync(["bun", "run", CLI, ...args]);
return new Response(new TextDecoder().decode(proc.stdout), {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
});
}
if (url.pathname === "/") return new Response(Bun.file(join(ROOT, "dashboard/index.html")));
return new Response("Not found", { status: 404 });
},
});
console.log("Dashboard: http://localhost:3847");
The HTML
Create dashboard/index.html. The CLI returns JSON — just fetch and render:
async function load() {
const resp = await fetch('/api/status');
const data = await resp.json();
// data.result.jobs is an array of {name, schedule, loaded, exitCode, ...}
// Render however you want
}
load();
setInterval(load, 5000);
Your coding agent can build the full HTML for you. Ask it: "build a dark-theme dashboard HTML page that fetches from /api/status and renders the jobs as cards"
Test It
bun run src/dashboard-server.ts
# Open http://localhost:3847
Companion Notes
Branch: Web Dashboard (localhost)
The simplest dashboard. A single HTML file that polls the CLI and renders job state. No build step, no framework, no dependencies.
Architecture
browser → fetch http://localhost:3847/api/status
↓
bun server (5 lines) → shells out to `jobs status`
↓
returns JSON → browser renders it
Step 1: The Server
Create src/dashboard-server.ts:
#!/usr/bin/env bun
import { resolve, join } from "node:path";
const PROJECT_ROOT = resolve(import.meta.dir, "..");
const PORT = 3847;
Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
// API: proxy CLI commands
if (url.pathname.startsWith("/api/")) {
const command = url.pathname.replace("/api/", "");
const args = command ? command.split("/") : [];
const proc = Bun.spawnSync(["bun", "run", join(PROJECT_ROOT, "src/cli.ts"), ...args]);
const stdout = new TextDecoder().decode(proc.stdout);
return new Response(stdout, {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
// Serve the dashboard HTML
if (url.pathname === "/" || url.pathname === "/index.html") {
return new Response(Bun.file(join(PROJECT_ROOT, "dashboard/index.html")));
}
return new Response("Not found", { status: 404 });
},
});
console.log(`Dashboard: http://localhost:${PORT}`);
Step 2: The HTML
Create dashboard/index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>AI Job Scheduler</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui; background: #0a0a0a; color: #e0e0e0; padding: 2rem; }
h1 { font-size: 1.2rem; color: #888; margin-bottom: 1.5rem; }
.jobs { display: grid; gap: 1rem; max-width: 800px; }
.job { background: #161616; border: 1px solid #222; border-radius: 8px; padding: 1rem 1.25rem; }
.job-header { display: flex; justify-content: space-between; align-items: center; }
.job-name { font-weight: 600; font-size: 1rem; }
.job-status { font-size: 0.8rem; padding: 2px 8px; border-radius: 4px; }
.status-loaded { background: #0a2a0a; color: #4ade80; border: 1px solid #166534; }
.status-unloaded { background: #2a1a0a; color: #fbbf24; border: 1px solid #854d0e; }
.status-error { background: #2a0a0a; color: #f87171; border: 1px solid #991b1b; }
.job-meta { font-size: 0.8rem; color: #666; margin-top: 0.5rem; }
.job-meta span { margin-right: 1.5rem; }
.refresh { font-size: 0.75rem; color: #444; margin-top: 1rem; }
</style>
</head>
<body>
<h1>AI Job Scheduler</h1>
<div id="jobs" class="jobs"></div>
<div id="refresh" class="refresh"></div>
<script>
async function load() {
try {
const resp = await fetch('/api/status');
const data = await resp.json();
const container = document.getElementById('jobs');
container.innerHTML = data.result.jobs.map(job => {
const statusClass = !job.loaded ? 'status-unloaded'
: (job.exitCode !== '0' && job.exitCode !== '-') ? 'status-error'
: 'status-loaded';
const statusText = !job.loaded ? 'unloaded'
: (job.exitCode !== '0' && job.exitCode !== '-') ? 'error'
: job.pid !== '-' ? 'running' : 'idle';
return `<div class="job">
<div class="job-header">
<span class="job-name">${job.name}</span>
<span class="job-status ${statusClass}">${statusText}</span>
</div>
<div class="job-meta">
<span>${job.schedule}</span>
<span>${job.scope}</span>
<span>exit: ${job.exitCode}</span>
</div>
</div>`;
}).join('');
document.getElementById('refresh').textContent =
`${data.result.total} jobs · updated ${new Date().toLocaleTimeString()}`;
} catch (err) {
document.getElementById('jobs').innerHTML =
`<div class="job" style="border-color: #991b1b">Connection error: ${err.message}</div>`;
}
}
load();
setInterval(load, 5000);
</script>
</body>
</html>
Step 3: Run It
bun run src/dashboard-server.ts
# → Dashboard: http://localhost:3847
Add a script to package.json: "dashboard": "bun run src/dashboard-server.ts"
What to Add Next
- Click a job → show logs (fetch
/api/logs/JOB_NAME) - Click a job → kick it (fetch
/api/kick/JOB_NAME) - Doctor status as a health bar
- Auto-refresh with visual diff (highlight changed jobs)