diff --git a/docker-compose.yml b/docker-compose.yml
index a637037..56b8615 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -120,11 +120,28 @@ services:
retries: 5
restart: unless-stopped
+ schedules-api:
+ build: ./scheduler
+ restart: unless-stopped
+ ports:
+ - "12253:12253"
+ environment:
+ - PORT=12253
+ - DATA_DIR=/app/data
+ - TEMPLATES_FILE=/app/templates.json
+ - DOCKER_SOCKET=/var/run/docker.sock
+ - TZ=America/New_York
+ volumes:
+ - schedule_data:/app/data
+ - ./templates.json:/app/templates.json:ro,Z
+ - /var/run/docker.sock:/var/run/docker.sock
+
volumes:
open-webui:
pgdata:
searxng_data:
webui_data:
+ schedule_data:
networks:
internal:
diff --git a/scheduler/.dockerignore b/scheduler/.dockerignore
new file mode 100644
index 0000000..836bd61
--- /dev/null
+++ b/scheduler/.dockerignore
@@ -0,0 +1,6 @@
+node_modules
+npm-cache
+bun.lock
+bun.lockb
+.DS_Store
+*.log
diff --git a/scheduler/Dockerfile b/scheduler/Dockerfile
new file mode 100644
index 0000000..b4413c2
--- /dev/null
+++ b/scheduler/Dockerfile
@@ -0,0 +1,14 @@
+FROM oven/bun:1 AS base
+WORKDIR /app
+
+# prod deps
+COPY package.json ./package.json
+RUN bun install --ci --production
+
+COPY server.mjs ./server.mjs
+COPY public ./public
+
+USER bun
+EXPOSE 12253
+ENV NODE_ENV=production
+CMD ["bun", "run", "server.mjs"]
diff --git a/scheduler/package.json b/scheduler/package.json
new file mode 100644
index 0000000..9bce977
--- /dev/null
+++ b/scheduler/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "ollama-scheduler",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "bun run server.mjs",
+ "dev": "bun run --hot server.mjs"
+ },
+ "dependencies": {
+ "@kubernetes/client-node": "^0.22.1",
+ "@types/node": "^24.3.3",
+ "dockerode": "^4.0.8",
+ "node-cron": "^4.2.1"
+ }
+}
diff --git a/scheduler/public/index.html b/scheduler/public/index.html
new file mode 100644
index 0000000..012cf2f
--- /dev/null
+++ b/scheduler/public/index.html
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+ Schedules • Task & Workflow Manager
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | name |
+ schedules |
+ tz |
+ template |
+ entrypoint |
+ one-shot |
+ actions |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/scheduler/public/script.js b/scheduler/public/script.js
new file mode 100644
index 0000000..36cc036
--- /dev/null
+++ b/scheduler/public/script.js
@@ -0,0 +1,309 @@
+const state = {
+ userId: localStorage.getItem("userId") || "",
+ displayName: localStorage.getItem("displayName") || "",
+};
+
+const $ = (sel) => document.querySelector(sel),
+ setText = (sel, v) => {
+ const el = $(sel);
+ if (el) el.textContent = v;
+ };
+
+const authStatusEl = $("#authStatus"),
+ listStatusEl = $("#listStatus"),
+ createStatusEl = $("#createStatus"),
+ runNowStatusEl = $("#runNowStatus"),
+ schedulesTbody = $("#schedulesTbody"),
+ templatesUl = $("#templatesUl");
+
+// update login ui from state
+function paintAuth() {
+ $("#userId").value = state.userId || "";
+ $("#displayName").value = state.displayName || "";
+ if (state.userId) {
+ authStatusEl.textContent = `logged in as ${state.displayName ? state.displayName + " · " : ""
+ }${state.userId}`;
+ } else {
+ authStatusEl.textContent = "not logged in";
+ }
+}
+
+const r = document; // alias to keep things smol
+
+const prefersDark = r.defaultView?.matchMedia?.('(prefers-color-scheme: dark)').matches === true,
+ stored = localStorage.getItem('theme'),
+ initial = stored ?? (prefersDark ? 'dark' : 'light');
+
+const applyTheme = (mode) => {
+ const b = r.body;
+ b.classList.remove('theme-dark', 'theme-light');
+ b.classList.add(mode === 'dark' ? 'theme-dark' : 'theme-light');
+
+ const icon = r.querySelector('#themeToggleIcon'),
+ label = r.querySelector('#themeToggleLabel');
+
+ if (icon && label) {
+ const dark = mode === 'dark';
+ icon.textContent = dark ? '🌙' : '☀️';
+ label.textContent = dark ? 'Dark' : 'Light';
+ }
+
+ const btn = r.querySelector('#themeToggle');
+ if (btn) btn.setAttribute('aria-pressed', String(mode === 'dark'));
+};
+
+applyTheme(initial);
+
+r.querySelector('#themeToggle')?.addEventListener('click', () => {
+ const isDark = r.body.classList.contains('theme-dark'),
+ next = isDark ? 'light' : 'dark';
+
+ applyTheme(next);
+ try { localStorage.setItem('theme', next); } catch (_) { /* ignore */ }
+});
+
+if (window.matchMedia) {
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
+ mq.addEventListener?.("change", (e) => {
+ // only adapt to system changes when user hasn't explicitly chosen a theme
+ if (!localStorage.getItem('theme')) applyTheme(e.matches ? 'dark' : 'light');
+ });
+}
+
+// wrap fetch to always attach x-user-id
+async function apiFetch(url, options = {}) {
+ const headers = new Headers(options.headers || {});
+ if (!state.userId)
+ throw new Error(
+ "no user id set — use the login form first"
+ );
+ headers.set("x-user-id", state.userId); // custom header
+ if (
+ !headers.has("content-type") &&
+ options.body &&
+ !(options.body instanceof FormData)
+ ) {
+ headers.set("content-type", "application/json");
+ }
+
+ const resp = await fetch(url, { ...options, headers });
+ if (!resp.ok) {
+ // try to surface json error bodies
+ let msg = `${resp.status} ${resp.statusText}`;
+ try {
+ const data = await resp.json();
+ if (data && data.error) msg = data.error;
+ } catch { }
+ throw new Error(msg);
+ }
+ return resp;
+}
+
+// render list
+function renderSchedules(items = []) {
+ schedulesTbody.innerHTML = "";
+ items.forEach((it) => {
+ const tr = document.createElement("tr");
+ const tRef = it.templateRef
+ ? it.templateRef.clusterScope
+ ? `(cluster) ${it.templateRef.name}`
+ : it.templateRef.name
+ : "";
+ tr.innerHTML = `
+ ${escapeHtml(it.displayName || it.name || "")} |
+ ${(it.schedules || []).map(escapeHtml).join(" ")} |
+ ${escapeHtml(it.timezone || "")} |
+ ${escapeHtml(tRef)} |
+ ${escapeHtml(it.entrypoint || "")} |
+ ${it.oneShot ? "yes" : "no"} |
+
+
+ |
+ `;
+ schedulesTbody.appendChild(tr);
+ });
+}
+
+// tiny escape helper
+function escapeHtml(s = "") {
+ return String(s)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">");
+}
+
+// wire up events
+$("#loginForm").addEventListener("submit", (e) => {
+ e.preventDefault();
+ const userId = $("#userId").value.trim(),
+ displayName = $("#displayName").value.trim();
+ if (!userId) {
+ authStatusEl.textContent = "please enter a user id";
+ return;
+ }
+ state.userId = userId;
+ state.displayName = displayName;
+ localStorage.setItem("userId", state.userId);
+ localStorage.setItem("displayName", state.displayName);
+ paintAuth();
+});
+
+$("#logoutBtn").addEventListener("click", () => {
+ localStorage.removeItem("userId");
+ localStorage.removeItem("displayName");
+ state.userId = "";
+ state.displayName = "";
+ paintAuth();
+});
+
+$("#refreshBtn").addEventListener("click", async () => {
+ try {
+ listStatusEl.textContent = "loading...";
+ const res = await apiFetch("/api/schedules");
+ const data = await res.json();
+ renderSchedules(data.items || []);
+ listStatusEl.textContent = `loaded ${Array.isArray(data.items) ? data.items.length : 0
+ } schedule(s)`;
+ } catch (e) {
+ listStatusEl.textContent = `error: ${e.message}`;
+ }
+});
+
+// delete handler (delegated)
+schedulesTbody.addEventListener("click", async (e) => {
+ const target = e.target;
+ if (!(target instanceof HTMLButtonElement)) return;
+
+ const name = target.getAttribute("data-del");
+ if (!name) return;
+
+ try {
+ target.disabled = true;
+ const res = await apiFetch(`/schedules/${name}`, {
+ method: "DELETE",
+ });
+
+ if (res.status === 204) {
+ target.closest("tr")?.remove();
+ listStatusEl.textContent = "deleted";
+ } else {
+ listStatusEl.textContent = "unexpected response";
+ }
+ } catch (err) {
+ listStatusEl.textContent = `error: ${err.message}`;
+ } finally {
+ target.disabled = false;
+ }
+});
+
+// create/update schedule
+$("#createForm").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ try {
+ createStatusEl.textContent = "saving...";
+ const name = $("#name").value.trim(),
+ tz = $("#tz").value.trim() || "America/New_York",
+ iso = $("#iso").value
+ ? new Date($("#iso").value).toISOString()
+ : "",
+ cron = $("#cron").value.trim(),
+ templateName = $("#templateName").value.trim(),
+ entrypoint = $("#entrypoint").value.trim(),
+ clusterScope = $("#clusterScope").checked,
+ oneShot = $("#oneShot").checked,
+ paramsRaw = $("#params").value.trim();
+
+ let parameters = {};
+ if (paramsRaw) {
+ try {
+ parameters = JSON.parse(paramsRaw);
+ } catch {
+ throw new Error("parameters must be valid json");
+ }
+ }
+
+ const payload = {
+ name,
+ when: cron ? { cron } : { iso },
+ tz,
+ oneShot,
+ template: { name: templateName, clusterScope },
+ parameters,
+ entrypoint: entrypoint || undefined,
+ };
+
+ await apiFetch("/schedules", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+
+ createStatusEl.textContent = "saved ✅";
+ $("#refreshBtn").click();
+ } catch (err) {
+ createStatusEl.textContent = `error: ${err.message}`;
+ }
+});
+
+// run now
+$("#runNowForm").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ try {
+ runNowStatusEl.textContent = "starting...";
+ const name = $("#rnName").value.trim() || "ad-hoc",
+ templateName = $("#rnTemplateName").value.trim(),
+ entrypoint = $("#rnEntrypoint").value.trim(),
+ clusterScope = $("#rnClusterScope").checked,
+ paramsRaw = $("#rnParams").value.trim();
+
+ let parameters = {};
+ if (paramsRaw) {
+ try {
+ parameters = JSON.parse(paramsRaw);
+ } catch {
+ throw new Error("parameters must be valid json");
+ }
+ }
+
+ const payload = {
+ name,
+ template: { name: templateName, clusterScope },
+ entrypoint: entrypoint || undefined,
+ parameters,
+ };
+
+ await apiFetch("/run-now", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+ runNowStatusEl.textContent = "started ✅";
+ } catch (err) {
+ runNowStatusEl.textContent = `error: ${err.message}`;
+ }
+});
+
+// load workflow templates for convenience
+$("#loadTemplatesBtn").addEventListener("click", async () => {
+ try {
+ templatesUl.innerHTML = "";
+ templatesUl.parentElement.open = true;
+
+ const res = await apiFetch("/api/workflowtemplates"),
+ data = await res.json();
+
+ (data.items || []).forEach((t) => {
+ const li = document.createElement("li");
+ li.textContent = t.name;
+ templatesUl.appendChild(li);
+ });
+ } catch (e) {
+ templatesUl.innerHTML = `error: ${escapeHtml(
+ e.message
+ )}`;
+ }
+});
+
+// boot
+paintAuth();
+
+// auto-refresh if already logged in
+if (state.userId) $("#refreshBtn").click();
\ No newline at end of file
diff --git a/scheduler/public/style.css b/scheduler/public/style.css
new file mode 100644
index 0000000..010d7c3
--- /dev/null
+++ b/scheduler/public/style.css
@@ -0,0 +1,360 @@
+:root {
+ /* base tokens */
+ --bg: #0b1224;
+ --bg-2: #0e1730;
+ --surface: #0f1c3a;
+ --card: #0f1b33;
+ --text: #e7eef9;
+ --muted: #9fb2ce;
+ --border: rgba(255, 255, 255, 0.1);
+ --accent: #7aa2ff;
+ --accent-600: #4f7dff;
+ --danger: #ef4444;
+ --ok: #16a34a;
+
+ --radius-lg: 16px;
+ --radius-md: 12px;
+ --radius-sm: 8px;
+ --gap: 1rem;
+ --maxw: 1120px;
+
+ color-scheme: dark light;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
+ font-size: 16px;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ --bg: #f6f8fc;
+ --bg-2: #eef2fb;
+ --surface: #ffffff;
+ --card: #ffffff;
+ --text: #0f172a;
+ --muted: #6b7280;
+ --border: #e5e9f2;
+ --accent: #2563eb;
+ --accent-600: #1e40af;
+ }
+}
+
+body.theme-light {
+ color-scheme: light;
+}
+
+body.theme-dark {
+ color-scheme: dark;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ background:
+ radial-gradient(60rem 60rem at -10% -20%, rgba(79, 125, 255, 0.15), transparent 60%),
+ radial-gradient(60rem 60rem at 110% 120%, rgba(122, 162, 255, 0.17), transparent 60%),
+ linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
+ color: var(--text);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* header */
+.app-header {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ backdrop-filter: saturate(140%) blur(8px);
+ background: color-mix(in srgb, var(--bg-2) 75%, transparent);
+ border-bottom: 1px solid var(--border);
+ padding: 1rem clamp(1rem, 3vw, 1.25rem);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.brand {
+ display: flex;
+ gap: .9rem;
+ align-items: center;
+}
+
+.logo {
+ width: 40px;
+ height: 40px;
+ display: grid;
+ place-items: center;
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+ border-radius: 12px;
+}
+
+.titles h1 {
+ margin: 0;
+ font-size: 1.35rem;
+ letter-spacing: -0.01em;
+}
+
+.subtitle {
+ margin: .15rem 0 0 0;
+ color: var(--muted);
+ font-size: .95rem;
+}
+
+/* content */
+.content-grid {
+ max-width: var(--maxw);
+ margin: 1.2rem auto 2rem;
+ padding: 0 clamp(1rem, 3vw, 1.25rem);
+ display: grid;
+ grid-template-columns: 360px 1fr;
+ gap: 1.2rem;
+}
+
+@media (max-width: 980px) {
+ .content-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.stack {
+ display: grid;
+ gap: 1rem;
+}
+
+.col-left {}
+
+.col-right {}
+
+/* cards */
+.card {
+ background: linear-gradient(180deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, .02)), var(--card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 10px 30px rgba(2, 6, 23, 0.28), 0 1px 0 rgba(255, 255, 255, .04) inset;
+}
+
+.card.compact {
+ padding: .55rem .8rem;
+ border-radius: 999px;
+}
+
+.card-header {
+ padding: 1rem 1rem .75rem;
+ border-bottom: 1px dashed var(--border);
+}
+
+.card>.form-stack,
+.card>.table-wrap,
+.card>p {
+ padding: 1rem;
+}
+
+/* forms */
+.form-stack {
+ display: grid;
+ gap: .9rem;
+}
+
+.row {
+ display: grid;
+ gap: .9rem;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ align-items: start;
+}
+
+@media (max-width: 720px) {
+ .row {
+ grid-template-columns: 1fr;
+ }
+}
+
+label {
+ display: block;
+ font-weight: 600;
+ font-size: .92rem;
+ margin-bottom: .3rem;
+ color: var(--text);
+}
+
+input[type="text"],
+input[type="datetime-local"],
+select,
+textarea {
+ width: 100%;
+ padding: 0.65rem 0.8rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
+ background: color-mix(in srgb, var(--surface) 92%, transparent);
+ color: var(--text);
+ outline: none;
+ font-size: .98rem;
+ transition: box-shadow .18s ease, border-color .12s ease, transform .06s ease, background .18s ease;
+}
+
+input::placeholder,
+textarea::placeholder {
+ color: color-mix(in srgb, var(--muted) 80%, transparent);
+}
+
+input:focus,
+select:focus,
+textarea:focus,
+.theme-toggle:focus,
+button:focus {
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 65%, transparent);
+ border-color: var(--accent);
+}
+
+textarea {
+ min-height: 120px;
+ font-family: ui-monospace, Menlo, "Roboto Mono", SFMono-Regular, monospace;
+}
+
+.checkbox label {
+ font-weight: 500;
+ display: inline-flex;
+ gap: .5rem;
+ align-items: center;
+}
+
+/* buttons */
+button {
+ padding: 0.6rem 0.9rem;
+ border-radius: 10px;
+ border: 1px solid rgba(2, 6, 23, 0.1);
+ cursor: pointer;
+ background: linear-gradient(180deg, var(--accent) 0%, var(--accent-600) 100%);
+ color: #fff;
+ font-weight: 650;
+ box-shadow: 0 10px 26px rgba(122, 162, 255, .18);
+ transition: transform .08s ease, box-shadow .12s ease, opacity .12s ease, background .18s ease;
+}
+
+button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 14px 34px rgba(122, 162, 255, .22);
+}
+
+button:active {
+ transform: translateY(0) scale(.997);
+}
+
+button[disabled] {
+ opacity: .6;
+ cursor: not-allowed;
+ box-shadow: none;
+}
+
+button[aria-variant="ghost"] {
+ background: transparent;
+ color: var(--accent);
+ border: 1px dashed color-mix(in srgb, var(--accent) 30%, transparent);
+ box-shadow: none;
+}
+
+/* theme toggle refinements */
+.theme-toggle {
+ border-color: color-mix(in srgb, var(--border) 60%, transparent);
+ background: color-mix(in srgb, var(--surface) 85%, transparent);
+ color: var(--text);
+}
+
+/* tables */
+.table-wrap {
+ overflow: auto;
+ border-radius: calc(var(--radius-lg) - 2px);
+ border: 1px dashed var(--border);
+ background: color-mix(in srgb, var(--surface) 86%, transparent);
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ min-width: 760px;
+ font-size: .96rem;
+}
+
+thead th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: linear-gradient(180deg, rgba(255, 255, 255, .07), rgba(255, 255, 255, .03));
+ backface-visibility: hidden;
+ text-align: left;
+ font-weight: 700;
+ font-size: .9rem;
+ color: var(--muted);
+}
+
+th,
+td {
+ padding: .75rem .9rem;
+ border-bottom: 1px dashed var(--border);
+ vertical-align: middle;
+}
+
+tbody tr:hover {
+ background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 9%, transparent), transparent);
+}
+
+td:first-child {
+ font-weight: 700;
+}
+
+/* helpers */
+.actions {
+ display: flex;
+ gap: .5rem;
+ align-items: center;
+}
+
+.actions.wrap {
+ flex-wrap: wrap;
+}
+
+.actions.between {
+ justify-content: space-between;
+}
+
+.muted {
+ color: var(--muted);
+ opacity: .95;
+}
+
+.status {
+ display: inline-block;
+ padding: .25rem .6rem;
+ border-radius: 999px;
+ font-size: .84rem;
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+ color: var(--accent-600);
+ border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);
+}
+
+/* inline code */
+code.inline {
+ padding: .14rem .3rem;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--surface) 92%, transparent);
+ font-family: ui-monospace, Menlo, monospace;
+ font-size: .9rem;
+}
+
+/* high contrast preference gets stronger focus */
+@media (prefers-contrast: more) {
+
+ input:focus,
+ select:focus,
+ textarea:focus,
+ button:focus,
+ .theme-toggle:focus {
+ box-shadow: 0 0 0 3px #fff, 0 0 0 5px var(--accent);
+ }
+}
\ No newline at end of file
diff --git a/scheduler/server.mjs b/scheduler/server.mjs
new file mode 100644
index 0000000..dd4d233
--- /dev/null
+++ b/scheduler/server.mjs
@@ -0,0 +1,305 @@
+import http from 'http'
+import fs from 'fs'
+import path from 'path'
+import { fileURLToPath } from 'url'
+import Docker from 'dockerode'
+import cron from 'node-cron'
+
+const LABEL_USER_KEY = 'openwebui.user-id',
+ ANNO_DISPLAY_NAME = 'openwebui/display-name'
+
+const __filename = fileURLToPath(import.meta.url),
+ __dirname = path.dirname(__filename),
+
+ // folders/files
+ DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'),
+ SCHEDULES_FILE = path.join(DATA_DIR, 'schedules.json'),
+ TEMPLATES_FILE = process.env.TEMPLATES_FILE || path.join(__dirname, 'templates.json'),
+
+ // defaults
+ DEFAULT_TZ = 'America/New_York',
+ PORT = Number(process.env.PORT) || 12253
+
+// connect to docker (via local socket by default)
+const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' })
+
+// in-memory schedule registry
+const tasks = new Map() // key -> { task, def }
+
+const readBodyJson = (req) => new Promise((resolve, reject) => {
+ let d = ''; req.on('data', c => d += c)
+ req.on('end', () => { try { resolve(JSON.parse(d || '{}')) } catch (e) { reject(e) } })
+ req.on('error', reject)
+}),
+ ensureDir = (p) => { try { fs.mkdirSync(p, { recursive: true }) } catch { } },
+ readJsonFile = (p, fallback = null) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return fallback } },
+ writeJsonFile = (p, obj) => fs.writeFileSync(p, JSON.stringify(obj, null, 2))
+
+// build cron string from an iso timestamp in a timezone (same as your original)
+const cronFromISO = (iso, tz = DEFAULT_TZ) => {
+ const dt = new Date(iso),
+ parts = new Intl.DateTimeFormat('en-US', {
+ timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric',
+ hour: 'numeric', minute: '2-digit', hour12: false
+ }).formatToParts(dt).reduce((a, p) => (a[p.type] = p.value, a), {})
+ const m = Number(parts.month), d = Number(parts.day), h = Number(parts.hour), min = Number(parts.minute)
+ return `${min} ${h} ${d} ${m} *`
+}
+
+// derive a docker-safe, user-scoped name and preserve a human display name
+const scopedName = (name, userId) => {
+ const base = String(name).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40),
+ suffix = String(userId).toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 8) || 'anon'
+ return `${base}--u-${suffix}`
+}
+
+// ensure we have a user id header
+const requireUserId = (req) => {
+ const userId = String(req.headers['x-user-id'] || '').trim()
+ if (!userId) throw Object.assign(new Error('missing x-user-id header'), { status: 401 })
+ return userId
+}
+
+// load templates (maps "name" -> { image, command?, args?, env? })
+const loadTemplates = () => readJsonFile(TEMPLATES_FILE, { items: [] }).items || []
+const findTemplate = (name) => loadTemplates().find(t => t.name === name)
+
+// create/start a container for a template (run once)
+async function runContainer({ displayName, template, parameters = {}, userId, entrypoint }) {
+ if (!template?.name) throw Object.assign(new Error('missing template.name'), { status: 400 })
+ const t = findTemplate(template.name)
+ if (!t) throw Object.assign(new Error(`unknown template: ${template.name}`), { status: 404 })
+
+ // env: pass user + params as env vars (simple & portable)
+ const env = [
+ `USER_ID=${userId}`,
+ `DISPLAY_NAME=${displayName}`,
+ ...Object.entries(parameters).map(([k, v]) => `PARAM_${String(k).toUpperCase()}=${String(v)}`)
+ ]
+
+ // image pull if absent then create+start (mirrors docker run flow)
+ // ref: docker engine api sequence: create -> pull if 404 -> create -> start
+ // https://docs.docker.com/reference/api/engine/version/v1.24/ (section 4.1)
+ try {
+ await docker.getImage(t.image).inspect()
+ } catch {
+ await new Promise((resolve, reject) => {
+ docker.pull(t.image, (err, stream) => {
+ if (err) return reject(err)
+ docker.modem.followProgress(stream, (err2) => err2 ? reject(err2) : resolve())
+ })
+ })
+ }
+
+ const nameActual = `${scopedName(displayName || t.name, userId)}-${Math.random().toString(36).slice(2, 8)}`
+
+ // build container create options
+ const createOpts = {
+ Image: t.image,
+ // optional explicit entrypoint/cmd wiring
+ Entrypoint: entrypoint ? [entrypoint] : (t.entrypoint ? [].concat(t.entrypoint) : undefined),
+ Cmd: t.command ? [].concat(t.command, t.args || []) : (t.args ? [].concat(t.args) : undefined),
+ Env: env,
+ Labels: {
+ [LABEL_USER_KEY]: userId,
+ [ANNO_DISPLAY_NAME]: displayName || t.name
+ },
+ HostConfig: {
+ AutoRemove: true
+ },
+ name: nameActual
+ }
+
+ const container = await docker.createContainer(createOpts) // create
+ await container.start() // start
+ return { id: container.id, name: nameActual }
+}
+
+// persistence of schedules (for restart durability)
+ensureDir(DATA_DIR)
+const persist = () => {
+ const defs = [...tasks.values()].map(v => v.def)
+ writeJsonFile(SCHEDULES_FILE, { items: defs })
+}
+const restore = () => {
+ const saved = readJsonFile(SCHEDULES_FILE, { items: [] }).items || []
+ for (const def of saved) scheduleOrReplace(def)
+}
+
+// schedule management (create/update)
+function scheduleOrReplace(def) {
+ // stop existing
+ const key = def.name
+ if (tasks.has(key)) { try { tasks.get(key).task.stop() } catch { } tasks.delete(key) }
+
+ // forbid overlapping runs per schedule (like argo's Forbid)
+ let running = false
+ const task = cron.schedule(def.schedule, async () => {
+ if (running) return
+ running = true
+ try {
+ await runContainer({
+ displayName: def.displayName,
+ template: def.template,
+ parameters: def.parameters,
+ userId: def.userId,
+ entrypoint: def.entrypoint
+ })
+ // one-shot: stop after first success
+ if (def.oneShot) {
+ try { task.stop() } catch { }
+ tasks.delete(key)
+ persist()
+ }
+ } catch (e) {
+ // you could log or collect errors here
+ } finally {
+ running = false
+ }
+ }, { timezone: def.timezone || DEFAULT_TZ })
+
+ tasks.set(key, { task, def })
+ task.start()
+ persist()
+}
+
+// convert input to schedule def
+const toScheduleDef = ({ name, when, tz = DEFAULT_TZ, oneShot = false, template = { name: '' }, parameters = {}, entrypoint, userId }) => {
+ const schedule = when?.cron ?? cronFromISO(when?.iso, tz)
+ return {
+ name: scopedName(name, userId),
+ displayName: name,
+ userId,
+ timezone: tz,
+ schedule,
+ oneShot: Boolean(oneShot),
+ template,
+ parameters,
+ entrypoint
+ }
+}
+
+const publicDir = path.join(__dirname, 'public');
+
+function reqToURL(req) {
+ let pathname = '/';
+ try {
+ pathname = new URL(req.url, `http://${req.headers.host || 'localhost'}`).pathname;
+ } catch {
+ pathname = req.url || '/';
+ }
+
+ return pathname;
+}
+
+// http server
+const server = http.createServer(async (req, res) => {
+ try {
+ // very light cors
+ const origin = req.headers.origin || '*'
+ res.setHeader('access-control-allow-origin', origin)
+ res.setHeader('vary', 'origin')
+ res.setHeader('access-control-allow-headers', 'content-type, x-user-id')
+ res.setHeader('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS')
+ if (req.method === 'OPTIONS') return res.writeHead(204).end()
+
+ const pathname = reqToURL(req),
+ allowed = ['/', '/index.html', '/script.js', '/style.css'];
+
+
+ //#region GET requests
+
+ // list schedules for the calling user
+ if (req.method === 'GET' && pathname === '/api/schedules') {
+ const userId = requireUserId(req),
+ items = [...tasks.values()]
+ .map(v => v.def)
+ .filter(d => d.userId === userId)
+ .map(d => ({
+ name: d.name,
+ displayName: d.displayName,
+ userId: d.userId,
+ timezone: d.timezone,
+ schedules: [d.schedule],
+ oneShot: d.oneShot,
+ templateRef: d.template,
+ entrypoint: d.entrypoint
+ }));
+
+ return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }))
+ }
+
+ // list "workflow templates" => just expose templates.json names
+ if (req.method === 'GET' && pathname === '/api/workflowtemplates') {
+ const items = loadTemplates().map(t => ({ name: t.name }))
+ return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }))
+ }
+
+ if (req.method === 'GET' && allowed.includes(pathname)) {
+ try {
+ const fileName = pathname === '/' ? 'index.html' : pathname.slice(1),
+ filePath = path.join(publicDir, fileName),
+ ext = path.extname(fileName).toLowerCase(),
+ type = ext === '.js' ? 'application/javascript' : ext === '.css' ? 'text/css' : 'text/html; charset=utf-8',
+ content = fs.readFileSync(filePath, 'utf8')
+ return res.writeHead(200, { 'content-type': type }).end(content);
+ } catch {
+ return res.writeHead(404).end('ui not found');
+ }
+
+ }
+
+ // DO NOT PUT ANY GET REQUESTS BELOW THIS LINE, THEY WILL FAIL
+
+ //#endregion
+
+ // create/update a user-scoped schedule
+ if (req.method === 'POST' && pathname === '/schedules') {
+ const userId = requireUserId(req),
+ input = await readBodyJson(req),
+ def = toScheduleDef({ ...input, userId })
+ scheduleOrReplace(def)
+ return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
+ }
+
+ // run a job now for the calling user (no schedule)
+ if (req.method === 'POST' && pathname === '/run-now') {
+ const userId = requireUserId(req),
+ input = await readBodyJson(req)
+ await runContainer({
+ displayName: input.name,
+ template: input.template,
+ parameters: input.parameters || {},
+ userId,
+ entrypoint: input.entrypoint
+ })
+ return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
+ }
+
+ // delete a schedule owned by the calling user
+ if (req.method === 'DELETE' && pathname.startsWith('/schedules/')) {
+ const userId = requireUserId(req),
+ nameParam = decodeURIComponent(pathname.split('/').pop()),
+ // stored names are already scoped; accept either raw or scoped
+ key = tasks.has(nameParam) ? nameParam : scopedName(nameParam, userId),
+ existing = tasks.get(key)?.def
+
+ if (!existing) return res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'not found' }))
+ if (existing.userId !== userId) return res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'forbidden: schedule not owned by this user' }))
+
+ try { tasks.get(key).task.stop() } catch { }
+ tasks.delete(key)
+ persist()
+ return res.writeHead(204).end()
+ }
+
+ res.writeHead(404).end('not found')
+ } catch (e) {
+ const code = Number(e.status) || 500
+ res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: e.message || String(e) }))
+ }
+})
+
+// boot
+restore()
+server.listen(PORT, "0.0.0.0", () => console.log(`schedules api listening on ${server.address().address}:${PORT}`));
diff --git a/scheduler/templates.json b/scheduler/templates.json
new file mode 100644
index 0000000..735e976
--- /dev/null
+++ b/scheduler/templates.json
@@ -0,0 +1,25 @@
+{
+ "items": [
+ {
+ "name": "echo",
+ "image": "bash:5.2",
+ "command": [
+ "-lc"
+ ],
+ "args": [
+ "echo \"hello $DISPLAY_NAME from $USER_ID with $PARAM_MSG\""
+ ]
+ },
+ {
+ "name": "alpine-task",
+ "image": "alpine:3.20",
+ "command": [
+ "sh",
+ "-lc"
+ ],
+ "args": [
+ "echo running && sleep 3 && echo done"
+ ]
+ }
+ ]
+}
\ No newline at end of file