Files
ollama-plus/scheduler/public/script.js
T

626 lines
18 KiB
JavaScript
Raw Normal View History

2025-09-26 14:28:04 -04:00
const storedMe = (() => {
try {
const raw = localStorage.getItem("me") ?? localStorage.getItem("user");
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
})();
2025-09-15 10:45:38 -04:00
const state = {
2025-09-26 14:28:04 -04:00
me: storedMe,
2025-09-15 10:45:38 -04:00
};
2025-09-26 14:28:04 -04:00
/**
* @returns {HTMLElement}
*/
2025-09-15 10:45:38 -04:00
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"),
2025-09-26 14:28:04 -04:00
templatesUl = $("#templatesUl"),
oneShotCheckmark = $("#oneShot"),
startAtInput = $("#startAt"),
cronInput = $("#cron"),
cronError = $("#cronError"),
filesInput = $("#scheduleFiles"),
filesList = $("#scheduleFilesList");
const cron5Regex = new RegExp(
'^' +
'(\\*|([0-5]?\\d)(\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))\\s+' +
'(\\*|([01]?\\d|2[0-3])(\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))\\s+' +
'(\\*|([1-9]|[12]\\d|3[01])(\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))\\s+' +
'(\\*|([1-9]|1[0-2])(\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))\\s+' +
'(\\*|[0-7](\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))' +
'$'
);
const validateCron = (e) => {
if (!cronInput || !cronError) return true;
const val = cronInput.value.trim();
if (cron5Regex.test(val)) {
cronError.style.display = 'none';
cronError.textContent = '';
return true;
}
e?.preventDefault();
cronError.textContent = 'please enter a valid cron expression';
cronError.style.display = 'block';
return false;
};
cronInput?.addEventListener('input', () => {
if (!cronError) return;
const val = cronInput.value.trim();
if (cron5Regex.test(val)) {
cronError.style.display = 'none';
cronError.textContent = '';
} else {
cronError.style.display = 'block';
}
});
const formatBytes = (bytes) => {
const size = Number(bytes);
if (!Number.isFinite(size) || size < 0) return '';
if (size === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const idx = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const scaled = size / Math.pow(1024, idx);
return `${scaled.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
};
const readFileAsDataUrl = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error(`failed to read file ${file?.name || ''}`));
reader.readAsDataURL(file);
});
const renderSelectedFiles = () => {
if (!filesList) return;
const files = filesInput?.files ? Array.from(filesInput.files) : [];
if (!files.length) {
filesList.innerHTML = '<li class="muted empty">no files selected</li>';
return;
}
filesList.innerHTML = '';
files.forEach((file) => {
const li = document.createElement('li');
const size = formatBytes(file.size);
li.textContent = size ? `${file.name} (${size})` : file.name;
filesList.appendChild(li);
});
};
filesInput?.addEventListener('change', renderSelectedFiles);
renderSelectedFiles();
// JSON
const paramsEl = $('#params'),
options = {
mode: 'code',
modes: ['code', 'form', 'text'], // allowed modes
onModeChange: function (newMode, oldMode) {
console.log('Mode switched from', oldMode, 'to', newMode)
}
},
jInstance = new JSONEditor(paramsEl, options)
// set json
const initialJson = {
"report_kind": "summary",
"Numbers": [1, 2, 3]
}
jInstance.set(initialJson);
window.jInstance = jInstance;
const togCron = ({ target: { checked } }) => {
const cron = cronInput?.parentElement,
start = startAtInput?.parentElement,
warn = $(".warning-box")?.parentElement;
if (!cron || !start || !warn) return;
if (checked) {
cron.style.display = 'none';
warn.style.display = 'none';
start.style.display = 'block';
if (startAtInput) startAtInput.required = true;
if (cronInput) cronInput.required = false;
} else {
cron.style.display = 'block';
warn.style.display = 'flex';
start.style.display = 'block';
if (startAtInput) startAtInput.required = false;
if (cronInput) cronInput.required = true;
}
}
oneShotCheckmark.addEventListener('change', togCron);
togCron({ target: { checked: oneShotCheckmark.checked } });
2025-09-15 10:45:38 -04:00
// update login ui from state
2025-09-26 14:28:04 -04:00
async function paintAuth() {
const meRes = await fetch('/api/me', {
headers: {
'credentials': 'include',
'Content-Type': 'application/json'
}
});
if (meRes.ok) {
const me = await meRes.json().catch(console.error);
if (me) {
localStorage.setItem("me", JSON.stringify(me));
state.me = me;
}
}
if (state.me && state.me.id) {
const who = state.me.name || state.me.email || state.me.id;
authStatusEl.textContent = `logged in as ${who}`;
document.querySelector('#userLogoutBtn').style.display = 'block';
// populate user-card
const userCard = $("#userCard"),
avatar = $("#userAvatar"),
nameEl = $("#userName"),
emailEl = $("#userEmail"),
roleEl = $("#userRole"),
permsEl = $("#userPermissions");
if (state.me.models) renderModelList(state.me.models);
if (state.me.tools) renderToolList(state.me.tools);
const featurePerms = state.me.permissions?.features;
renderFeatureList(featurePerms);
if (userCard) userCard.setAttribute("aria-hidden", "false");
if (avatar) {
avatar.src = state.me.profile_image_url || "";
avatar.alt = state.me.name || state.me.email || "user avatar";
}
if (nameEl) nameEl.textContent = state.me.name || state.me.email || state.me.id;
if (emailEl) emailEl.textContent = state.me.email || "";
if (roleEl) roleEl.textContent = state.me.role ? `role: ${state.me.role}` : "";
if (permsEl) {
permsEl.hidden = false;
renderSelectedPermissions(state.me, permsEl);
}
document.querySelector('#auth').ariaHidden = 'true';
2025-09-15 10:45:38 -04:00
} else {
authStatusEl.textContent = "not logged in";
2025-09-26 14:28:04 -04:00
document.querySelector('#userLogoutBtn').style.display = 'none';
// clear credential inputs if present
const u = $("#username"),
p = $("#password");
if (u) u.value = "";
if (p) p.value = "";
// hide user-card
const userCard = $("#userCard"),
avatar = $("#userAvatar"),
nameEl = $("#userName"),
emailEl = $("#userEmail"),
roleEl = $("#userRole"),
permsEl = $("#userPermissions");
if (userCard) userCard.setAttribute("aria-hidden", "true");
if (avatar) { avatar.src = ""; avatar.alt = ""; }
if (nameEl) nameEl.textContent = "not logged in";
if (emailEl) emailEl.textContent = "";
if (roleEl) roleEl.textContent = "";
if (permsEl) {
permsEl.hidden = true;
// clear values
permsEl.querySelectorAll(".perm-val").forEach(el => el.textContent = "");
}
renderFeatureList();
2025-09-15 10:45:38 -04:00
}
}
2025-09-26 14:28:04 -04:00
// helper to safely resolve nested permission path like "features.web_search"
function getPerm(me, path) {
if (!me || !me.permissions || !path) return false;
return path.split('.').reduce((acc, k) => (acc && typeof acc === 'object') ? acc[k] : undefined, me.permissions) || false;
}
function renderSelectedPermissions(me, container) {
// mapping of displayed items -> permission path
const items = [
{ path: 'workspace.tools', label: 'Workspace (tools)' },
{ path: 'chat.file_upload', label: 'Chat (file_upload)' },
{ path: 'features.web_search', label: 'web_search' },
{ path: 'features.code_interpreter', label: 'code_interpreter' }
],
isadmin = me.role === "admin";
// update all .perm-val placeholders by data-perm attribute when present
container.querySelectorAll('.perm-val').forEach(el => {
const key = el.getAttribute('data-perm');
if (key) {
const v = isadmin || getPerm(me, key);
el.textContent = v ? '✅' : '❌';
el.classList.toggle('perm-yes', Boolean(v));
el.classList.toggle('perm-no', !v);
el.title = `${key}: ${v ? 'allowed' : 'denied'}`;
}
});
// fallback placeholders
items.forEach(it => {
const sel = `[data-perm="${it.path}"]`;
if (!container.querySelector(sel)) {
const li = document.createElement('li');
li.innerHTML = `<span class="perm-val ${getPerm(me, it.path) ? 'perm-yes' : 'perm-no'}">${isadmin || getPerm(me, it.path) ? '✅' : '❌'}</span>`;
li.innerHTML += `<span class="perm-key">${escapeHtml(it.label)}</span>`;
container.querySelector('.perms-list')?.appendChild(li);
}
});
}
2025-09-15 10:54:35 -04:00
// theme toggle stuff!
2025-09-26 14:28:04 -04:00
const prefersLight = document.defaultView?.matchMedia?.("(prefers-color-scheme: light)").matches === true,
saved = localStorage.getItem("theme"),
initial = saved ?? (prefersLight ? "light" : "dark");
2025-09-15 10:45:38 -04:00
const applyTheme = (mode) => {
2025-09-15 10:54:35 -04:00
const b = document.body;
2025-09-26 14:28:04 -04:00
b.classList.remove("theme-dark", "theme-light");
b.classList.add(mode === "dark" ? "theme-dark" : "theme-light");
2025-09-15 10:45:38 -04:00
2025-09-26 14:28:04 -04:00
const icon = document.querySelector("#themeToggleIcon"),
label = document.querySelector("#themeToggleLabel");
2025-09-15 10:45:38 -04:00
if (icon && label) {
2025-09-26 14:28:04 -04:00
const isDark = mode === "dark";
icon.textContent = isDark ? "🌙" : "☀️";
label.textContent = isDark ? "Dark" : "Light";
2025-09-15 10:45:38 -04:00
}
2025-09-26 14:28:04 -04:00
document.querySelector("#themeToggle")?.setAttribute("aria-pressed", String(mode === "dark"));
2025-09-15 10:45:38 -04:00
};
applyTheme(initial);
2025-09-26 14:28:04 -04:00
document.querySelector("#themeToggle")?.addEventListener("click", () => {
const next = document.body.classList.contains("theme-dark") ? "light" : "dark";
2025-09-15 10:45:38 -04:00
applyTheme(next);
2025-09-26 14:28:04 -04:00
try {
localStorage.setItem("theme", next);
} catch (_) {
/* ignore */
}
2025-09-15 10:45:38 -04:00
});
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
2025-09-26 14:28:04 -04:00
if (!localStorage.getItem("theme")) applyTheme(e.matches ? "dark" : "light");
2025-09-15 10:45:38 -04:00
});
}
2025-09-26 14:28:04 -04:00
// wrap fetch to always attach x-user-id and Authorization when available
2025-09-15 10:45:38 -04:00
async function apiFetch(url, options = {}) {
const headers = new Headers(options.headers || {});
2025-09-26 14:28:04 -04:00
if (!state.me || !state.me.id) {
throw new Error("no authenticated user — use the login form first");
}
headers.set("x-user-id", state.me.id); // keep existing custom header for server compatibility
if (state.me.token) {
const typ = state.me.token_type || "Bearer";
headers.set("authorization", `${typ} ${state.me.token}`);
}
if (!headers.has("content-type") && options.body && !(options.body instanceof FormData)) {
2025-09-15 10:45:38 -04:00
headers.set("content-type", "application/json");
}
2025-09-26 14:28:04 -04:00
headers.set('credentials', 'include');
2025-09-15 10:45:38 -04:00
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) => {
2025-09-26 14:28:04 -04:00
const tr = document.createElement("tr"),
tRef = it.templateRef
? it.templateRef.clusterScope
? `(cluster) ${it.templateRef.name}`
: it.templateRef.name
: "";
2025-09-15 10:45:38 -04:00
tr.innerHTML = `
<td>${escapeHtml(it.displayName || it.name || "")}</td>
<td>${(it.schedules || []).map(escapeHtml).join("<br/>")}</td>
2025-09-26 14:28:04 -04:00
<td>${escapeHtml(it.startAt || "")}</td>
2025-09-15 10:45:38 -04:00
<td>${escapeHtml(tRef)}</td>
2025-09-26 14:28:04 -04:00
<td>${escapeHtml(it.prompt || "")}</td>
2025-09-15 10:45:38 -04:00
<td>${it.oneShot ? "yes" : "no"}</td>
<td class="actions">
<button data-del="${encodeURIComponent(it.name)}">delete</button>
</td>
`;
2025-09-26 14:28:04 -04:00
2025-09-15 10:45:38 -04:00
schedulesTbody.appendChild(tr);
});
}
// tiny escape helper
function escapeHtml(s = "") {
2025-09-26 14:28:04 -04:00
return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
2025-09-15 10:45:38 -04:00
}
// wire up events
2025-09-26 14:28:04 -04:00
$("#loginForm").addEventListener("submit", async (e) => {
2025-09-15 10:45:38 -04:00
e.preventDefault();
2025-09-26 14:28:04 -04:00
const username = $("#username").value.trim(),
password = $("#password").value;
if (!username || !password) {
authStatusEl.textContent = "please enter username and password";
2025-09-15 10:45:38 -04:00
return;
}
2025-09-26 14:28:04 -04:00
try {
authStatusEl.textContent = "authenticating...";
const res = await fetch("/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: username, password }),
});
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
try {
const d = await res.json();
if (d && d.error) msg = d.error;
} catch { }
throw new Error(msg);
}
const data = await res.json(),
me = data.me || data.user || data;
if (!me || !me.id) {
throw new Error("invalid login response (missing id)");
}
// // persist cookie if server returned one
// if (data.cookie) {
// try {
// document.cookie = `${document.cookie.replace(/owebucookie=.*;/, '')}owebucookie=${data.cookie}`;
// } catch { }
// }
// store canonical "me"
try {
localStorage.setItem("me", JSON.stringify(me));
// keep old key for compatibility
localStorage.setItem("user", JSON.stringify(me));
} catch { }
state.me = me;
paintAuth();
// trigger refresh immediately
$("#refreshBtn")?.click();
} catch (err) {
authStatusEl.textContent = `login error: ${err.message}`;
}
2025-09-15 10:45:38 -04:00
});
2025-09-26 14:28:04 -04:00
// central logout routine used by multiple buttons
function doLogout() {
try {
localStorage.removeItem("me");
localStorage.removeItem("user");
} catch { }
state.me = null;
2025-09-15 10:45:38 -04:00
paintAuth();
2025-09-26 14:28:04 -04:00
}
// existing logout button (if present) + user card logout
const logoutBtn = $("#logoutBtn"),
userLogoutBtn = $("#userLogoutBtn");
if (logoutBtn) logoutBtn.addEventListener("click", doLogout);
if (userLogoutBtn) userLogoutBtn.addEventListener("click", doLogout);
2025-09-15 10:45:38 -04:00
$("#refreshBtn").addEventListener("click", async () => {
try {
listStatusEl.textContent = "loading...";
2025-09-26 14:28:04 -04:00
const res = await apiFetch("/api/schedules"),
data = await res.json();
2025-09-15 10:45:38 -04:00
renderSchedules(data.items || []);
2025-09-26 14:28:04 -04:00
listStatusEl.textContent = `loaded ${Array.isArray(data.items) ? data.items.length : 0} schedule(s)`;
2025-09-15 10:45:38 -04:00
} 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;
2025-09-26 14:28:04 -04:00
const res = await apiFetch(`/api/schedules/${name}`, {
2025-09-15 10:45:38 -04:00
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(),
2025-09-26 14:28:04 -04:00
startAt = startAtInput?.value || "",
2025-09-15 10:45:38 -04:00
cron = $("#cron").value.trim(),
templateName = $("#templateName").value.trim(),
2025-09-26 14:28:04 -04:00
prompt = $("#prompt").value.trim(),
2025-09-15 10:45:38 -04:00
clusterScope = $("#clusterScope").checked,
oneShot = $("#oneShot").checked,
2025-09-26 14:28:04 -04:00
paramsRaw = jInstance.getText(),
model = $("#model-select").value.trim(),
tools = Array.from(document
.querySelectorAll("#tools-select [aria-selected='true']"))
.map(o => o.dataset.toolId),
featuresInput = $("#features-select-input");
if (!oneShot && !cron) {
throw new Error("cron expression is required");
}
if (!oneShot && !validateCron(e)) return;
if (oneShot && !startAt) {
throw new Error("start date is required for one-shot schedules");
}
const jErrs = await jInstance.validate();
if (jErrs?.length) {
console.error(jErrs);
throw new Error(`Please fix your JSON errors!`);
}
2025-09-15 10:45:38 -04:00
2025-09-26 14:28:04 -04:00
// leave as extra validation
2025-09-15 10:45:38 -04:00
let parameters = {};
if (paramsRaw) {
try {
parameters = JSON.parse(paramsRaw);
} catch {
throw new Error("parameters must be valid json");
}
}
2025-09-26 14:28:04 -04:00
let features = {};
if (featuresInput && featuresInput.value) {
try {
const parsed = JSON.parse(featuresInput.value);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
features = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, Boolean(v)]));
}
} catch {
throw new Error("features selection must be valid json");
}
}
const attachments = filesInput?.files ? Array.from(filesInput.files) : [];
const filesPayload = await Promise.all(attachments.map(async (file) => ({
fname: file.name,
content: await readFileAsDataUrl(file)
})));
const when = oneShot ? { start: startAt } : { cron };
if (!oneShot && startAt) when.start = startAt;
2025-09-15 10:45:38 -04:00
const payload = {
name,
2025-09-26 14:28:04 -04:00
when,
2025-09-15 10:45:38 -04:00
oneShot,
template: { name: templateName, clusterScope },
parameters,
2025-09-26 14:28:04 -04:00
prompt,
model,
tools,
features
2025-09-15 10:45:38 -04:00
};
2025-09-26 14:28:04 -04:00
if (filesPayload.length) payload.files = filesPayload;
2025-09-15 10:45:38 -04:00
2025-09-26 14:28:04 -04:00
await apiFetch("/api/schedules", {
2025-09-15 10:45:38 -04:00
method: "POST",
body: JSON.stringify(payload),
});
createStatusEl.textContent = "saved ✅";
$("#refreshBtn").click();
2025-09-26 14:28:04 -04:00
// clear the form
$("#createForm").reset();
togCron({ target: { checked: $("#oneShot").checked } });
renderSelectedFiles();
if (cronError) {
cronError.style.display = 'none';
cronError.textContent = '';
2025-09-15 10:45:38 -04:00
}
} catch (err) {
2025-09-26 14:28:04 -04:00
console.error(err);
createStatusEl.textContent = `error: ${err.message}`;
2025-09-15 10:45:38 -04:00
}
});
2025-09-26 14:28:04 -04:00
2025-09-15 10:45:38 -04:00
// 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) {
2025-09-26 14:28:04 -04:00
templatesUl.innerHTML = `<li class="danger">error: ${escapeHtml(e.message)}</li>`;
2025-09-15 10:45:38 -04:00
}
});
2025-09-26 14:28:04 -04:00
// window.onbeforeunload = () => CookieStore.delete('token');
const reffunc = () => {
// auto-refresh if already logged in
if (state.me && state.me.id) $("#refreshBtn").click();
}
2025-09-15 10:45:38 -04:00
2025-09-26 14:28:04 -04:00
// boot
paintAuth().then(reffunc);