modifying scheduler

This commit is contained in:
ION606
2025-09-15 10:45:38 -04:00
parent 9153c3b1c6
commit a7f6c9edb5
9 changed files with 1264 additions and 0 deletions
+212
View File
@@ -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 followups</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 adhoc 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>
+309
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
// 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();
+360
View File
@@ -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);
}
}