attempting to add scheduler UI
This commit is contained in:
@@ -135,3 +135,4 @@ __pycache__/
|
||||
|
||||
*.xml
|
||||
temp.*
|
||||
bun.lock
|
||||
|
||||
@@ -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/)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
node_modules
|
||||
npm-cache
|
||||
bun.lock
|
||||
bun.lockb
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Schedules UI</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>Manage Your Tasks and Follow-Ups!</h1>
|
||||
|
||||
<!-- login card -->
|
||||
<section class="card" id="auth">
|
||||
<h2>login</h2>
|
||||
<p class="muted">
|
||||
enter your open webui user id (uuid). this is sent as the
|
||||
<code class="inline">x-user-id</code> header on api requests.
|
||||
</p>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="userId">user id (uuid)</label>
|
||||
<input
|
||||
id="userId"
|
||||
name="userId"
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. 5a8d1d7e-..." />
|
||||
</div>
|
||||
<div>
|
||||
<label for="displayName">display name (optional)</label>
|
||||
<input
|
||||
id="displayName"
|
||||
name="displayName"
|
||||
type="text"
|
||||
placeholder="your name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" style="margin-top: 0.75rem">
|
||||
<button type="submit">save & set header</button>
|
||||
<button type="button" id="logoutBtn">logout</button>
|
||||
</div>
|
||||
<p id="authStatus" class="muted"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- schedules list -->
|
||||
<section class="card">
|
||||
<div
|
||||
class="actions"
|
||||
style="justify-content: space-between; align-items: center">
|
||||
<h2 style="margin: 0">your schedules</h2>
|
||||
<div class="actions">
|
||||
<button id="refreshBtn">refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="listStatus"
|
||||
class="muted"
|
||||
style="margin: 0.4rem 0 0.6rem"></div>
|
||||
<div style="overflow: auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
<th>schedules</th>
|
||||
<th>tz</th>
|
||||
<th>template</th>
|
||||
<th>entrypoint</th>
|
||||
<th>one-shot</th>
|
||||
<th>actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedulesTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- create/update schedule -->
|
||||
<section class="card">
|
||||
<h2>create / update schedule</h2>
|
||||
<form id="createForm">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="name">name</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="daily-report" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tz">timezone</label>
|
||||
<input
|
||||
id="tz"
|
||||
name="tz"
|
||||
type="text"
|
||||
value="America/New_York" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="iso"
|
||||
>run at (iso datetime, or leave empty if using
|
||||
cron)</label
|
||||
>
|
||||
<input id="iso" name="iso" type="datetime-local" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="cron"
|
||||
>cron (min hour day month *), if not using
|
||||
iso</label
|
||||
>
|
||||
<input
|
||||
id="cron"
|
||||
name="cron"
|
||||
type="text"
|
||||
placeholder="30 9 * * *" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="templateName">workflow template</label>
|
||||
<input
|
||||
id="templateName"
|
||||
name="templateName"
|
||||
type="text"
|
||||
required
|
||||
placeholder="report-template" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="entrypoint">entrypoint (optional)</label>
|
||||
<input
|
||||
id="entrypoint"
|
||||
name="entrypoint"
|
||||
type="text"
|
||||
placeholder="main" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label
|
||||
><input id="clusterScope" type="checkbox" />
|
||||
template is cluster-scoped</label
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
><input id="oneShot" type="checkbox" /> stop after
|
||||
first success (one-shot)</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="params">parameters (json object)</label>
|
||||
<textarea
|
||||
id="params"
|
||||
name="params"
|
||||
placeholder='{"report_kind":"summary"}'></textarea>
|
||||
|
||||
<div class="actions" style="margin-top: 0.75rem">
|
||||
<button type="submit">upsert schedule</button>
|
||||
<button type="button" id="loadTemplatesBtn">
|
||||
load workflow templates
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="createStatus"
|
||||
class="muted"
|
||||
style="margin-top: 0.5rem"></div>
|
||||
</form>
|
||||
|
||||
<details style="margin-top: 0.75rem">
|
||||
<summary class="muted">available workflow templates</summary>
|
||||
<ul id="templatesUl" class="muted"></ul>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- run now -->
|
||||
<section class="card">
|
||||
<h2>run now</h2>
|
||||
<form id="runNowForm">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="rnName">name (label only)</label>
|
||||
<input
|
||||
id="rnName"
|
||||
type="text"
|
||||
placeholder="ad-hoc-run" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="rnTemplateName">workflow template</label>
|
||||
<input
|
||||
id="rnTemplateName"
|
||||
type="text"
|
||||
placeholder="report-template"
|
||||
required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="rnEntrypoint">entrypoint (optional)</label>
|
||||
<input
|
||||
id="rnEntrypoint"
|
||||
type="text"
|
||||
placeholder="main" />
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
><input id="rnClusterScope" type="checkbox" />
|
||||
template is cluster-scoped</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<label for="rnParams">parameters (json object)</label>
|
||||
<textarea
|
||||
id="rnParams"
|
||||
placeholder='{"report_kind":"summary"}'></textarea>
|
||||
|
||||
<div class="actions" style="margin-top: 0.75rem">
|
||||
<button type="submit">run now</button>
|
||||
</div>
|
||||
<div
|
||||
id="runNowStatus"
|
||||
class="muted"
|
||||
style="margin-top: 0.5rem"></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script type="module" src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = `
|
||||
<td>${escapeHtml(it.displayName || it.name || "")}</td>
|
||||
<td>${(it.schedules || []).map(escapeHtml).join("<br/>")}</td>
|
||||
<td>${escapeHtml(it.timezone || "")}</td>
|
||||
<td>${escapeHtml(tRef)}</td>
|
||||
<td>${escapeHtml(it.entrypoint || "")}</td>
|
||||
<td>${it.oneShot ? "yes" : "no"}</td>
|
||||
<td class="actions">
|
||||
<button data-del="${encodeURIComponent(it.name)}">delete</button>
|
||||
</td>
|
||||
`;
|
||||
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 = `<li class="danger">error: ${escapeHtml(
|
||||
e.message
|
||||
)}</li>`;
|
||||
}
|
||||
});
|
||||
|
||||
// boot
|
||||
paintAuth();
|
||||
|
||||
// auto-refresh if already logged in
|
||||
if (state.userId) $("#refreshBtn").click();
|
||||
@@ -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;
|
||||
}
|
||||
+213
-103
@@ -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}`));
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user