attempting to add scheduler UI
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user