attempting to add scheduler UI

This commit is contained in:
2025-09-13 13:04:33 -04:00
parent 7975430489
commit 4e127f663b
13 changed files with 837 additions and 260 deletions
+236
View File
@@ -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>
+267
View File
@@ -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("&", "&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();
+96
View File
@@ -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;
}