626 lines
18 KiB
JavaScript
626 lines
18 KiB
JavaScript
const storedMe = (() => {
|
|
try {
|
|
const raw = localStorage.getItem("me") ?? localStorage.getItem("user");
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
const state = {
|
|
me: storedMe,
|
|
};
|
|
|
|
/**
|
|
* @returns {HTMLElement}
|
|
*/
|
|
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"),
|
|
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 } });
|
|
|
|
// update login ui from state
|
|
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';
|
|
} else {
|
|
authStatusEl.textContent = "not logged in";
|
|
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();
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
}
|
|
|
|
// theme toggle stuff!
|
|
const prefersLight = document.defaultView?.matchMedia?.("(prefers-color-scheme: light)").matches === true,
|
|
saved = localStorage.getItem("theme"),
|
|
initial = saved ?? (prefersLight ? "light" : "dark");
|
|
|
|
const applyTheme = (mode) => {
|
|
const b = document.body;
|
|
b.classList.remove("theme-dark", "theme-light");
|
|
b.classList.add(mode === "dark" ? "theme-dark" : "theme-light");
|
|
|
|
const icon = document.querySelector("#themeToggleIcon"),
|
|
label = document.querySelector("#themeToggleLabel");
|
|
if (icon && label) {
|
|
const isDark = mode === "dark";
|
|
icon.textContent = isDark ? "🌙" : "☀️";
|
|
label.textContent = isDark ? "Dark" : "Light";
|
|
}
|
|
|
|
document.querySelector("#themeToggle")?.setAttribute("aria-pressed", String(mode === "dark"));
|
|
};
|
|
|
|
applyTheme(initial);
|
|
|
|
document.querySelector("#themeToggle")?.addEventListener("click", () => {
|
|
const next = document.body.classList.contains("theme-dark") ? "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 and Authorization when available
|
|
async function apiFetch(url, options = {}) {
|
|
const headers = new Headers(options.headers || {});
|
|
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)) {
|
|
headers.set("content-type", "application/json");
|
|
}
|
|
|
|
headers.set('credentials', 'include');
|
|
|
|
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"),
|
|
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.startAt || "")}</td>
|
|
<td>${escapeHtml(tRef)}</td>
|
|
<td>${escapeHtml(it.prompt || "")}</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", async (e) => {
|
|
e.preventDefault();
|
|
const username = $("#username").value.trim(),
|
|
password = $("#password").value;
|
|
|
|
if (!username || !password) {
|
|
authStatusEl.textContent = "please enter username and password";
|
|
return;
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
});
|
|
|
|
// central logout routine used by multiple buttons
|
|
function doLogout() {
|
|
try {
|
|
localStorage.removeItem("me");
|
|
localStorage.removeItem("user");
|
|
} catch { }
|
|
state.me = null;
|
|
paintAuth();
|
|
}
|
|
|
|
// 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);
|
|
|
|
$("#refreshBtn").addEventListener("click", async () => {
|
|
try {
|
|
listStatusEl.textContent = "loading...";
|
|
|
|
const res = await apiFetch("/api/schedules"),
|
|
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(`/api/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(),
|
|
startAt = startAtInput?.value || "",
|
|
cron = $("#cron").value.trim(),
|
|
templateName = $("#templateName").value.trim(),
|
|
prompt = $("#prompt").value.trim(),
|
|
clusterScope = $("#clusterScope").checked,
|
|
oneShot = $("#oneShot").checked,
|
|
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!`);
|
|
}
|
|
|
|
// leave as extra validation
|
|
let parameters = {};
|
|
if (paramsRaw) {
|
|
try {
|
|
parameters = JSON.parse(paramsRaw);
|
|
} catch {
|
|
throw new Error("parameters must be valid json");
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
const payload = {
|
|
name,
|
|
when,
|
|
oneShot,
|
|
template: { name: templateName, clusterScope },
|
|
parameters,
|
|
prompt,
|
|
model,
|
|
tools,
|
|
features
|
|
};
|
|
if (filesPayload.length) payload.files = filesPayload;
|
|
|
|
await apiFetch("/api/schedules", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
createStatusEl.textContent = "saved ✅";
|
|
$("#refreshBtn").click();
|
|
|
|
// clear the form
|
|
$("#createForm").reset();
|
|
togCron({ target: { checked: $("#oneShot").checked } });
|
|
renderSelectedFiles();
|
|
if (cronError) {
|
|
cronError.style.display = 'none';
|
|
cronError.textContent = '';
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
createStatusEl.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>`;
|
|
}
|
|
});
|
|
|
|
// window.onbeforeunload = () => CookieStore.delete('token');
|
|
|
|
const reffunc = () => {
|
|
// auto-refresh if already logged in
|
|
if (state.me && state.me.id) $("#refreshBtn").click();
|
|
}
|
|
|
|
// boot
|
|
paintAuth().then(reffunc);
|