modifying scheduler
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
npm-cache
|
||||
bun.lock
|
||||
bun.lockb
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Schedules • Task & Workflow Manager</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- app shell header -->
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<div class="logo" aria-hidden="true">⏱️</div>
|
||||
<div class="titles">
|
||||
<h1>Schedules</h1>
|
||||
<p class="subtitle">manage your tasks and follow‑ups</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- theme toggle kept with same ids so your code still works -->
|
||||
<button id="themeToggle" class="theme-toggle card compact" aria-pressed="false" title="toggle dark / light">
|
||||
<span id="themeToggleIcon" aria-hidden="true">🌙</span>
|
||||
<span id="themeToggleLabel">Dark</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- content grid -->
|
||||
<main class="content-grid">
|
||||
<!-- left column: auth + run now -->
|
||||
<aside class="stack col-left">
|
||||
<!-- login card (ids preserved) -->
|
||||
<section class="card" id="auth">
|
||||
<header class="card-header">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<form id="loginForm" class="form-stack">
|
||||
<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">
|
||||
<button type="submit">save & set header</button>
|
||||
<button type="button" id="logoutBtn" aria-variant="ghost">logout</button>
|
||||
</div>
|
||||
<p id="authStatus" class="muted" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- run now -->
|
||||
<section class="card">
|
||||
<header class="card-header">
|
||||
<h2>run now</h2>
|
||||
<p class="muted">trigger a workflow ad‑hoc with parameters</p>
|
||||
</header>
|
||||
|
||||
<form id="runNowForm" class="form-stack">
|
||||
<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 class="checkbox">
|
||||
<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">
|
||||
<button type="submit">run now</button>
|
||||
</div>
|
||||
<p id="runNowStatus" class="muted" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<!-- right column: schedules + create/update -->
|
||||
<section class="stack col-right">
|
||||
<!-- schedules list -->
|
||||
<section class="card">
|
||||
<header class="card-header actions between">
|
||||
<h2>your schedules</h2>
|
||||
<div class="actions">
|
||||
<button id="refreshBtn">refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
<p id="listStatus" class="muted" aria-live="polite"></p>
|
||||
|
||||
<div class="table-wrap" role="region" aria-label="schedules table" tabindex="0">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">name</th>
|
||||
<th scope="col">schedules</th>
|
||||
<th scope="col">tz</th>
|
||||
<th scope="col">template</th>
|
||||
<th scope="col">entrypoint</th>
|
||||
<th scope="col">one-shot</th>
|
||||
<th scope="col">actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedulesTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- create/update schedule -->
|
||||
<section class="card">
|
||||
<header class="card-header">
|
||||
<h2>create / update schedule</h2>
|
||||
</header>
|
||||
|
||||
<form id="createForm" class="form-stack">
|
||||
<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 *)</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 class="checkbox">
|
||||
<label>
|
||||
<input id="clusterScope" type="checkbox" /> template is cluster-scoped
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<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 between wrap">
|
||||
<div class="actions">
|
||||
<button type="submit">upsert schedule</button>
|
||||
<button type="button" id="loadTemplatesBtn" aria-variant="ghost">load workflow
|
||||
templates</button>
|
||||
</div>
|
||||
<span id="createStatus" class="muted" aria-live="polite"></span>
|
||||
</div>
|
||||
|
||||
<details class="templates">
|
||||
<summary class="muted">available workflow templates</summary>
|
||||
<ul id="templatesUl" class="muted"></ul>
|
||||
</details>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- scripts -->
|
||||
<script type="module" src="script.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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 = `
|
||||
<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,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);
|
||||
}
|
||||
}
|
||||
@@ -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}`));
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user