309 lines
8.2 KiB
JavaScript
309 lines
8.2 KiB
JavaScript
|
|
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();
|