From a7f6c9edb557b9e9503cc0820251a092ec1ef014 Mon Sep 17 00:00:00 2001 From: ION606 Date: Mon, 15 Sep 2025 10:45:38 -0400 Subject: [PATCH] modifying scheduler --- docker-compose.yml | 17 ++ scheduler/.dockerignore | 6 + scheduler/Dockerfile | 14 ++ scheduler/package.json | 16 ++ scheduler/public/index.html | 212 +++++++++++++++++++++ scheduler/public/script.js | 309 +++++++++++++++++++++++++++++++ scheduler/public/style.css | 360 ++++++++++++++++++++++++++++++++++++ scheduler/server.mjs | 305 ++++++++++++++++++++++++++++++ scheduler/templates.json | 25 +++ 9 files changed, 1264 insertions(+) create mode 100644 scheduler/.dockerignore create mode 100644 scheduler/Dockerfile create mode 100644 scheduler/package.json create mode 100644 scheduler/public/index.html create mode 100644 scheduler/public/script.js create mode 100644 scheduler/public/style.css create mode 100644 scheduler/server.mjs create mode 100644 scheduler/templates.json 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 + + + + + +
+
+ +
+

Schedules

+

manage your tasks and follow‑ups

+
+
+ + + +
+ + +
+ + + + +
+ +
+
+

your schedules

+
+ +
+
+

+ +
+ + + + + + + + + + + + + +
nameschedulestztemplateentrypointone-shotactions
+
+
+ + +
+
+

create / update schedule

+
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ + + + +
+
+ + +
+ +
+ +
+ available workflow templates +
    +
    +
    +
    +
    +
    + + + + + + \ 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