diff --git a/.gitignore b/.gitignore
index b02f485..6a1df10 100644
--- a/.gitignore
+++ b/.gitignore
@@ -135,3 +135,4 @@ __pycache__/
*.xml
temp.*
+bun.lock
diff --git a/README.md b/README.md
index 2d76384..6feeaac 100644
--- a/README.md
+++ b/README.md
@@ -2,10 +2,10 @@
My openWebUI/searxng configs, plugins, RAG server, as well as a custom program that runs the AI's code in isolated Docker containers
-*Last updated: 2025-09-10*
+*Last updated: 2025-09-13*
> [!TIP]
-> Looking for the compose version of this? See the [compose]()
+> Looking for the compose version of this? See the [compose branch](https://git.ion606.com/ION606/ollama-plus/src/branch/compose/)
---
diff --git a/apps/children/ollama-scheduler.yaml b/apps/children/ollama-scheduler.yaml
index 1af399e..e34f886 100644
--- a/apps/children/ollama-scheduler.yaml
+++ b/apps/children/ollama-scheduler.yaml
@@ -13,7 +13,7 @@ spec:
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
- path: manifests/argo-ollama-scheduler
+ path: manifests/argo-schedules-api
syncPolicy:
automated:
prune: true
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index c3d1e61..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,131 +0,0 @@
-services:
- open-webui:
- image: ghcr.io/open-webui/open-webui:main
- container_name: open-webui
- ports:
- - "4000:8080"
- volumes:
- - open-webui:/app/backend/data
- extra_hosts:
- - host.docker.internal:host-gateway
- restart: always
- depends_on:
- - postgres
- - tools
- networks:
- - internal
-
- tools:
- container_name: openwebui_tools
- build:
- context: ./tools
- dockerfile: Dockerfile
- env_file: .env
- restart: on-failure
-
- networks:
- - internal
-
- postgres:
- image: postgres:latest
- container_name: openwebui_postgres
- restart: always
- environment:
- - POSTGRES_USER=postgres
- - POSTGRES_PASSWORD=mypassword
- - POSTGRES_DB=openwebui_db
- volumes:
- - pgdata:/var/lib/postgresql/data
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U postgres"]
- interval: 10s
- timeout: 5s
- retries: 5
- networks:
- - internal
-
- # 8080
- searxng:
- image: searxng/searxng:latest
- container_name: searxng
- volumes:
- - ./searxng.yml:/etc/searxng/settings.yml:ro,Z
- - searxng_data:/etc/searxng:rw
- restart: always
-
- # DELETEME: for local testing only (extern port closed)
- ports:
- - "4001:8080"
- networks:
- - internal
-
- coderunner:
- build:
- context: ./coderunner
- dockerfile: Dockerfile
- restart: unless-stopped
- healthcheck:
- test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8787/openapi.json"]
- interval: 30s
- timeout: 3s
- retries: 3
- start_period: 10s
-
- user: "1000:1000"
- group_add:
- - "977"
-
- # death
- environment:
- DOCKER_HOST: "unix:///var/run/docker.sock"
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock:Z
- # - ./tmp:/tmp
-
- read_only: true
- tmpfs:
- - /run:rw,nosuid,nodev
- - /tmp:rw,exec,nosuid,nodev,size=64m
-
- security_opt:
- - no-new-privileges:true
- - label=disable
- networks:
- - internal
-
- browser:
- build:
- context: ./browser
- dockerfile: Dockerfile
- container_name: browser
- networks:
- - internal
- # playwright/chromium has larger /dev/shm :D
- shm_size: "1gb"
- user: "1000:1000"
- environment:
- WEBUI_IP: "0.0.0.0"
- WEBUI_PORT: "7788"
- ports:
- - "7788:7788"
- tmpfs:
- - /opt/web-ui/tmp:rw,exec,nosuid,nodev,mode=1777,size=64m
- volumes:
- - webui_data:/data
- # - webui_env:/opt/web-ui/.env
- healthcheck:
- test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7788').read()"]
- interval: 30s
- timeout: 5s
- retries: 5
- restart: unless-stopped
-
-volumes:
- open-webui:
- pgdata:
- searxng_data:
- webui_data:
-
-networks:
- internal:
- driver: bridge
diff --git a/manifests/argo-schedules-api/deployment.yaml b/manifests/argo-schedules-api/deployment.yaml
index dbdb729..9ab920e 100644
--- a/manifests/argo-schedules-api/deployment.yaml
+++ b/manifests/argo-schedules-api/deployment.yaml
@@ -7,47 +7,42 @@ spec:
replicas: 1
selector:
matchLabels:
- - app: ollama-scheduler
+ app: ollama-scheduler
template:
metadata:
labels:
- - app: ollama-scheduler
-
+ app: ollama-scheduler
spec:
serviceAccountName: ollama-scheduler
containers:
- name: ollama-scheduler
image: docker.io/ion606/ollama-scheduler:0.1.0
imagePullPolicy: IfNotPresent
-
env:
- name: PORT
- value: "3000"
+ value: "12253"
- name: NS
value: "argo"
ports:
- name: http
- containerPort: 3000
-
+ containerPort: 12253
readinessProbe:
tcpSocket:
- - port: 3000
+ port: 12253
initialDelaySeconds: 3
periodSeconds: 10
-
livenessProbe:
tcpSocket:
- - port: 3000
+ port: 12253
initialDelaySeconds: 10
periodSeconds: 20
-
resources:
requests:
- - cpu: "50m"
- - memory: "64Mi"
+ cpu: "50m"
+ memory: "64Mi"
limits:
- - cpu: "200m"
- - memory: "256Mi"
+ cpu: "200m"
+ memory: "256Mi"
---
apiVersion: v1
kind: Service
@@ -56,9 +51,9 @@ metadata:
namespace: argo
spec:
selector:
- - app: ollama-scheduler
+ app: ollama-scheduler
ports:
- name: http
- port: 3000
- targetPort: 3000
+ port: 12253
+ targetPort: 12253
type: ClusterIP
diff --git a/scheduler/.dockerignore b/scheduler/.dockerignore
index 2eb32eb..836bd61 100644
--- a/scheduler/.dockerignore
+++ b/scheduler/.dockerignore
@@ -1,6 +1,6 @@
node_modules
npm-cache
+bun.lock
bun.lockb
.DS_Store
*.log
-
diff --git a/scheduler/Dockerfile b/scheduler/Dockerfile
index 9421b36..aef65db 100644
--- a/scheduler/Dockerfile
+++ b/scheduler/Dockerfile
@@ -6,9 +6,9 @@ COPY package.json ./package.json
RUN bun install --ci --production
COPY server.mjs ./server.mjs
+COPY public ./public
USER bun
-EXPOSE 3000
+EXPOSE 12253
ENV NODE_ENV=production
CMD ["bun", "run", "server.mjs"]
-
diff --git a/scheduler/package.json b/scheduler/package.json
index d7b2b0e..0d13237 100644
--- a/scheduler/package.json
+++ b/scheduler/package.json
@@ -8,6 +8,7 @@
"dev": "bun run --hot server.mjs"
},
"dependencies": {
- "@kubernetes/client-node": "^0.22.1"
+ "@kubernetes/client-node": "^0.22.1",
+ "@types/node": "^24.3.3"
}
}
\ No newline at end of file
diff --git a/scheduler/public/index.html b/scheduler/public/index.html
new file mode 100644
index 0000000..a646a97
--- /dev/null
+++ b/scheduler/public/index.html
@@ -0,0 +1,236 @@
+
+
+
+
+
+ Schedules UI
+
+
+
+ Manage Your Tasks and Follow-Ups!
+
+
+
+ login
+
+ enter your open webui user id (uuid). this is sent as the
+ x-user-id header on api requests.
+
+
+
+
+
+
+
+
+
your schedules
+
+
+
+
+
+
+
+
+
+ | name |
+ schedules |
+ tz |
+ template |
+ entrypoint |
+ one-shot |
+ actions |
+
+
+
+
+
+
+
+
+
+ create / update schedule
+
+
+
+ available workflow templates
+
+
+
+
+
+
+ run now
+
+
+
+
+
+
diff --git a/scheduler/public/script.js b/scheduler/public/script.js
new file mode 100644
index 0000000..494f32e
--- /dev/null
+++ b/scheduler/public/script.js
@@ -0,0 +1,267 @@
+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";
+ }
+}
+
+// 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..1c37bed
--- /dev/null
+++ b/scheduler/public/style.css
@@ -0,0 +1,96 @@
+:root {
+ color-scheme: light dark;
+ font-family: system-ui, sans-serif;
+}
+
+body {
+ margin: 2rem;
+ display: grid;
+ gap: 1.5rem;
+ max-width: 980px;
+}
+
+form,
+.card {
+ border: 1px solid #ccc;
+ padding: 1rem;
+ border-radius: 12px;
+}
+
+label {
+ display: block;
+ margin: 0.25rem 0 0.15rem;
+}
+
+input[type="text"],
+input[type="datetime-local"],
+select,
+textarea {
+ width: 100%;
+ padding: 0.5rem;
+ border-radius: 8px;
+ border: 1px solid #bbb;
+}
+
+textarea {
+ min-height: 96px;
+ font-family: ui-monospace, Menlo, monospace;
+}
+
+button {
+ padding: 0.55rem 0.9rem;
+ border-radius: 10px;
+ border: 1px solid #888;
+ cursor: pointer;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.95rem;
+}
+
+th,
+td {
+ padding: 0.5rem 0.6rem;
+ border-bottom: 1px solid #ddd;
+ text-align: left;
+}
+
+.row {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.muted {
+ opacity: 0.75;
+ font-size: 0.92rem;
+}
+
+.danger {
+ color: #a30000;
+}
+
+.ok {
+ color: #008000;
+}
+
+.actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+@media (max-width: 800px) {
+ .row {
+ grid-template-columns: 1fr;
+ }
+}
+
+code.inline {
+ padding: 0.15rem 0.3rem;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ background: #f7f7f7;
+}
\ No newline at end of file
diff --git a/scheduler/server.mjs b/scheduler/server.mjs
index 3b8445d..e708e1b 100644
--- a/scheduler/server.mjs
+++ b/scheduler/server.mjs
@@ -1,123 +1,233 @@
-// bun run server.mjs
-// tiny schedules api to manage argo cronworkflows/workflows via k8s CRDs
-// comments intentionally lowercase per original style
-
import http from 'http'
+import fs from 'fs'
+import path from 'path'
+import { fileURLToPath } from 'url'
import { KubeConfig, CustomObjectsApi } from '@kubernetes/client-node'
-const GROUP = 'argoproj.io'
-const VERSION = 'v1alpha1'
-const CRON_PLURAL = 'cronworkflows'
-const WF_PLURAL = 'workflows'
-const NAMESPACE = process.env.NS || 'argo'
+const GROUP = 'argoproj.io',
+ VERSION = 'v1alpha1',
+ CRON_PLURAL = 'cronworkflows',
+ WF_PLURAL = 'workflows',
+ NAMESPACE = process.env.NS || 'argo',
-// load cluster credentials (or fallback to local kubeconfig for dev)
-const kc = new KubeConfig()
+ // k8s label/annotation keys (must be lowercase dns-labels)
+ LABEL_USER_KEY = 'openwebui.user-id',
+ ANNO_DISPLAY_NAME = 'openwebui/display-name';
+
+// load cluster credentials
+const kc = new KubeConfig();
try { kc.loadFromCluster() } catch { kc.loadFromDefault() }
-const co = kc.makeApiClient(CustomObjectsApi)
-// helper: build cron string from an iso timestamp in a tz
+const co = kc.makeApiClient(CustomObjectsApi);
+
+// build cron string from an iso timestamp in a tz
const cronFromISO = (iso, tz = 'America/New_York') => {
- const dt = new Date(iso)
- const 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} *`
+ 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), {}),
+
+ m = Number(parts.month), d = Number(parts.day), h = Number(parts.hour), min = Number(parts.minute);
+
+ return `${min} ${h} ${d} ${m} *`;
}
-// create or update a cronworkflow that runs a workflowtemplate
+// derive a k8s-safe, user-scoped name and preserve a human display name
+const scopedName = (name, userId) => {
+ // keep to dns-1123 by trimming/normalizing a bit; add an 8-char user suffix for uniqueness
+ 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;
+}
+
+// normalize parameters and force-inject user_id
+const buildParams = (parameters = {}, userId) => {
+ const merged = { ...parameters, user_id: userId },
+ args = Object.entries(merged).map(([name, value]) => ({ name, value }));
+ return args.length ? { parameters: args } : undefined;
+}
+
+// create or update a cronworkflow that runs a workflowtemplate (scoped to user)
async function upsertCronWorkflow({
- name, when, tz = 'America/New_York', oneShot = false,
- template = { name: '', clusterScope: false },
- parameters = {}, entrypoint
+ name, when, tz = 'America/New_York', oneShot = false,
+ template = { name: '', clusterScope: false },
+ parameters = {}, entrypoint, userId
}) {
- const schedule = when.cron ?? cronFromISO(when.iso, tz)
- const args = Object.entries(parameters).map(([name, value]) => ({ name, value }))
+ const schedule = when.cron ?? cronFromISO(when.iso, tz),
+ nameActual = scopedName(name, userId),
- const body = {
- apiVersion: `${GROUP}/${VERSION}`,
- kind: 'CronWorkflow',
- metadata: { name },
- spec: {
- timezone: tz,
- schedules: [schedule],
- concurrencyPolicy: 'Forbid',
- ...(oneShot ? { stopStrategy: { expression: 'cronworkflow.succeeded >= 1' } } : {}),
- workflowSpec: {
- ...(entrypoint ? { entrypoint } : {}),
- arguments: args.length ? { parameters: args } : undefined,
- workflowTemplateRef: {
- name: template.name,
- ...(template.clusterScope ? { clusterScope: true } : {})
- }
- }
- }
- }
+ body = {
+ apiVersion: `${GROUP}/${VERSION}`,
+ kind: 'CronWorkflow',
+ metadata: {
+ name: nameActual,
+ labels: { [LABEL_USER_KEY]: userId },
+ annotations: { [ANNO_DISPLAY_NAME]: name },
+ },
+ spec: {
+ timezone: tz,
+ schedules: [schedule],
+ concurrencyPolicy: 'Forbid',
+ ...(oneShot ? { stopStrategy: { expression: 'cronworkflow.succeeded >= 1' } } : {}),
+ workflowSpec: {
+ ...(entrypoint ? { entrypoint } : {}),
+ arguments: buildParams(parameters, userId),
+ workflowTemplateRef: {
+ name: template.name,
+ ...(template.clusterScope ? { clusterScope: true } : {})
+ }
+ }
+ }
+ };
- // try patch, else create
- try {
- await co.patchNamespacedCustomObject(
- GROUP, VERSION, NAMESPACE, CRON_PLURAL, name, body,
- undefined, undefined, undefined,
- { headers: { 'content-type': 'application/merge-patch+json' } }
- )
- } catch {
- await co.createNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, body)
- }
+ // try patch, else create
+ try {
+ await co.patchNamespacedCustomObject(
+ GROUP, VERSION, NAMESPACE, CRON_PLURAL, nameActual, body,
+ undefined, undefined, undefined,
+ { headers: { 'content-type': 'application/merge-patch+json' } }
+ );
+ } catch {
+ await co.createNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, body);
+ }
}
-// run immediately (no schedule) by creating a workflow from the same template
-async function runNow({ name, template, parameters = {}, entrypoint }) {
- const args = Object.entries(parameters).map(([name, value]) => ({ name, value }))
- const wf = {
- apiVersion: `${GROUP}/${VERSION}`,
- kind: 'Workflow',
- metadata: { generateName: `${name}-` },
- spec: {
- ...(entrypoint ? { entrypoint } : {}),
- arguments: args.length ? { parameters: args } : undefined,
- workflowTemplateRef: {
- name: template.name,
- ...(template.clusterScope ? { clusterScope: true } : {})
- }
- }
- }
- await co.createNamespacedCustomObject(GROUP, VERSION, NAMESPACE, WF_PLURAL, wf)
+// run immediately (no schedule) by creating a workflow from the same template (scoped to user)
+async function runNow({ name, template, parameters = {}, entrypoint, userId }) {
+ const wf = {
+ apiVersion: `${GROUP}/${VERSION}`,
+ kind: 'Workflow',
+ metadata: {
+ generateName: `${scopedName(name, userId)}-`,
+ labels: { [LABEL_USER_KEY]: userId },
+ annotations: { [ANNO_DISPLAY_NAME]: name },
+ },
+ spec: {
+ ...(entrypoint ? { entrypoint } : {}),
+ arguments: buildParams(parameters, userId),
+ workflowTemplateRef: {
+ name: template.name,
+ ...(template.clusterScope ? { clusterScope: true } : {})
+ }
+ }
+ };
+
+ await co.createNamespacedCustomObject(GROUP, VERSION, NAMESPACE, WF_PLURAL, wf);
}
-// tiny http api
+const __filename = fileURLToPath(import.meta.url),
+ __dirname = path.dirname(__filename),
+ publicDir = path.join(__dirname, 'public');
+
+// tiny json helper
+const readJson = (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);
+});
+
const server = http.createServer(async (req, res) => {
- try {
- if (req.method === 'POST' && req.url === '/schedules') {
- const input = JSON.parse(await new Promise(r => {
- let d = ''; req.on('data', c => d += c); req.on('end', () => r(d))
- }))
- await upsertCronWorkflow(input)
- res.writeHead(201).end(JSON.stringify({ ok: true }))
- return
- }
- if (req.method === 'POST' && req.url === '/run-now') {
- const input = JSON.parse(await new Promise(r => {
- let d = ''; req.on('data', c => d += c); req.on('end', () => r(d))
- }))
- await runNow(input)
- res.writeHead(201).end(JSON.stringify({ ok: true }))
- return
- }
- if (req.method === 'DELETE' && req.url?.startsWith('/schedules/')) {
- const name = decodeURIComponent(req.url.split('/').pop())
- await co.deleteNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, name)
- res.writeHead(204).end()
- return
- }
- res.writeHead(404).end('not found')
- } catch (e) {
- res.writeHead(500).end(JSON.stringify({ ok: false, error: e.message }))
- }
-})
+ try {
+ // death
+ 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 port = Number(process.env.PORT) || 3000
-server.listen(port, () => console.log(`schedules api listening on :${port}`))
+ // minimal static ui
+ if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
+ try {
+ const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf8');
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html);
+ } catch {
+ res.writeHead(404).end('ui not found');
+ }
+ return;
+ }
+ // list CronWorkflows for the calling user
+ if (req.method === 'GET' && req.url === '/api/schedules') {
+ const userId = requireUserId(req),
+ list = await co.listNamespacedCustomObject(
+ GROUP, VERSION, NAMESPACE, CRON_PLURAL,
+ undefined, undefined, undefined, `${LABEL_USER_KEY}=${userId}` // labelSelector
+ ),
+ items = (list.body.items || []).map(it => ({
+ name: it.metadata?.name,
+ displayName: it.metadata?.annotations?.[ANNO_DISPLAY_NAME] || it.metadata?.name,
+ userId: it.metadata?.labels?.[LABEL_USER_KEY],
+ timezone: it.spec?.timezone,
+ schedules: it.spec?.schedules,
+ oneShot: Boolean(it.spec?.stopStrategy),
+ templateRef: it.spec?.workflowSpec?.workflowTemplateRef,
+ entrypoint: it.spec?.workflowSpec?.entrypoint,
+ }));
+
+ return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }));
+ }
+
+ // list WorkflowTemplates for UI (shared)
+ if (req.method === 'GET' && req.url === '/api/workflowtemplates') {
+ const list = await co.listNamespacedCustomObject(GROUP, VERSION, NAMESPACE, 'workflowtemplates'),
+ items = (list.body.items || []).map(it => ({ name: it.metadata?.name }));
+
+ return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }));
+ }
+
+ // create/update a user-scoped schedule
+ if (req.method === 'POST' && req.url === '/schedules') {
+ const userId = requireUserId(req),
+ input = await readJson(req);
+
+ await upsertCronWorkflow({ ...input, userId });
+ return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }));
+ }
+
+ // run a job now for the calling user
+ if (req.method === 'POST' && req.url === '/run-now') {
+ const userId = requireUserId(req),
+ input = await readJson(req);
+
+ await runNow({ ...input, userId });
+ 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' && req.url?.startsWith('/schedules/')) {
+ const userId = requireUserId(req),
+ name = decodeURIComponent(req.url.split('/').pop());
+
+ // guard: verify ownership via label before deletion
+ const obj = await co.getNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, name),
+ owner = obj.body?.metadata?.labels?.[LABEL_USER_KEY];
+
+ if (owner !== userId) {
+ res.writeHead(403, { 'content-type': 'application/json' })
+ .end(JSON.stringify({ ok: false, error: 'forbidden: schedule not owned by this user' }));
+ return;
+ }
+
+ await co.deleteNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, name);
+ 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) }));
+ }
+});
+
+const port = Number(process.env.PORT) || 12253;
+server.listen(port, () => console.log(`schedules api listening on :${port}`));
diff --git a/scripts/setup.sh b/scripts/setup.sh
index 6efaf44..0433936 100644
--- a/scripts/setup.sh
+++ b/scripts/setup.sh
@@ -35,4 +35,6 @@ kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath='{.data.password}' | base64 -d; echo "";
echo "";
echo "port-forwarding argocd ui to https://localhost:8443 (ctrl+c to stop) ...";
-kubectl -n argocd port-forward svc/argocd-server 8443:443;
+
+kubectl -n ai port-forward svc/scheduler-ui 12253:12253
+kubectl -n argocd port-forward svc/argocd-server 8443:443