From 133ef3f48bf437d9a00808fc18a6da2ceed8711a Mon Sep 17 00:00:00 2001 From: ION606 Date: Fri, 26 Sep 2025 14:28:04 -0400 Subject: [PATCH] added scheduler --- .gitignore | 4 + docker-compose.yml | 4 + scheduler/.dockerignore | 1 + scheduler/Dockerfile | 11 +- scheduler/helpers/ollamaCalls.ts | 324 +++++++++++++++ scheduler/helpers/resolve-user.ts | 97 +++++ scheduler/package.json | 3 + scheduler/public/featureList.css | 212 ++++++++++ scheduler/public/featureList.js | 258 ++++++++++++ scheduler/public/index.html | 280 ++++++++----- scheduler/public/modelList.css | 340 ++++++++++++++++ scheduler/public/modelList.js | 441 +++++++++++++++++++++ scheduler/public/script.js | 568 ++++++++++++++++++++------ scheduler/public/style.css | 253 +++++++++++- scheduler/public/toolList.css | 382 ++++++++++++++++++ scheduler/public/toolList.js | 389 ++++++++++++++++++ scheduler/server.mjs | 305 -------------- scheduler/server.ts | 636 ++++++++++++++++++++++++++++++ tmp/pods.txt | 22 -- 19 files changed, 3975 insertions(+), 555 deletions(-) create mode 100644 scheduler/helpers/ollamaCalls.ts create mode 100644 scheduler/helpers/resolve-user.ts create mode 100644 scheduler/public/featureList.css create mode 100644 scheduler/public/featureList.js create mode 100644 scheduler/public/modelList.css create mode 100644 scheduler/public/modelList.js create mode 100644 scheduler/public/toolList.css create mode 100644 scheduler/public/toolList.js delete mode 100644 scheduler/server.mjs create mode 100644 scheduler/server.ts delete mode 100644 tmp/pods.txt diff --git a/.gitignore b/.gitignore index c935f0e..29b72ae 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,7 @@ dist __pycache__/ .venv/ + +bun.lock +tmp/ +temp.* diff --git a/docker-compose.yml b/docker-compose.yml index 56b8615..208e63f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,10 +131,14 @@ services: - TEMPLATES_FILE=/app/templates.json - DOCKER_SOCKET=/var/run/docker.sock - TZ=America/New_York + networks: + - internal volumes: - schedule_data:/app/data - ./templates.json:/app/templates.json:ro,Z - /var/run/docker.sock:/var/run/docker.sock + + - ./tmp:/tmp volumes: open-webui: diff --git a/scheduler/.dockerignore b/scheduler/.dockerignore index 836bd61..af9de5a 100644 --- a/scheduler/.dockerignore +++ b/scheduler/.dockerignore @@ -4,3 +4,4 @@ bun.lock bun.lockb .DS_Store *.log +templates.json diff --git a/scheduler/Dockerfile b/scheduler/Dockerfile index b4413c2..6972533 100644 --- a/scheduler/Dockerfile +++ b/scheduler/Dockerfile @@ -3,12 +3,17 @@ WORKDIR /app # prod deps COPY package.json ./package.json + RUN bun install --ci --production -COPY server.mjs ./server.mjs -COPY public ./public +COPY . . + +RUN mkdir -p /app/data && chown -R bun:bun /app/data USER bun + EXPOSE 12253 + ENV NODE_ENV=production -CMD ["bun", "run", "server.mjs"] + +CMD ["bun", "run", "server.ts"] diff --git a/scheduler/helpers/ollamaCalls.ts b/scheduler/helpers/ollamaCalls.ts new file mode 100644 index 0000000..0baf58c --- /dev/null +++ b/scheduler/helpers/ollamaCalls.ts @@ -0,0 +1,324 @@ +import crypto from 'node:crypto'; +import { base } from './resolve-user'; +import fs from 'fs'; + +export type schedInp = { + name: string + when: { + cron?: string + start?: string + } + oneShot?: boolean + template?: { name: string } + parameters: object + prompt: string + userId: string + model: string, + tools: string[] + features?: Record + chatId?: string + files?: { fname: string, fkey?: string, content?: string }[] +} + +export type ollamaInp = { + name: string + displayName: string + userId: string + schedule: string + startAt?: string + oneShot: boolean + template: any //idk + parameters: object + prompt: string + model: string + tools: string[] + features: Record + cookie: string + chatId?: string + files?: { fname: string, fkey: string }[] +} + +async function makeRequest(cookie: string, path: string, throwOnErr = false) { + if (!cookie) return []; + + const r = await fetch(`${base}/${path}`, { + method: "GET", + headers: { + 'Authorization': `Bearer ${cookie}`, + "content-type": "application/json", + "accept": "application/json", + } + }); + + if (throwOnErr && !r.ok) throw r; + + return await r.text().then(data => { + try { return JSON.parse(data); } + catch (_) { return data; } + }).catch(async err => { + console.error(err); + return []; + }); +} + +// NO FOREWARD SLASH NO IDK WHY IG /v1/ DOES IT FUCK ME MAN +export const getModels = (cookie: string) => makeRequest(cookie, 'api/models').then((r) => (r.data || [])); +export const getTools = (cookie: string) => makeRequest(cookie, 'api/v1/tools/'); +export const authCall = (cookie: string) => makeRequest(cookie, 'api/v1/auths/', true); + +const buildPrompt = async (def: ollamaInp) => { + let content = `Please complete this request given the following information:\n`; + content += `request: ${def.prompt}\nExtra Context:\n\`\`\`json\n${JSON.stringify(def.parameters || {})}\n\`\`\``; + + if (def.files) { + const files = await Promise.all(def.files.map(async f => { + const fpath = `/app/data/files/${f.fkey}`, + r = await fetch(`https://${base}/api/v1/files`, { + headers: { + 'Content-Type': 'multipart/form-data', + 'filename': f.fname + }, + method: 'POST', + body: await fs.readFileSync(fpath) + }).catch(err => { + console.error(err); + return { data: err, ok: false }; + }); + + fs.rm(fpath, { recursive: false }, (err: any) => { + if (err) console.error(err); + }); + + return { + ok: r.ok, + data: r.ok ? await (r as Response).json() : (r as any).data + }; + })); + + if (files.find(v => !v.ok)) { + throw new Error(`File upload failed for file arr: ${JSON.stringify(files)}`) + } + + return { + content, files: files.map(({ data: f }) => ({ + id: f.id, + type: 'file', + url: `/api/v1/files/${f.id}`, + file: f, + name: f.filename, + status: 'uploaded', // trust, trust + size: f.meta.size, + error: '', + itemId: crypto.randomUUID() + })) + }; + } + + return { content }; +}; + +const getSystemSettings = async (cookie: string) => { + const r = await makeRequest(cookie, 'api/v1/users/user/settings/'), + { notifications, system } = r?.length || {}; + return { notifications, system }; +}; + +const showErr = async (r: any) => { + console.error('========================================='); + console.error(r); + console.error(await r.text()); + console.error('========================================='); +} + +// duplicate? Maybe? +const toFeatureFlags = (f: Record = {}) => ({ + image_generation: !!f.image_generation, + code_interpreter: !!f.code_interpreter, + web_search: !!f.web_search, + memory: !!f.memory +}); + +const splitThink = (raw: string, startPos: number = 0) => { + const open = raw.indexOf('', startPos), + close = raw.indexOf('', startPos); + + if (close >= 0) { + const start = open >= 0 ? open + ''.length : 0; + return { + thinking: raw.slice(start, close).trim(), + answer: raw.slice(close + ''.length).trim() + }; + } + return { thinking: '', answer: raw.trim(), endPos: close }; +}; + +async function newChat(def: ollamaInp, sysMsg?: string) { + const userId = crypto.randomUUID(), + assistantId = crypto.randomUUID(), // will be reused in completions + now = Date.now(); + + const userMsg = { + id: userId, + parentId: null, + childrenIds: [assistantId], + role: 'user', + ...(await buildPrompt(def)), + timestamp: now, + models: [def.model] + }; + + const assistantPlaceholder = { + id: assistantId, + parentId: userId, + childrenIds: [], + role: 'assistant', + content: '', + model: def.model, + modelName: def.model, + modelIdx: 0, + timestamp: now + }; + + const reqObj = { + user_id: def.userId, + title: 'New Chat', + chat: { + id: '', + title: 'New Chat', + models: [def.model], + system: sysMsg || '', + params: {}, // model params??? + history: { + messages: { + [userMsg.id]: userMsg, + [assistantPlaceholder.id]: assistantPlaceholder + }, + currentId: assistantId // needed for openwebUI stuff + }, + messages: [userMsg, assistantPlaceholder], + tags: [] + }, + share_id: null, + archived: false, + pinned: false, + meta: {}, + folder_id: null + }; + + const r = await fetch(`${base}/api/v1/chats/new`, { + method: 'POST', + headers: { + Authorization: `Bearer ${def.cookie}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(reqObj) + }); + + if (!r.ok) { + await showErr(r); + throw new Error('Failed to create new chat!'); + } + + const created = await r.json(); + return { chatId: created.id as string, assistantId }; +} + +export async function callNewChat(def: ollamaInp) { + const { cookie } = def; + if (!cookie) throw new Error('Cookie not found!'); + + const sysSettings = await getSystemSettings(cookie), + { name: username } = await makeRequest(cookie, 'api/v1/auths/'), + models = await getModels(cookie), + model = models.find((m: any) => m.id === def.model); + + if (!model) throw new Error(`Model ${def.model} not found!`); + + const { chatId, assistantId } = def.chatId + ? { chatId: def.chatId, assistantId: crypto.randomUUID() } // TODO: if passing an existing chat, insert a placeholder there first (same shape as above) + : await newChat(def, sysSettings.system); + + // mock socket ID + const sessionId = crypto.randomUUID(), + completeReq = { + chat_id: chatId, + id: assistantId, + stream: false, + model: def.model, + messages: [ + { role: 'system', content: sysSettings.system || '' }, + { role: 'user', ...(await buildPrompt(def)) } + ], + features: toFeatureFlags(def.features), + variables: { + '{{USER_NAME}}': username, + '{{USER_LANGUAGE}}': 'en-US' + }, + session_id: sessionId, + background_tasks: { title_generation: true, follow_up_generation: true } + }; + + // run completion + let r = await fetch(`${base}/api/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${cookie}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(completeReq) + }); + if (!r.ok) return showErr(r); + + // bc stream:false, read the whole json here bc ui still wants a finalizer call + const txt = await r.text(); + let content = txt; + + try { const j = JSON.parse(txt); content = j?.choices?.[0]?.message?.content ?? txt; } catch { } + const thinkRes = splitThink(content), + answerArr = [(thinkRes.thinking ? `
\n` : '') + thinkRes.answer]; + + let counter = thinkRes.thinking ? 1 : 0, + { thinking, endPos } = thinkRes; + + while (thinking && counter < 10) { + const { thinking: newThink, answer: newAnswer, endPos: newEndPos } = splitThink(content, endPos); + + thinking = newThink; + + if (thinking) { + answerArr.push(`
\n` + newAnswer); + endPos = newEndPos; + } + else break; + + counter++; + } + + const answer = answerArr.join('\n') + + // fetch current chat, replace the assistant placeholder content with `answer` + const chat = await (await fetch(`${base}/api/v1/chats/${chatId}`, { + headers: { Authorization: `Bearer ${def.cookie}` } + })).json(); + + chat.chat.history.messages[assistantId].content = answer; + const idx = chat.chat.messages.findIndex((m: any) => m.id === assistantId); + if (idx >= 0) chat.chat.messages[idx].content = answer; + + console.log(JSON.stringify(chat)); + + r = await fetch(`${base}/api/v1/chats/${chatId}`, { + method: 'POST', + headers: { Authorization: `Bearer ${def.cookie}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat: chat.chat }) + }); + + // now finalize + r = await fetch(`${base}/api/chat/completed`, { + method: 'POST', + headers: { Authorization: `Bearer ${def.cookie}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: chatId, id: assistantId, session_id: sessionId, model: def.model }) + }); + + console.log('completed:', await r.text()); +} diff --git a/scheduler/helpers/resolve-user.ts b/scheduler/helpers/resolve-user.ts new file mode 100644 index 0000000..e19f734 --- /dev/null +++ b/scheduler/helpers/resolve-user.ts @@ -0,0 +1,97 @@ +import http from 'http' +import { readBodyJson } from '../server'; +import { getModels, getTools } from './ollamaCalls'; + +export const base = 'http://open-webui:8080'; +type Me = { + id: string; + email: string; + name: string; + role: string; + profile_image_url: string; + token: string; + token_type: string; + expires_at: string | null; + permissions: { + workspace: { + models: boolean; + knowledge: boolean; + prompts: boolean; + tools: boolean; + }; + + features: { + direct_tool_servers: boolean; + web_search: boolean; + image_generation: boolean; + code_interpreter: boolean; + notes: boolean; + }; + }; +}; + +export default async function loginUser(req: http.IncomingMessage, res: http.ServerResponse) { + const { email, password } = await readBodyJson(req); + + if (!email || !password) { + res.writeHead(400).end(JSON.stringify({ error: "email or password not sent" })); + return; + } + + // login with username/password + const loginRes = await fetch(`${base}/api/v1/auths/signin`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (!loginRes.ok) { + console.error("error logging in", await loginRes.text()); + res.writeHead(401).end(); + return + } + + const upstreamCookie = loginRes.headers.get("set-cookie"), + user = await loginRes.json(); + + // forward Set-Cookie to the browser so it stores the cookie + const outHeaders: Record = { + "content-type": "application/json", + }; + + if (upstreamCookie) { + outHeaders["Set-Cookie"] = upstreamCookie; + } + + res.writeHead(200, outHeaders).end(JSON.stringify({ user })); +} + +export async function getUser(req: http.IncomingMessage, res: http.ServerResponse) { + if (!req.headers.cookie) { + return res.writeHead(401).end("Not logged in"); + } + + const cookies = Object.fromEntries(req.headers.cookie.split(';').map(c => c.split('=').map(o => o.trim()))); + if (!('token' in cookies)) { + return res.writeHead(401).end("Not logged in"); + } + + const uRes = await fetch(`${base}/api/v1/auths/`, { + method: "GET", + headers: { + "content-type": "application/json", + 'Authorization': `Bearer ${cookies['token']}` + }, + }); + + if (!uRes.ok) { + console.error("Error getting user", await uRes.text()); + return res.writeHead(401).end(); + } + + const uObj = await uRes.json(); + uObj.models = await getModels(cookies['token']); + uObj.tools = await getTools(cookies['token']); + + res.writeHead(200).end(JSON.stringify(uObj)); +} diff --git a/scheduler/package.json b/scheduler/package.json index 9bce977..b0bcdd3 100644 --- a/scheduler/package.json +++ b/scheduler/package.json @@ -8,8 +8,11 @@ "dev": "bun run --hot server.mjs" }, "dependencies": { + "@js-temporal/polyfill": "^0.5.1", "@kubernetes/client-node": "^0.22.1", + "@types/cookie-parser": "^1.4.9", "@types/node": "^24.3.3", + "cookie-parser": "^1.4.7", "dockerode": "^4.0.8", "node-cron": "^4.2.1" } diff --git a/scheduler/public/featureList.css b/scheduler/public/featureList.css new file mode 100644 index 0000000..838bcd2 --- /dev/null +++ b/scheduler/public/featureList.css @@ -0,0 +1,212 @@ +:root { + --fc-surface: #0f2219; + --fc-surface-2: #103220; + --fc-text: #e8f9f0; + --fc-muted: #7dc9a5; + --fc-accent: #2dd282; + --fc-accent-soft: rgba(45, 210, 130, 0.18); + --fc-border: rgba(125, 201, 165, 0.45); + --fc-border-strong: rgba(45, 210, 130, 0.65); + --fc-overlay: rgba(4, 18, 12, 0.72); +} + +.feature-section { + margin-top: 12px; + display: grid; + gap: 6px; +} + +.feature-section-header { + display: flex; + flex-direction: column; + gap: 2px; +} + +.feature-section-header h3 { + margin: 0; + font-size: 1rem; +} + +.feature-pill-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 6px 0; + min-height: 40px; +} + +.feature-pill { + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 999px; + border: 1px solid var(--fc-border); + background: var(--fc-accent-soft); + color: inherit; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.07); + transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} + +.feature-pill--selected { + background: color-mix(in srgb, var(--fc-accent), transparent 68%); + border-color: var(--fc-border-strong); + box-shadow: 0 2px 6px rgba(21, 94, 50, 0.22); +} + +.feature-pill-toggle { + appearance: none; + border: none; + background: transparent; + color: inherit; + font: inherit; + padding: 6px 12px; + border-radius: 999px; + cursor: pointer; + flex: 1 1 auto; + text-align: left; +} + +.feature-pill-toggle:focus-visible, +.feature-pill-info:focus-visible { + outline: 2px solid color-mix(in srgb, var(--fc-accent), white 25%); + outline-offset: 2px; +} + +.feature-pill-info { + appearance: none; + border: none; + background: transparent; + color: var(--fc-muted); + width: 28px; + height: 28px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + margin-right: 4px; +} + +.feature-pill-info:hover { + color: var(--fc-text); + background: rgba(45, 210, 130, 0.16); +} + +.feature-pill-info span { + display: inline-block; + font-size: 0.9rem; + font-weight: 700; +} + +/* Modal styles */ +.fc-overlay { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: clamp(12px, 2vw, 24px); + background: var(--fc-overlay); + z-index: 9999; +} + +.fc-dialog { + width: min(520px, 100%); + max-height: 90dvh; + overflow: auto; + border-radius: 12px; + background: linear-gradient(180deg, var(--fc-surface), var(--fc-surface-2)); + color: var(--fc-text); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45); + border: 1px solid rgba(125, 201, 165, 0.35); +} + +.fc-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid rgba(125, 201, 165, 0.25); + position: sticky; + top: 0; + backdrop-filter: blur(6px); + background: + linear-gradient(to bottom, color-mix(in srgb, var(--fc-surface), transparent 30%), color-mix(in srgb, var(--fc-surface-2), transparent 35%)), + conic-gradient(from 0.35turn at 12% -30%, color-mix(in srgb, var(--fc-accent), transparent 80%), transparent 40%); + z-index: 1; +} + +.fc-titlewrap { + display: flex; + align-items: center; + gap: 10px; +} + +.fc-title { + margin: 0; + font-size: 1.1rem; + font-weight: 700; +} + +.fc-tag { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #06361d; + background: color-mix(in srgb, var(--fc-accent), white 38%); + border: 1px solid color-mix(in srgb, var(--fc-accent), black 10%); +} + +.fc-close { + appearance: none; + border: 1px solid transparent; + background: transparent; + color: var(--fc-muted); + font-size: 1.6rem; + line-height: 1; + border-radius: 8px; + padding: 4px 8px; + cursor: pointer; +} + +.fc-close:hover { + color: var(--fc-text); + border-color: rgba(125, 201, 165, 0.4); + background: rgba(45, 210, 130, 0.16); +} + +.fc-body { + padding: 20px; + display: grid; + gap: 18px; +} + +.fc-desc { + margin: 0; + color: var(--fc-text); + font-size: 0.95rem; + line-height: 1.4; +} + +.fc-details { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0; +} + +.fc-details dt { + font-weight: 600; + color: var(--fc-muted); +} + +.fc-details dd { + margin: 0; + font-weight: 500; +} diff --git a/scheduler/public/featureList.js b/scheduler/public/featureList.js new file mode 100644 index 0000000..22015a2 --- /dev/null +++ b/scheduler/public/featureList.js @@ -0,0 +1,258 @@ +const FEATURE_DEFAULTS = { + image_generation: false, + code_interpreter: false, + web_search: false, + memory: true, +}; + +const FEATURE_METADATA = { + image_generation: { + label: 'Image Generation', + description: 'Request image outputs from supported models. When enabled the assistant may produce images alongside text responses.', + }, + code_interpreter: { + label: 'Code Interpreter', + description: 'Grant access to the sandboxed runtime for running Python snippets and data transformations during the task.', + }, + web_search: { + label: 'Web Search', + description: 'Allow the assistant to perform outbound web searches to enrich the response with current information.', + }, + memory: { + label: 'Memory', + description: 'Persist relevant conversation context for future automations so runs can recall prior outcomes.', + }, +}; + +const FEATURE_SECTION_TAG = 'Feature'; + +function renderFeatureList(featureState) { + const container = document.querySelector('#features-select'); + const hiddenInput = document.querySelector('#features-select-input'); + if (!container || !hiddenInput) return; + + const baseState = normalizeFeatureState(featureState), + featureKeys = Object.keys(baseState); + + container.innerHTML = ''; + container.setAttribute('data-has-features', String(featureKeys.length > 0)); + + const selection = { ...baseState }, + syncHidden = () => { + hiddenInput.value = JSON.stringify(selection); + hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); + hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); + }; + + featureKeys.forEach((id) => { + const meta = getFeatureMeta(id); + const pill = document.createElement('div'); + pill.className = 'feature-pill'; + pill.dataset.featureId = id; + pill.setAttribute('role', 'option'); + pill.setAttribute('tabindex', '-1'); + + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'feature-pill-toggle'; + toggleBtn.textContent = meta.label; + toggleBtn.setAttribute('aria-pressed', String(Boolean(selection[id]))); + toggleBtn.addEventListener('click', () => { + selection[id] = !selection[id]; + const isSelected = Boolean(selection[id]); + pill.classList.toggle('feature-pill--selected', isSelected); + pill.setAttribute('aria-selected', String(isSelected)); + toggleBtn.setAttribute('aria-pressed', String(isSelected)); + syncHidden(); + }); + + const infoBtn = document.createElement('button'); + infoBtn.type = 'button'; + infoBtn.className = 'feature-pill-info'; + infoBtn.setAttribute('aria-label', `Show info for ${meta.label}`); + infoBtn.innerHTML = ''; + infoBtn.addEventListener('click', (event) => { + event.stopPropagation(); + generateFeatureCard({ id, ...meta, selected: Boolean(selection[id]) }, { trigger: infoBtn }); + }); + + const isSelected = Boolean(selection[id]); + pill.classList.toggle('feature-pill--selected', isSelected); + pill.setAttribute('aria-selected', String(isSelected)); + + pill.appendChild(toggleBtn); + pill.appendChild(infoBtn); + container.appendChild(pill); + }); + + syncHidden(); +} + +function normalizeFeatureState(featureState) { + const normalized = { ...FEATURE_DEFAULTS }; + if (featureState && typeof featureState === 'object' && !Array.isArray(featureState)) { + for (const [key, value] of Object.entries(featureState)) { + if (Object.prototype.hasOwnProperty.call(FEATURE_DEFAULTS, key) || Object.prototype.hasOwnProperty.call(FEATURE_METADATA, key)) { + normalized[key] = Boolean(value); + } + } + } + return normalized; +} + +function getFeatureMeta(id) { + if (Object.prototype.hasOwnProperty.call(FEATURE_METADATA, id)) { + return FEATURE_METADATA[id]; + } + const label = id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + return { + label, + description: 'Toggle this capability for the scheduled run.', + }; +} + +function generateFeatureCard(feature, options = {}) { + const mount = options.mountSelector ? document.querySelector(options.mountSelector) : document.body; + if (!mount) throw new Error('mount element not found'); + + const overlay = document.createElement('div'); + overlay.className = 'fc-overlay'; + overlay.setAttribute('data-testid', 'feature-card-overlay'); + + const dialog = document.createElement('div'); + dialog.className = 'fc-dialog'; + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + + const titleId = `fc-title-${Math.random().toString(36).slice(2)}`; + const descId = `fc-desc-${Math.random().toString(36).slice(2)}`; + dialog.setAttribute('aria-labelledby', titleId); + dialog.setAttribute('aria-describedby', descId); + + const header = document.createElement('header'); + header.className = 'fc-header'; + + const titleWrap = document.createElement('div'); + titleWrap.className = 'fc-titlewrap'; + + const h1 = document.createElement('h1'); + h1.id = titleId; + h1.className = 'fc-title'; + h1.textContent = feature.label; + + const tag = document.createElement('span'); + tag.className = 'fc-tag'; + tag.textContent = FEATURE_SECTION_TAG; + tag.setAttribute('data-testid', 'feature-tag'); + + titleWrap.appendChild(h1); + titleWrap.appendChild(tag); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'fc-close'; + closeBtn.type = 'button'; + closeBtn.setAttribute('aria-label', 'close'); + closeBtn.textContent = '×'; + + header.appendChild(titleWrap); + header.appendChild(closeBtn); + + const body = document.createElement('div'); + body.className = 'fc-body'; + + const desc = document.createElement('p'); + desc.id = descId; + desc.className = 'fc-desc'; + desc.textContent = feature.description; + + const details = document.createElement('dl'); + details.className = 'fc-details'; + + const pairs = [ + ['Identifier', feature.id], + ['Current state', feature.selected ? 'Enabled' : 'Disabled'], + ]; + + pairs.forEach(([label, value]) => { + const dt = document.createElement('dt'); + dt.textContent = label; + const dd = document.createElement('dd'); + dd.textContent = value; + details.appendChild(dt); + details.appendChild(dd); + }); + + body.appendChild(desc); + body.appendChild(details); + + dialog.appendChild(header); + dialog.appendChild(body); + overlay.appendChild(dialog); + mount.appendChild(overlay); + + const opener = options.trigger instanceof HTMLElement ? options.trigger : document.activeElement; + + const focusableSelectors = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])' + ].join(','); + + const getFocusable = () => /** @type {HTMLElement[]} */(Array.from(dialog.querySelectorAll(focusableSelectors))); + + const focusFirst = () => { + const items = getFocusable(); + (items[0] || closeBtn).focus(); + }; + + const onKeydown = (event) => { + if (event.key === 'Tab') { + const items = getFocusable(); + if (items.length === 0) { + event.preventDefault(); + closeBtn.focus(); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + const active = /** @type {HTMLElement} */(document.activeElement); + if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } else if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + } + } + if (event.key === 'Escape') { + event.preventDefault(); + close(); + } + }; + + const onOverlayClick = (event) => { + if (event.target === overlay) close(); + }; + + overlay.addEventListener('click', onOverlayClick); + document.addEventListener('keydown', onKeydown); + closeBtn.addEventListener('click', () => close()); + + const prevOverflow = document.documentElement.style.overflow; + document.documentElement.style.overflow = 'hidden'; + + setTimeout(focusFirst, 0); + + function close() { + overlay.removeEventListener('click', onOverlayClick); + document.removeEventListener('keydown', onKeydown); + document.documentElement.style.overflow = prevOverflow; + overlay.remove(); + if (opener && typeof opener.focus === 'function') opener.focus(); + } + + return { close, root: overlay }; +} diff --git a/scheduler/public/index.html b/scheduler/public/index.html index 012cf2f..daac7e9 100644 --- a/scheduler/public/index.html +++ b/scheduler/public/index.html @@ -5,7 +5,13 @@ Schedules • Task & Workflow Manager + + + + + + @@ -30,6 +36,64 @@
@@ -118,9 +145,9 @@ name schedules - tz + start template - entrypoint + prompt one-shot actions @@ -142,71 +169,130 @@ -
- - + +
+
+ + +
- - + +
- + +
+
+ +
+
+ + + Cron is in EST (America/New_York) +
+
+ +
+ +
+
+ +
+
Run Settings
+
    +
  • + +
  • +
  • + +
  • +
+ +
+ available workflow templates +
    +
    - - + + +
    - - + +
    + + +
    +
    +

    Features

    +

    Toggle optional capabilities for the run.

    +
    +
    + +
    -
    -
    - -
    -
    - +
    +

    Prompt

    +

    Supports Markdown formatting.

    + +
    + +
    +

    Attachments

    +

    Optional files are uploaded with the schedule and shared with the run.

    + +
      +
      + +
      +
      + +
      +
      + + +
      +
      - - - - -
      -
      - - -
      - -
      - -
      - available workflow templates -
        -
        - + + + + - \ No newline at end of file + diff --git a/scheduler/public/modelList.css b/scheduler/public/modelList.css new file mode 100644 index 0000000..c8d779a --- /dev/null +++ b/scheduler/public/modelList.css @@ -0,0 +1,340 @@ +.mc-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: grid; + place-items: center; + padding: clamp(12px, 2vw, 24px); + z-index: 9999; +} + +.mc-dialog { + width: min(880px, 100%); + max-height: 90dvh; + overflow: auto; + border-radius: 12px; + background: #0b0b0c; + color: #e8e8ea; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + outline: none; +} + +.mc-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + position: sticky; + top: 0; + backdrop-filter: blur(6px); + background: linear-gradient(to bottom, rgba(11, 11, 12, 0.9), rgba(11, 11, 12, 0.7)); + z-index: 1; +} + +.mc-titlewrap { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.mc-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; + border: 1px solid rgba(255, 255, 255, 0.1); + background: #1a1a1d; +} + +.mc-avatar--placeholder { + background: linear-gradient(135deg, #2a2a2e, #1e1e22); +} + +.mc-title { + font-size: 1.1rem; + font-weight: 600; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mc-subtitle { + font-size: 0.85rem; + color: #b8b8bf; +} + +.mc-close { + appearance: none; + border: none; + background: transparent; + color: #bfbfc7; + font-size: 1.6rem; + line-height: 1; + border-radius: 8px; + padding: 4px 8px; + cursor: pointer; +} + +.mc-close:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.06); +} + +.mc-body { + padding: 20px; + display: grid; + gap: 18px; +} + +.mc-desc { + margin: 0; + color: #d8d8de; +} + +.mc-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mc-badge { + font-size: 0.78rem; + padding: 6px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #e8e8ea; +} + +.mc-section { + display: grid; + gap: 10px; +} + +.mc-section-title { + font-size: 0.95rem; + font-weight: 600; + color: #f0f0f3; + margin: 0; +} + +.mc-capabilities { + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px 14px; + padding: 0; + margin: 0; +} + +.mc-cap { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.mc-cap-label { + color: #d8d8de; + font-size: 0.92rem; +} + +.mc-cap-state { + font-size: 0.82rem; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); +} + +.mc-cap-state.is-yes { + background: rgba(16, 185, 129, 0.22); + border-color: rgba(16, 185, 129, 0.35); +} + +.mc-cap-state.is-no { + background: rgba(239, 68, 68, 0.22); + border-color: rgba(239, 68, 68, 0.35); +} + +.mc-table { + width: 100%; + border-collapse: collapse; + overflow: hidden; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.mc-table th, +.mc-table td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + vertical-align: top; +} + +.mc-table th { + width: 30%; + color: #bfc0c7; + font-weight: 500; +} + +.mc-links { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 6px; +} + +.mc-links a { + color: #a6c8ff; + text-decoration: none; + word-break: break-all; +} + +.mc-links a:hover { + text-decoration: underline; +} + +/* disclosure (advanced details) */ +.mc-disclosure { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + overflow: hidden; +} + +.mc-disclosure+.mc-disclosure { + margin-top: 8px; +} + +.mc-disclosure-summary { + cursor: pointer; + list-style: none; + user-select: none; + padding: 12px 14px; + font-weight: 600; + color: #f0f0f3; +} + +.mc-disclosure[open] .mc-disclosure-summary { + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.mc-disclosure-body { + padding: 12px 14px; + display: grid; + gap: 16px; +} + +/* nested disclosure for raw json */ +.mc-disclosure--nested { + border: 1px dashed rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.02); +} + +.mc-disclosure--nested .mc-disclosure-summary { + font-weight: 500; + color: #d8d8de; +} + +.mc-pre { + margin: 0; + padding: 12px; + overflow: auto; + max-height: 40dvh; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: 0.82rem; + line-height: 1.4; + border-radius: 8px; + background: #0f0f12; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +/* responsive adjustments */ +@media (max-width: 520px) { + .mc-header { + padding: 14px 16px; + } + + .mc-body { + padding: 16px; + } + + .mc-title { + font-size: 1rem; + } +} + +.selected-model-card { + margin-top: 0.5rem; + width: 100%; + display: grid; + gap: 0.25rem; + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + border: 1px solid color-mix(in srgb, var(--border), transparent 30%); + background: linear-gradient(180deg, + color-mix(in srgb, var(--accent) 12%, transparent), + color-mix(in srgb, var(--card) 92%, transparent)); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease; + outline: none; + position: relative; +} + +.selected-model-card:hover { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + box-shadow: 0 6px 20px rgba(15, 27, 51, 0.35); + transform: translateY(-1px); +} + +.selected-model-card:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 65%, white 20%); + outline-offset: 2px; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); +} + +.selected-model-title { + font-weight: 600; + font-size: 0.95rem; + letter-spacing: -0.01em; +} + +.selected-model-meta { + font-size: 0.8rem; + color: var(--muted); +} + +.selected-model-desc { + font-size: 0.78rem; + color: color-mix(in srgb, var(--muted) 85%, var(--text)); + opacity: 0.92; +} + +.selected-model-hint { + margin-top: 0.1rem; + font-size: 0.75rem; + color: color-mix(in srgb, var(--accent) 70%, var(--text)); + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.selected-model-hint::after { + content: '↗'; + font-size: 0.75rem; +} diff --git a/scheduler/public/modelList.js b/scheduler/public/modelList.js new file mode 100644 index 0000000..f2dc82c --- /dev/null +++ b/scheduler/public/modelList.js @@ -0,0 +1,441 @@ +function generateModelCard(model, options = {}) { + const mount = options.mountSelector + ? /** @type {HTMLElement} */(document.querySelector(options.mountSelector)) + : document.body; + + if (!mount) throw new Error('mount element not found'); + + // overlay + const overlay = document.createElement('div'); + overlay.className = 'mc-overlay'; + overlay.setAttribute('data-testid', 'model-card-overlay'); + + // dialog shell + const dialog = document.createElement('div'); + dialog.className = 'mc-dialog'; + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + + const titleId = `mc-title-${Math.random().toString(36).slice(2)}`, + descId = `mc-desc-${Math.random().toString(36).slice(2)}`; + dialog.setAttribute('aria-labelledby', titleId); + dialog.setAttribute('aria-describedby', descId); + + // header + const header = document.createElement('header'); + header.className = 'mc-header'; + + const titleWrap = document.createElement('div'); + titleWrap.className = 'mc-titlewrap'; + + const avatar = document.createElement('img'); + avatar.className = 'mc-avatar'; + if (model?.info?.meta?.profile_image_url) { + avatar.src = model.info.meta.profile_image_url; + avatar.alt = ''; + } else { + avatar.src = 'data:image/gif;base64,R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; + avatar.alt = ''; + avatar.classList.add('mc-avatar--placeholder'); + } + + const h1 = document.createElement('h1'); + h1.id = titleId; + h1.className = 'mc-title'; + h1.textContent = model?.name ?? model?.id ?? 'model'; + + const owner = document.createElement('div'); + owner.className = 'mc-subtitle'; + owner.textContent = model?.owned_by ? `by ${model.owned_by}` : ''; + + titleWrap.appendChild(avatar); + titleWrap.appendChild(h1); + if (owner.textContent) titleWrap.appendChild(owner); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'mc-close'; + closeBtn.type = 'button'; + closeBtn.setAttribute('aria-label', 'close'); + closeBtn.textContent = '×'; + + header.appendChild(titleWrap); + header.appendChild(closeBtn); + + // body + const body = document.createElement('div'); + body.className = 'mc-body'; + + // description (primary) + const desc = document.createElement('p'); + desc.id = descId; + desc.className = 'mc-desc'; + desc.textContent = model?.info?.meta?.description ?? 'no description provided.'; + + // badges (primary) + const badges = document.createElement('div'); + badges.className = 'mc-badges'; + const addBadge = (label, value) => { + if (value === undefined || value === null || value === '') return; + const b = document.createElement('span'); + b.className = 'mc-badge'; + b.textContent = `${label}: ${value}`; + badges.appendChild(b); + }; + addBadge('family', model?.ollama?.details?.family ?? model?.ollama?.details?.families?.[0]); + addBadge('params', model?.ollama?.details?.parameter_size); + addBadge('quant', model?.ollama?.details?.quantization_level); + addBadge('format', model?.ollama?.details?.format); + addBadge('connection', model?.connection_type ?? model?.ollama?.connection_type); + addBadge('created', formatEpoch(model?.created)); + addBadge('modified', formatIso(model?.ollama?.modified_at)); + + // capabilities (primary) + const caps = model?.info?.meta?.capabilities ?? {}; + const capsWrap = document.createElement('div'); + capsWrap.className = 'mc-section'; + + const capsTitle = document.createElement('h2'); + capsTitle.className = 'mc-section-title'; + capsTitle.textContent = 'capabilities'; + + const capsList = document.createElement('ul'); + capsList.className = 'mc-capabilities'; + Object.entries(caps).forEach(([k, v]) => { + const li = document.createElement('li'); + li.className = 'mc-cap'; + const label = document.createElement('span'); + label.className = 'mc-cap-label'; + label.textContent = k.replaceAll('_', ' '); + const state = document.createElement('span'); + state.className = `mc-cap-state ${v ? 'is-yes' : 'is-no'}`; + state.textContent = v ? 'yes' : 'no'; + li.appendChild(label); + li.appendChild(state); + capsList.appendChild(li); + }); + capsWrap.appendChild(capsTitle); + capsWrap.appendChild(capsList); + + // advanced details (moved to disclosure) + const adv = document.createElement('details'); + adv.className = 'mc-disclosure'; + adv.setAttribute('data-testid', 'model-card-advanced'); + + const advSum = document.createElement('summary'); + advSum.className = 'mc-disclosure-summary'; + advSum.textContent = 'advanced details'; + adv.appendChild(advSum); + + const advBody = document.createElement('div'); + advBody.className = 'mc-disclosure-body'; + + // details table (now inside disclosure) + const table = document.createElement('table'); + table.className = 'mc-table'; + const tbody = document.createElement('tbody'), + rows = [ + ['id', model?.id], + ['object', model?.object], + ['digest', model?.ollama?.digest], + ['size', formatBytes(model?.ollama?.size)], + ['parameter size', model?.ollama?.details?.parameter_size], + ['quantization', model?.ollama?.details?.quantization_level], + ['base model id', safeText(model?.info?.base_model_id) ?? '—'], + ['function calling', safeText(model?.info?.params?.function_calling)], + ['active', String(model?.info?.is_active ?? model?.info?.isActive ?? '—')], + ]; + + rows.forEach(([k, v]) => { + if (v === undefined || v === null) return; + const tr = document.createElement('tr'); + const th = document.createElement('th'); + th.textContent = k; + const td = document.createElement('td'); + td.textContent = String(v); + tr.appendChild(th); + tr.appendChild(td); + tbody.appendChild(tr); + }); + + table.appendChild(tbody); + + // urls (inside disclosure; only if valid strings) + const urlSection = document.createElement('div'); + urlSection.className = 'mc-section'; + if (Array.isArray(model?.ollama?.urls) && model.ollama.urls.some(u => u && typeof u === 'string')) { + const urlsTitle = document.createElement('h2'); + urlsTitle.className = 'mc-section-title'; + urlsTitle.textContent = 'urls'; + + const list = document.createElement('ul'); + list.className = 'mc-links'; + + model.ollama.urls.forEach(u => { + if (!u || typeof u !== 'string') return; + const li = document.createElement('li'); + const a = document.createElement('a'); + a.rel = 'noopener noreferrer'; + a.target = '_blank'; + a.href = u; + a.textContent = u; + li.appendChild(a); + list.appendChild(li); + }); + + urlSection.appendChild(urlsTitle); + urlSection.appendChild(list); + } + + // raw json (inside disclosure) + const rawWrap = document.createElement('details'); + rawWrap.className = 'mc-disclosure mc-disclosure--nested'; + + const rawSum = document.createElement('summary'); + rawSum.className = 'mc-disclosure-summary'; + rawSum.textContent = 'raw model json'; + + rawWrap.appendChild(rawSum); + + const pre = document.createElement('pre'); + pre.className = 'mc-pre'; + pre.textContent = prettyJson(model); + rawWrap.appendChild(pre); + + // assemble disclosure body + advBody.appendChild(table); + if (urlSection.childNodes.length) advBody.appendChild(urlSection); + advBody.appendChild(rawWrap); + adv.appendChild(advBody); + + // assemble modal content + body.appendChild(desc); + body.appendChild(badges); + body.appendChild(capsWrap); + body.appendChild(adv); // advanced section at the very end + + dialog.appendChild(header); + dialog.appendChild(body); + overlay.appendChild(dialog); + mount.appendChild(overlay); + + // focus management (trap + restore) + const opener = options.trigger instanceof HTMLElement ? options.trigger : /** @type {HTMLElement|null} */(document.activeElement); + + const focusableSelectors = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])' + ].join(','); + + const findFocusable = () => /** @type {HTMLElement[]} */(Array.from(dialog.querySelectorAll(focusableSelectors))), + focusFirst = () => { + const items = findFocusable(); + const target = items[0] || closeBtn; + target.focus(); + }; + + const onKeydown = (e) => { + if (e.key === 'Tab') { + const items = findFocusable(); + if (items.length === 0) { + e.preventDefault(); + closeBtn.focus(); + return; + } + const first = items[0], + last = items[items.length - 1], + active = /** @type {HTMLElement} */(document.activeElement); + if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } else if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } + } + if (e.key === 'Escape') { + e.preventDefault(); + close(); + } + }; + + const onOverlayClick = (e) => { + if (e.target === overlay) close(); + }; + + overlay.addEventListener('click', onOverlayClick); + document.addEventListener('keydown', onKeydown); + closeBtn.addEventListener('click', () => close()); + + // prevent background scroll while open + const prevOverflow = document.documentElement.style.overflow; + document.documentElement.style.overflow = 'hidden'; + + // open + setTimeout(focusFirst, 0); + + function close() { + overlay.removeEventListener('click', onOverlayClick); + document.removeEventListener('keydown', onKeydown); + document.documentElement.style.overflow = prevOverflow; + overlay.remove(); + if (opener && typeof opener.focus === 'function') opener.focus(); + } + + return { close, root: overlay }; +} + +// helpers +function formatBytes(n) { + if (typeof n !== 'number') return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0, v = n; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(1)} ${units[i]}`; +} + +const formatEpoch = (epochSeconds) => { + if (!Number.isFinite(epochSeconds)) return '—'; + try { return new Date(epochSeconds * 1000).toLocaleString(); } catch { return '—'; } +} + +function formatIso(iso) { + if (!iso || typeof iso !== 'string') return '—'; + const d = new Date(iso); + return isNaN(d.getTime()) ? iso : d.toLocaleString(); +} + +const safeText = (v) => { return v === undefined || v === null ? null : String(v); }, + prettyJson = (obj) => { + try { return JSON.stringify(obj, null, 2); } catch { return String(obj); } + } + + +const makeOptionLabel = (m, models) => { + const base = m?.name ?? m?.id ?? 'model', + duplicates = models.filter(x => (x?.name ?? x?.id) === (m?.name ?? m?.id)); + + if (duplicates.length > 1) { + const hint = m?.ollama?.details?.parameter_size || m?.ollama?.details?.quantization_level; + return hint ? `${base} • ${hint}` : `${base} • ${String(m?.id).slice(0, 8)}`; + } + + return base; +}; + +// render options +function renderModelList(models) { + const sel = document.querySelector('#model-select'), + card = document.querySelector('#selected-model-card'); + + if (!sel) { + console.warn('failed to find model dropdown'); + return; + } + + const list = Array.isArray(models) ? models : []; + sel.__modelsData = list; + if (card) card.__modelsData = list; + + const prevValue = sel.value; + for (let i = sel.options.length - 1; i >= 1; i--) sel.remove(i); + + list.forEach(m => { + const opt = document.createElement('option'); + opt.value = String(m?.id ?? ''); + opt.textContent = makeOptionLabel(m, list); + sel.appendChild(opt); + }); + + const selectedExists = list.some(m => String(m?.id ?? '') === prevValue); + sel.value = selectedExists ? prevValue : ''; + + const updateCard = (id) => { + if (!card) return; + const registry = card.__modelsData || [], + model = registry.find(m => String(m?.id ?? '') === id); + + if (!id || !model) { + card.hidden = true; + card.dataset.modelId = ''; + card.replaceChildren(); + card.setAttribute('aria-label', 'No model selected'); + return; + } + + card.hidden = false; + card.dataset.modelId = id; + card.setAttribute('aria-label', `Selected model ${makeOptionLabel(model, registry)}`); + card.replaceChildren(); + + const title = document.createElement('span'); + title.className = 'selected-model-title'; + title.textContent = model?.name ?? model?.id ?? 'model'; + + const metaParts = []; + if (model?.owned_by) metaParts.push(model.owned_by); + + const paramSize = model?.ollama?.details?.parameter_size; + if (paramSize) metaParts.push(paramSize); + + const quant = model?.ollama?.details?.quantization_level; + if (quant) metaParts.push(quant); + + const metaText = metaParts.join(' • '), + descRaw = (model?.info?.meta?.description ?? '').trim(), + descText = descRaw.length > 90 ? `${descRaw.slice(0, 87).trim()}…` : descRaw; + + const hint = document.createElement('span'); + hint.className = 'selected-model-hint'; + hint.textContent = 'Open model details'; + + card.appendChild(title); + if (metaText) { + const meta = document.createElement('span'); + meta.className = 'selected-model-meta'; + meta.textContent = metaText; + card.appendChild(meta); + } + + if (descText) { + const desc = document.createElement('span'); + desc.className = 'selected-model-desc'; + desc.textContent = descText; + card.appendChild(desc); + } + + card.appendChild(hint); + }; + + if (!sel.__modelChangeHandler) { + sel.__modelChangeHandler = (e) => { + const target = /** @type {HTMLSelectElement} */(e.currentTarget), + id = target.value; + + if (id) updateCard(id); + + // const registry = sel.__modelsData || [], + // model = registry.find(m => String(m?.id ?? '') === id); + + // if (!model) return; + // generateModelCard(model, { trigger: sel }); + }; + sel.addEventListener('change', sel.__modelChangeHandler); + } + + if (card && !card.dataset.bound) { + card.addEventListener('click', () => { + const id = card.dataset.modelId; + if (!id) return; + const registry = card.__modelsData || []; + const model = registry.find(m => String(m?.id ?? '') === id); + if (model) generateModelCard(model, { trigger: card }); + }); + card.dataset.bound = 'true'; + } + + updateCard(sel.value); +} diff --git a/scheduler/public/script.js b/scheduler/public/script.js index 04d30c6..9113f77 100644 --- a/scheduler/public/script.js +++ b/scheduler/public/script.js @@ -1,8 +1,19 @@ +const storedMe = (() => { + try { + const raw = localStorage.getItem("me") ?? localStorage.getItem("user"); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +})(); + const state = { - userId: localStorage.getItem("userId") || "", - displayName: localStorage.getItem("displayName") || "", + me: storedMe, }; +/** + * @returns {HTMLElement} + */ const $ = (sel) => document.querySelector(sel), setText = (sel, v) => { const el = $(sel); @@ -14,74 +25,314 @@ const authStatusEl = $("#authStatus"), createStatusEl = $("#createStatus"), runNowStatusEl = $("#runNowStatus"), schedulesTbody = $("#schedulesTbody"), - templatesUl = $("#templatesUl"); + templatesUl = $("#templatesUl"), + oneShotCheckmark = $("#oneShot"), + startAtInput = $("#startAt"), + cronInput = $("#cron"), + cronError = $("#cronError"), + filesInput = $("#scheduleFiles"), + filesList = $("#scheduleFilesList"); -// 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}`; +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 { - authStatusEl.textContent = "not logged in"; + 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 = '
      • no files selected
      • '; + 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 = `${isadmin || getPerm(me, it.path) ? '✅' : '❌'}`; + li.innerHTML += `${escapeHtml(it.label)}`; + 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 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'); + 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'); + const icon = document.querySelector("#themeToggleIcon"), + label = document.querySelector("#themeToggleLabel"); if (icon && label) { - const isDark = mode === 'dark'; - icon.textContent = isDark ? '🌙' : '☀️'; - label.textContent = isDark ? 'Dark' : 'Light'; + const isDark = mode === "dark"; + icon.textContent = isDark ? "🌙" : "☀️"; + label.textContent = isDark ? "Dark" : "Light"; } - document.querySelector('#themeToggle')?.setAttribute('aria-pressed', String(mode === 'dark')); + 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'; +document.querySelector("#themeToggle")?.addEventListener("click", () => { + const next = document.body.classList.contains("theme-dark") ? "light" : "dark"; applyTheme(next); - try { localStorage.setItem('theme', next); } catch (_) { /* ignore */ } + 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'); + if (!localStorage.getItem("theme")) applyTheme(e.matches ? "dark" : "light"); }); } -// wrap fetch to always attach x-user-id +// 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.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) - ) { + 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 @@ -99,67 +350,117 @@ async function apiFetch(url, options = {}) { 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 - : ""; + const tr = document.createElement("tr"), + tRef = it.templateRef + ? it.templateRef.clusterScope + ? `(cluster) ${it.templateRef.name}` + : it.templateRef.name + : ""; + tr.innerHTML = ` ${escapeHtml(it.displayName || it.name || "")} ${(it.schedules || []).map(escapeHtml).join("
        ")} - ${escapeHtml(it.timezone || "")} + ${escapeHtml(it.startAt || "")} ${escapeHtml(tRef)} - ${escapeHtml(it.entrypoint || "")} + ${escapeHtml(it.prompt || "")} ${it.oneShot ? "yes" : "no"} `; + schedulesTbody.appendChild(tr); }); } // tiny escape helper function escapeHtml(s = "") { - return String(s) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">"); + return String(s).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } // wire up events -$("#loginForm").addEventListener("submit", (e) => { +$("#loginForm").addEventListener("submit", async (e) => { e.preventDefault(); - const userId = $("#userId").value.trim(), - displayName = $("#displayName").value.trim(); - if (!userId) { - authStatusEl.textContent = "please enter a user id"; + const username = $("#username").value.trim(), + password = $("#password").value; + + if (!username || !password) { + authStatusEl.textContent = "please enter username and password"; return; } - state.userId = userId; - state.displayName = displayName; - localStorage.setItem("userId", state.userId); - localStorage.setItem("displayName", state.displayName); - paintAuth(); + + 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}`; + } }); -$("#logoutBtn").addEventListener("click", () => { - localStorage.removeItem("userId"); - localStorage.removeItem("displayName"); - state.userId = ""; - state.displayName = ""; +// 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"); - const data = await res.json(); + + 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)`; + listStatusEl.textContent = `loaded ${Array.isArray(data.items) ? data.items.length : 0} schedule(s)`; } catch (e) { listStatusEl.textContent = `error: ${e.message}`; } @@ -175,7 +476,7 @@ schedulesTbody.addEventListener("click", async (e) => { try { target.disabled = true; - const res = await apiFetch(`/schedules/${name}`, { + const res = await apiFetch(`/api/schedules/${name}`, { method: "DELETE", }); @@ -198,17 +499,36 @@ $("#createForm").addEventListener("submit", async (e) => { try { createStatusEl.textContent = "saving..."; const name = $("#name").value.trim(), - tz = $("#tz").value.trim() || "America/New_York", - iso = $("#iso").value - ? new Date($("#iso").value).toISOString() - : "", + startAt = startAtInput?.value || "", cron = $("#cron").value.trim(), templateName = $("#templateName").value.trim(), - entrypoint = $("#entrypoint").value.trim(), + prompt = $("#prompt").value.trim(), clusterScope = $("#clusterScope").checked, oneShot = $("#oneShot").checked, - paramsRaw = $("#params").value.trim(); + 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 { @@ -218,64 +538,62 @@ $("#createForm").addEventListener("submit", async (e) => { } } + 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: cron ? { cron } : { iso }, - tz, + when, oneShot, template: { name: templateName, clusterScope }, parameters, - entrypoint: entrypoint || undefined, + prompt, + model, + tools, + features }; + if (filesPayload.length) payload.files = filesPayload; - await apiFetch("/schedules", { + 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}`; } }); -// 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 () => { @@ -292,14 +610,16 @@ $("#loadTemplatesBtn").addEventListener("click", async () => { templatesUl.appendChild(li); }); } catch (e) { - templatesUl.innerHTML = `
      • error: ${escapeHtml( - e.message - )}
      • `; + templatesUl.innerHTML = `
      • error: ${escapeHtml(e.message)}
      • `; } }); -// boot -paintAuth(); +// window.onbeforeunload = () => CookieStore.delete('token'); -// auto-refresh if already logged in -if (state.userId) $("#refreshBtn").click(); \ No newline at end of file +const reffunc = () => { + // auto-refresh if already logged in + if (state.me && state.me.id) $("#refreshBtn").click(); +} + +// boot +paintAuth().then(reffunc); diff --git a/scheduler/public/style.css b/scheduler/public/style.css index c0aea16..8cc5ce0 100644 --- a/scheduler/public/style.css +++ b/scheduler/public/style.css @@ -48,6 +48,12 @@ body.theme-dark { --border: rgba(255, 255, 255, 0.1); --accent: #7aa2ff; --accent-600: #4f7dff; + + /* warn box */ + --box-bg: #2c2c2c; + --box-text: #f5f5f5; + --box-border: #ff6b6b; + --icon-fill: #ff6b6b; } body.theme-light { @@ -61,6 +67,12 @@ body.theme-light { --border: #e5e9f2; --accent: #2563eb; --accent-600: #1e40af; + + /* warn box */ + --box-bg: #ffffff; + --box-text: #000000; + --box-border: #d9534f; + --icon-fill: #d9534f; } @@ -70,7 +82,7 @@ body.theme-light { html, body { - height: 100%; + min-height: 100%; } body { @@ -128,7 +140,7 @@ body { /* content */ .content-grid { max-width: var(--maxw); - margin: 1.2rem auto 2rem; + /* margin: 1.2rem auto 2rem; */ padding: 0 clamp(1rem, 3vw, 1.25rem); display: grid; grid-template-columns: 360px 1fr; @@ -150,12 +162,17 @@ body { .col-right {} +.templates summary { + cursor: pointer; +} + /* cards */ .card { background: linear-gradient(180deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, .02)), var(--card); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: 0 10px 30px rgba(2, 6, 23, 0.28), 0 1px 0 rgba(255, 255, 255, .04) inset; + padding: 10px; } .card.compact { @@ -180,6 +197,36 @@ body { gap: .9rem; } +.file-input { + display: grid; + gap: .35rem; + font-size: .95rem; +} + +.file-input-label { + font-weight: 600; +} + +.file-input input[type="file"] { + font-size: .95rem; +} + +.file-list { + margin: .5rem 0 0; + padding-left: 1.2rem; + list-style: disc; + color: var(--muted); + font-size: .9rem; +} + +.file-list li + li { + margin-top: .25rem; +} + +.file-list .empty::marker { + content: ''; +} + .row { display: grid; gap: .9rem; @@ -193,7 +240,16 @@ body { } } -label { +ul[class="row"] { + display: block; + line-height: 25px; + list-style: none; + padding: 0; + margin: 10px 0px; +} + +label, +.label { display: block; font-weight: 600; font-size: .92rem; @@ -331,6 +387,9 @@ td:first-child { display: flex; gap: .5rem; align-items: center; + padding: 10px 0px; + width: 100%; + justify-content: space-evenly; } .actions.wrap { @@ -366,6 +425,153 @@ code.inline { font-size: .9rem; } +/* auth card */ +.auth-card { + border: 1px solid var(--border, #333); + border-radius: 8px; + padding: 1rem; + margin: 1rem 0 2rem; + max-width: 480px; + background: var(--panel, rgba(0, 0, 0, 0.05)); +} + +.auth-card h2 { + margin: 0 0 .5rem; + font-size: 1.1rem; +} + +.auth-card form { + display: grid; + gap: .75rem; +} + +.auth-card label { + display: grid; + gap: .25rem; + font-size: .9rem; +} + +.auth-card input { + padding: .5rem .6rem; + border-radius: 6px; + border: 1px solid var(--border, #333); + background: var(--bg, #fff); + color: inherit; +} + +.auth-card .row { + display: flex; + gap: .5rem; + align-items: center; +} + +.auth-card button { + padding: .45rem .8rem; + border-radius: 6px; + border: 1px solid var(--border, #333); + color: inherit; + cursor: pointer; +} + +/* user card */ +.user-card { + height: fit-content; +} + +.user-card .user-compact { + display: flex; + gap: 0.6rem; + align-items: center; + padding: 0.6rem; +} + +.user-card .avatar { + width: 48px; + height: 48px; + border-radius: 999px; + object-fit: cover; + background: rgba(0, 0, 0, 0.06); + border: 1px solid rgba(0, 0, 0, 0.06); +} + +.user-card .user-info { + min-width: 0; +} + +.user-card .user-name { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-card .user-email, +.user-card .user-role { + font-size: 0.85rem; + opacity: 0.8; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card[aria-hidden="true"] { + display: none; +} + +/* permissions UI */ +.permissions { + margin-top: .6rem; + padding: 1rem; + border-top: 1px dashed rgba(0, 0, 0, 0.06); +} + +.permissions .small { + margin: 0 0 .35rem; + font-size: .85rem; + color: var(--muted, #666); +} + +.perms-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: .25rem; + font-size: .9rem; + margin-left: 5px; +} + +.perms-list .perm-key { + display: inline-block; + min-width: 140px; + color: var(--muted, #666); +} + +.nested-perms { + list-style: none; + padding: 0 0 0 .5rem; + margin: 0; + display: grid; + gap: .15rem; +} + +.perm-val { + font-weight: 700; + margin-left: .5rem; +} + +.perm-yes { + color: #0b875b; +} + +/* green */ +.perm-no { + color: #c23b3b; +} + +/* red */ + /* high contrast preference gets stronger focus */ @media (prefers-contrast: more) { @@ -376,4 +582,43 @@ code.inline { .theme-toggle:focus { box-shadow: 0 0 0 3px #fff, 0 0 0 5px var(--accent); } -} \ No newline at end of file +} + + +/* Box layout and styling */ +.warning-box { + display: inline-flex; + align-items: center; + /* Space between icon and text */ + gap: 0.6rem; + margin-top: 10px; + padding: 0.4rem 0.8rem; + font-family: system-ui, sans-serif; + font-size: 0.9rem; + color: var(--box-text); + background: var(--box-bg); + border: 1px solid var(--box-border); + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + max-height: 50px; +} + +/* Icon styling */ +.warning-icon { + width: 1.2rem; + height: 1.2rem; + flex-shrink: 0; + fill: var(--icon-fill); +} + +/* make the whole box a bit more readable on hover */ +.warning-box:hover { + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + cursor: pointer; +} + +.form-subsection { + border-top: 1px dashed var(--border); + padding-top: 1rem; + margin-top: .25rem; +} diff --git a/scheduler/public/toolList.css b/scheduler/public/toolList.css new file mode 100644 index 0000000..fd4c80d --- /dev/null +++ b/scheduler/public/toolList.css @@ -0,0 +1,382 @@ +:root { + /* core palette tuned for strong contrast on very dark ui (aiming for wcag aa) */ + --tc-bg: #120a09; + /* deep near-black with red tint */ + --tc-surface: #1a0e0b; + /* card background */ + --tc-surface-2: #21110e; + /* subtle layered bg */ + --tc-text: #f3e9e7; + /* primary text */ + --tc-text-subtle: #913413; + /* secondary text */ + --tc-muted: #cbb2aa; + --tc-accent: #ff6b3d; + /* orange/red accent */ + --tc-accent-2: #ff3d3d; + /* red accent for states */ + --tc-border: rgba(255, 255, 255, 0.10); + --tc-border-2: rgba(255, 255, 255, 0.16); + --tc-overlay: rgba(0, 0, 0, 0.55); + --tc-link: #ffb083; + /* readable on dark bg */ +} + +.tc-overlay { + position: fixed; + inset: 0; + background: var(--tc-overlay); + display: grid; + place-items: center; + padding: clamp(12px, 2vw, 24px); + z-index: 9999; +} + +.tc-dialog { + width: min(880px, 100%); + max-height: 90dvh; + overflow: auto; + border-radius: 12px; + background: linear-gradient(180deg, var(--tc-surface), var(--tc-surface-2)); + color: var(--tc-text); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); + outline: none; + border: 1px solid var(--tc-border); +} + +.tc-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--tc-border); + position: sticky; + top: 0; + backdrop-filter: blur(6px); + background: + linear-gradient(to bottom, color-mix(in srgb, var(--tc-surface), transparent 20%), color-mix(in srgb, var(--tc-surface-2), transparent 30%)), + conic-gradient(from 0.25turn at 10% -40%, color-mix(in srgb, var(--tc-accent), transparent 85%), transparent 30%); + z-index: 1; +} + +.tc-titlewrap { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.tc-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; + border: 1px solid var(--tc-border-2); + background: #2a1512; +} + +.tc-avatar--placeholder { + background: linear-gradient(135deg, #3b1e19, #2a1512); +} + +.tc-title { + font-size: 1.1rem; + font-weight: 700; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tc-subtitle { + font-size: 0.85rem; + color: var(--tc-muted); +} + +.tc-close { + appearance: none; + border: 1px solid transparent; + background: transparent; + color: var(--tc-muted); + font-size: 1.6rem; + line-height: 1; + border-radius: 8px; + padding: 4px 8px; + cursor: pointer; +} + +.tc-close:hover { + color: var(--tc-text); + border-color: var(--tc-border-2); + background: color-mix(in srgb, var(--tc-accent), transparent 86%); +} + +.tc-body { + padding: 20px; + display: grid; + gap: 18px; +} + +.tc-desc { + margin: 0; + color: var(--tc-text); +} + +.tc-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tc-badge { + font-size: 0.78rem; + padding: 6px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--tc-accent), transparent 88%); + border: 1px solid var(--tc-border-2); + color: var(--tc-text); +} + +.tc-section { + display: grid; + gap: 10px; +} + +.tc-section-title { + font-size: 0.95rem; + font-weight: 700; + color: var(--tc-text); + margin: 0; +} + +.tc-manifest { + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px 14px; + padding: 0; + margin: 0; +} + +.tc-manifest-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--tc-border); +} + +.tc-manifest-item a { + color: #4da6ff; + text-decoration: none; +} + +.tc-manifest-item a:hover { + color: #80c1ff; + text-decoration: underline; +} + + +.tc-manifest-label { + color: var(--tc-text-subtle); + font-size: 0.92rem; +} + +.tc-manifest-value { + font-size: 0.86rem; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--tc-border-2); + background: rgba(255, 255, 255, 0.06); + overflow-x: scroll; +} + +.tc-table { + width: 100%; + border-collapse: collapse; + overflow: hidden; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--tc-border); +} + +.tc-table th, +.tc-table td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid var(--tc-border); + vertical-align: top; +} + +.tool-pill-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 6px 0; + min-height: 40px; +} + +.tool-pill { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid var(--tc-border); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} + +.tool-pill--selected { + background: color-mix(in srgb, var(--tc-accent), transparent 70%); + border-color: color-mix(in srgb, var(--tc-accent), transparent 40%); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} + +.tool-pill-toggle { + appearance: none; + border: none; + background: transparent; + color: inherit; + font: inherit; + padding: 6px 12px; + border-radius: 999px; + cursor: pointer; + flex: 1 1 auto; + text-align: left; +} + +.tool-pill-toggle:focus-visible, +.tool-pill-info:focus-visible { + outline: 2px solid color-mix(in srgb, var(--tc-accent), white 35%); + outline-offset: 2px; +} + +.tool-pill-info { + appearance: none; + border: none; + background: transparent; + color: var(--tc-muted); + width: 28px; + height: 28px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + margin-right: 4px; +} + +.tool-pill-info:hover { + color: var(--tc-text); + background: rgba(255, 255, 255, 0.08); +} + +.tool-pill-info span { + display: inline-block; + font-size: 0.9rem; + font-weight: 700; +} + +.tool-pill-empty { + margin: 0; +} + +.tc-table th { + width: 30%; + color: var(--tc-muted); + font-weight: 600; +} + +.tc-links { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 6px; +} + +.tc-links a { + color: var(--tc-link); + text-decoration: none; + word-break: break-all; +} + +.tc-links a:hover { + text-decoration: underline; +} + +/* disclosure (advanced details) */ +.tc-disclosure { + border: 1px solid var(--tc-border); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + overflow: hidden; +} + +.tc-disclosure+.tc-disclosure { + margin-top: 8px; +} + +.tc-disclosure-summary { + cursor: pointer; + list-style: none; + user-select: none; + padding: 12px 14px; + font-weight: 700; + color: var(--tc-text); +} + +.tc-disclosure[open] .tc-disclosure-summary { + border-bottom: 1px solid var(--tc-border); +} + +.tc-disclosure-body { + padding: 12px 14px; + display: grid; + gap: 16px; +} + +/* nested disclosure for raw json */ +.tc-disclosure--nested { + border: 1px dashed color-mix(in srgb, var(--tc-accent-2), transparent 65%); + background: rgba(255, 255, 255, 0.02); +} + +.tc-disclosure--nested .tc-disclosure-summary { + font-weight: 600; + color: var(--tc-text-subtle); +} + +.tc-pre { + margin: 0; + padding: 12px; + overflow: auto; + max-height: 40dvh; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: 0.82rem; + line-height: 1.4; + border-radius: 8px; + background: #0d0706; + border: 1px solid var(--tc-border); + color: var(--tc-text); +} + +/* responsive adjustments */ +@media (max-width: 520px) { + .tc-header { + padding: 14px 16px; + } + + .tc-body { + padding: 16px; + } + + .tc-title { + font-size: 1rem; + } +} \ No newline at end of file diff --git a/scheduler/public/toolList.js b/scheduler/public/toolList.js new file mode 100644 index 0000000..2a54720 --- /dev/null +++ b/scheduler/public/toolList.js @@ -0,0 +1,389 @@ +function generateToolCard(tool, options = {}) { + const mount = options.mountSelector + ? (document.querySelector(options.mountSelector)) + : document.body; + + if (!mount) throw new Error('mount element not found'); + + // overlay + const overlay = document.createElement('div'); + overlay.className = 'tc-overlay'; + overlay.setAttribute('data-testid', 'tool-card-overlay'); + + // dialog shell + const dialog = document.createElement('div'); + dialog.className = 'tc-dialog'; + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); // per apg modal dialog pattern + // apg & mdn recommend explicit labelling/description for dialogs + const titleId = `tc-title-${Math.random().toString(36).slice(2)}`, + descId = `tc-desc-${Math.random().toString(36).slice(2)}`; + dialog.setAttribute('aria-labelledby', titleId); + dialog.setAttribute('aria-describedby', descId); + + // header + const header = document.createElement('header'); + header.className = 'tc-header'; + + const titleWrap = document.createElement('div'); + titleWrap.className = 'tc-titlewrap'; + + // // avatar (from user.profile_image_url if present) + // const avatar = document.createElement('img'); + // avatar.className = 'tc-avatar'; + // const profileUrl = tool?.user?.profile_image_url; + // if (typeof profileUrl === 'string' && profileUrl.length > 0) { + // avatar.src = profileUrl; + // avatar.alt = ''; + // } else { + // avatar.src = 'data:image/gif;base64,R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; + // avatar.alt = ''; + // avatar.classList.add('tc-avatar--placeholder'); + // } + + // title + owner + const h1 = document.createElement('h1'); + h1.id = titleId; + h1.className = 'tc-title'; + h1.textContent = tool?.name ?? tool?.id ?? 'tool'; + + // const owner = document.createElement('div'); + // owner.className = 'tc-subtitle'; + // owner.textContent = tool?.user?.name ? `by ${tool.user.name}` : ''; + + // titleWrap.appendChild(avatar); + titleWrap.appendChild(h1); + // if (owner.textContent) titleWrap.appendChild(owner); + + // close button + const closeBtn = document.createElement('button'); + closeBtn.className = 'tc-close'; + closeBtn.type = 'button'; + closeBtn.setAttribute('aria-label', 'close'); + closeBtn.textContent = '×'; + + header.appendChild(titleWrap); + header.appendChild(closeBtn); + + // body + const body = document.createElement('div'); + body.className = 'tc-body'; + + // description (primary) + const desc = document.createElement('p'); + desc.id = descId; + desc.className = 'tc-desc'; + desc.textContent = + tool?.meta?.description + ?? tool?.meta?.manifest?.description + ?? 'no description provided.'; + + // badges (primary) + const badges = document.createElement('div'); + badges.className = 'tc-badges'; + + const addBadge = (label, value) => { + if (value === undefined || value === null || value === '') return; + const b = document.createElement('span'); + b.className = 'tc-badge'; + b.textContent = `${label}: ${value}`; + badges.appendChild(b); + }; + + addBadge('id', tool?.id); + addBadge('user', tool?.user?.name ?? tool?.user_id); + addBadge('version', tool?.meta?.manifest?.version); + addBadge('license', tool?.meta?.manifest?.license ?? tool?.meta?.manifest?.licence); + addBadge('updated', formatEpoch(tool?.updated_at)); + addBadge('created', formatEpoch(tool?.created_at)); + + // manifest summary (primary) + const manifestWrap = document.createElement('div'); + manifestWrap.className = 'tc-section'; + + const manifestTitle = document.createElement('h2'); + manifestTitle.className = 'tc-section-title'; + manifestTitle.textContent = 'manifest'; + + const manifestList = document.createElement('ul'); + manifestList.className = 'tc-manifest'; + + /** + * @param {string} k + * @param {string} v + */ + const kv = (k, v) => { + if (v === undefined || v === null || v === '') return; + const li = document.createElement('li'); + li.className = 'tc-manifest-item'; + + const label = document.createElement('span'); + label.className = 'tc-manifest-label'; + label.textContent = k; + + li.appendChild(label); + + const valueEl = document.createElement('span'); + valueEl.className = 'tc-manifest-value'; + valueEl.textContent = typeof v === 'string' ? v : JSON.stringify(v); + + try { + // add https to all of them + const u = 'https://' + v.replace(/https?:\/\//, ''); + new URL(u); + if (!(/https:\/\/.*\..*/.test(u))) throw ""; + + const uEl = document.createElement('a'); + + uEl.href = u; + uEl.target = '_blank'; + + uEl.appendChild(valueEl); + li.appendChild(uEl); + } catch { + li.appendChild(valueEl); + } + + manifestList.appendChild(li); + }; + + const mani = tool?.meta?.manifest ?? {}; + kv('title', mani.title); + kv('author', mani.author); + kv('author url', mani.author_url ?? mani.author_urls); + kv('email', mani.email); + kv('github', mani.github); + kv('requirements', mani.requirements); + kv('required webui', mani.required_open_webui_version); + kv('funding url', mani.funding_url); + kv('date', mani.date); + + manifestWrap.appendChild(manifestTitle); + manifestWrap.appendChild(manifestList); + + // advanced details (disclosure) + const adv = document.createElement('details'); + adv.className = 'tc-disclosure'; + adv.setAttribute('data-testid', 'tool-card-advanced'); + + const advSum = document.createElement('summary'); + advSum.className = 'tc-disclosure-summary'; + advSum.textContent = 'advanced details'; + adv.appendChild(advSum); + + const advBody = document.createElement('div'); + advBody.className = 'tc-disclosure-body'; + + // details table + const table = document.createElement('table'); + table.className = 'tc-table'; + const tbody = document.createElement('tbody'); + + const rows = [ + ['tool id', tool?.id], + ['user id', tool?.user_id], + ['user role', tool?.user?.role], + ['email', tool?.user?.email], + ['access control', safeText(Object.keys(tool?.access_control ?? {}).length ? JSON.stringify(tool.access_control) : '—')], + ['updated at', formatEpoch(tool?.updated_at)], + ['created at', formatEpoch(tool?.created_at)] + ]; + + rows.forEach(([k, v]) => { + if (v === undefined || v === null) return; + const tr = document.createElement('tr'); + const th = document.createElement('th'); th.textContent = k; + const td = document.createElement('td'); td.textContent = String(v); + tr.appendChild(th); tr.appendChild(td); tbody.appendChild(tr); + }); + + table.appendChild(tbody); + + // raw json (nested disclosure) + const rawWrap = document.createElement('details'); + rawWrap.className = 'tc-disclosure tc-disclosure--nested'; + + const rawSum = document.createElement('summary'); + rawSum.className = 'tc-disclosure-summary'; + rawSum.textContent = 'raw tool json'; + + const pre = document.createElement('pre'); + pre.className = 'tc-pre'; + pre.textContent = prettyJson(tool); + + rawWrap.appendChild(rawSum); + rawWrap.appendChild(pre); + + // assemble + advBody.appendChild(table); + advBody.appendChild(rawWrap); + adv.appendChild(advBody); + + body.appendChild(desc); + body.appendChild(badges); + body.appendChild(manifestWrap); + body.appendChild(adv); + + dialog.appendChild(header); + dialog.appendChild(body); + overlay.appendChild(dialog); + mount.appendChild(overlay); + + // focus management per apg recommendations: trap focus inside the dialog, close on esc + const opener = options.trigger instanceof HTMLElement ? options.trigger : /** @type {HTMLElement|null} */(document.activeElement); + + const focusableSelectors = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])' + ].join(','); + + const findFocusable = () => /** @type {HTMLElement[]} */(Array.from(dialog.querySelectorAll(focusableSelectors))), + focusFirst = () => { + const items = findFocusable(); + const target = items[0] || closeBtn; + target.focus(); + }; + + const onKeydown = (e) => { + if (e.key === 'Tab') { + const items = findFocusable(); + if (items.length === 0) { e.preventDefault(); closeBtn.focus(); return; } + const first = items[0], + last = items[items.length - 1], + active = /** @type {HTMLElement} */(document.activeElement); + if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); } + else if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); } + } + if (e.key === 'Escape') { e.preventDefault(); close(); } + }; + + const onOverlayClick = (e) => { if (e.target === overlay) close(); }; + + overlay.addEventListener('click', onOverlayClick); + document.addEventListener('keydown', onKeydown); + closeBtn.addEventListener('click', () => close()); + + // prevent background scroll while open + const prevOverflow = document.documentElement.style.overflow; + document.documentElement.style.overflow = 'hidden'; + + // open + setTimeout(focusFirst, 0); + + function close() { + overlay.removeEventListener('click', onOverlayClick); + document.removeEventListener('keydown', onKeydown); + document.documentElement.style.overflow = prevOverflow; + overlay.remove(); + if (opener && typeof opener.focus === 'function') opener.focus(); + } + + return { close, root: overlay }; +} + +// helpers +function renderToolList(tools) { + const container = document.querySelector('#tools-select'), + hiddenInput = document.querySelector('#tools-select-input'); + if (!container) return console.warn('failed to find tools selection container'); + + container.innerHTML = ''; + container.setAttribute('data-has-tools', String(Boolean(tools?.length))); + + const initialSelectionRaw = hiddenInput?.value ? hiddenInput.value.split(',') : [], + initialSelection = new Set(initialSelectionRaw.map(v => v.trim()).filter(Boolean)); + + if (!Array.isArray(tools) || tools.length === 0) { + const empty = document.createElement('p'); + empty.className = 'tool-pill-empty muted'; + empty.textContent = 'no tools available'; + container.appendChild(empty); + if (hiddenInput) { + hiddenInput.value = ''; + hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); + } + return; + } + + const selection = new Set(initialSelection), + validIds = new Set(); + + const syncHidden = () => { + if (!hiddenInput) return; + const ordered = Array.from(selection).filter(id => validIds.has(id)); + hiddenInput.value = ordered.join(','); + hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); + hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); + }; + + const toggleSelection = (pill, toggleBtn, id) => { + const isSelected = selection.has(id); + if (isSelected) selection.delete(id); + else selection.add(id); + + const nowSelected = selection.has(id); + pill.classList.toggle('tool-pill--selected', nowSelected); + pill.setAttribute('aria-selected', String(nowSelected)); + toggleBtn.setAttribute('aria-pressed', String(nowSelected)); + syncHidden(); + }; + + tools.forEach(tool => { + const id = String(tool?.id ?? ''); + if (!id) return; + validIds.add(id); + const label = makeToolLabel(tool, tools); + + const pill = document.createElement('div'); + pill.className = 'tool-pill'; + pill.dataset.toolId = id; + pill.setAttribute('role', 'option'); + pill.setAttribute('tabindex', '-1'); + + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'tool-pill-toggle'; + toggleBtn.textContent = label; + toggleBtn.setAttribute('aria-pressed', String(selection.has(id))); + toggleBtn.addEventListener('click', () => toggleSelection(pill, toggleBtn, id)); + + const infoBtn = document.createElement('button'); + infoBtn.type = 'button'; + infoBtn.className = 'tool-pill-info'; + infoBtn.setAttribute('aria-label', `Show info for ${label}`); + infoBtn.innerHTML = ''; + infoBtn.addEventListener('click', (event) => { + event.stopPropagation(); + generateToolCard(tool, { trigger: infoBtn }); + }); + + const isSelected = selection.has(id); + pill.classList.toggle('tool-pill--selected', isSelected); + pill.setAttribute('aria-selected', String(isSelected)); + + pill.appendChild(toggleBtn); + pill.appendChild(infoBtn); + container.appendChild(pill); + }); + + // drop any selections for tools that no longer exist + Array.from(selection).forEach(id => { + if (!validIds.has(id)) selection.delete(id); + }); + + syncHidden(); +} + +const makeToolLabel = (t, tools) => { + const base = t?.name ?? t?.id ?? 'tool'; + const duplicates = tools.filter(x => (x?.name ?? x?.id) === (t?.name ?? t?.id)); + if (duplicates.length > 1) { + const hint = t?.meta?.manifest?.version ?? t?.user?.name ?? t?.id?.slice?.(0, 8); + return `${base} • ${hint}`; + } + return base; +} diff --git a/scheduler/server.mjs b/scheduler/server.mjs deleted file mode 100644 index dd4d233..0000000 --- a/scheduler/server.mjs +++ /dev/null @@ -1,305 +0,0 @@ -import http from 'http' -import fs from 'fs' -import path from 'path' -import { fileURLToPath } from 'url' -import Docker from 'dockerode' -import cron from 'node-cron' - -const LABEL_USER_KEY = 'openwebui.user-id', - ANNO_DISPLAY_NAME = 'openwebui/display-name' - -const __filename = fileURLToPath(import.meta.url), - __dirname = path.dirname(__filename), - - // folders/files - DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), - SCHEDULES_FILE = path.join(DATA_DIR, 'schedules.json'), - TEMPLATES_FILE = process.env.TEMPLATES_FILE || path.join(__dirname, 'templates.json'), - - // defaults - DEFAULT_TZ = 'America/New_York', - PORT = Number(process.env.PORT) || 12253 - -// connect to docker (via local socket by default) -const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' }) - -// in-memory schedule registry -const tasks = new Map() // key -> { task, def } - -const readBodyJson = (req) => new Promise((resolve, reject) => { - let d = ''; req.on('data', c => d += c) - req.on('end', () => { try { resolve(JSON.parse(d || '{}')) } catch (e) { reject(e) } }) - req.on('error', reject) -}), - ensureDir = (p) => { try { fs.mkdirSync(p, { recursive: true }) } catch { } }, - readJsonFile = (p, fallback = null) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return fallback } }, - writeJsonFile = (p, obj) => fs.writeFileSync(p, JSON.stringify(obj, null, 2)) - -// build cron string from an iso timestamp in a timezone (same as your original) -const cronFromISO = (iso, tz = DEFAULT_TZ) => { - const dt = new Date(iso), - parts = new Intl.DateTimeFormat('en-US', { - timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric', - hour: 'numeric', minute: '2-digit', hour12: false - }).formatToParts(dt).reduce((a, p) => (a[p.type] = p.value, a), {}) - const m = Number(parts.month), d = Number(parts.day), h = Number(parts.hour), min = Number(parts.minute) - return `${min} ${h} ${d} ${m} *` -} - -// derive a docker-safe, user-scoped name and preserve a human display name -const scopedName = (name, userId) => { - const base = String(name).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40), - suffix = String(userId).toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 8) || 'anon' - return `${base}--u-${suffix}` -} - -// ensure we have a user id header -const requireUserId = (req) => { - const userId = String(req.headers['x-user-id'] || '').trim() - if (!userId) throw Object.assign(new Error('missing x-user-id header'), { status: 401 }) - return userId -} - -// load templates (maps "name" -> { image, command?, args?, env? }) -const loadTemplates = () => readJsonFile(TEMPLATES_FILE, { items: [] }).items || [] -const findTemplate = (name) => loadTemplates().find(t => t.name === name) - -// create/start a container for a template (run once) -async function runContainer({ displayName, template, parameters = {}, userId, entrypoint }) { - if (!template?.name) throw Object.assign(new Error('missing template.name'), { status: 400 }) - const t = findTemplate(template.name) - if (!t) throw Object.assign(new Error(`unknown template: ${template.name}`), { status: 404 }) - - // env: pass user + params as env vars (simple & portable) - const env = [ - `USER_ID=${userId}`, - `DISPLAY_NAME=${displayName}`, - ...Object.entries(parameters).map(([k, v]) => `PARAM_${String(k).toUpperCase()}=${String(v)}`) - ] - - // image pull if absent then create+start (mirrors docker run flow) - // ref: docker engine api sequence: create -> pull if 404 -> create -> start - // https://docs.docker.com/reference/api/engine/version/v1.24/ (section 4.1) - try { - await docker.getImage(t.image).inspect() - } catch { - await new Promise((resolve, reject) => { - docker.pull(t.image, (err, stream) => { - if (err) return reject(err) - docker.modem.followProgress(stream, (err2) => err2 ? reject(err2) : resolve()) - }) - }) - } - - const nameActual = `${scopedName(displayName || t.name, userId)}-${Math.random().toString(36).slice(2, 8)}` - - // build container create options - const createOpts = { - Image: t.image, - // optional explicit entrypoint/cmd wiring - Entrypoint: entrypoint ? [entrypoint] : (t.entrypoint ? [].concat(t.entrypoint) : undefined), - Cmd: t.command ? [].concat(t.command, t.args || []) : (t.args ? [].concat(t.args) : undefined), - Env: env, - Labels: { - [LABEL_USER_KEY]: userId, - [ANNO_DISPLAY_NAME]: displayName || t.name - }, - HostConfig: { - AutoRemove: true - }, - name: nameActual - } - - const container = await docker.createContainer(createOpts) // create - await container.start() // start - return { id: container.id, name: nameActual } -} - -// persistence of schedules (for restart durability) -ensureDir(DATA_DIR) -const persist = () => { - const defs = [...tasks.values()].map(v => v.def) - writeJsonFile(SCHEDULES_FILE, { items: defs }) -} -const restore = () => { - const saved = readJsonFile(SCHEDULES_FILE, { items: [] }).items || [] - for (const def of saved) scheduleOrReplace(def) -} - -// schedule management (create/update) -function scheduleOrReplace(def) { - // stop existing - const key = def.name - if (tasks.has(key)) { try { tasks.get(key).task.stop() } catch { } tasks.delete(key) } - - // forbid overlapping runs per schedule (like argo's Forbid) - let running = false - const task = cron.schedule(def.schedule, async () => { - if (running) return - running = true - try { - await runContainer({ - displayName: def.displayName, - template: def.template, - parameters: def.parameters, - userId: def.userId, - entrypoint: def.entrypoint - }) - // one-shot: stop after first success - if (def.oneShot) { - try { task.stop() } catch { } - tasks.delete(key) - persist() - } - } catch (e) { - // you could log or collect errors here - } finally { - running = false - } - }, { timezone: def.timezone || DEFAULT_TZ }) - - tasks.set(key, { task, def }) - task.start() - persist() -} - -// convert input to schedule def -const toScheduleDef = ({ name, when, tz = DEFAULT_TZ, oneShot = false, template = { name: '' }, parameters = {}, entrypoint, userId }) => { - const schedule = when?.cron ?? cronFromISO(when?.iso, tz) - return { - name: scopedName(name, userId), - displayName: name, - userId, - timezone: tz, - schedule, - oneShot: Boolean(oneShot), - template, - parameters, - entrypoint - } -} - -const publicDir = path.join(__dirname, 'public'); - -function reqToURL(req) { - let pathname = '/'; - try { - pathname = new URL(req.url, `http://${req.headers.host || 'localhost'}`).pathname; - } catch { - pathname = req.url || '/'; - } - - return pathname; -} - -// http server -const server = http.createServer(async (req, res) => { - try { - // very light cors - const origin = req.headers.origin || '*' - res.setHeader('access-control-allow-origin', origin) - res.setHeader('vary', 'origin') - res.setHeader('access-control-allow-headers', 'content-type, x-user-id') - res.setHeader('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS') - if (req.method === 'OPTIONS') return res.writeHead(204).end() - - const pathname = reqToURL(req), - allowed = ['/', '/index.html', '/script.js', '/style.css']; - - - //#region GET requests - - // list schedules for the calling user - if (req.method === 'GET' && pathname === '/api/schedules') { - const userId = requireUserId(req), - items = [...tasks.values()] - .map(v => v.def) - .filter(d => d.userId === userId) - .map(d => ({ - name: d.name, - displayName: d.displayName, - userId: d.userId, - timezone: d.timezone, - schedules: [d.schedule], - oneShot: d.oneShot, - templateRef: d.template, - entrypoint: d.entrypoint - })); - - return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items })) - } - - // list "workflow templates" => just expose templates.json names - if (req.method === 'GET' && pathname === '/api/workflowtemplates') { - const items = loadTemplates().map(t => ({ name: t.name })) - return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items })) - } - - if (req.method === 'GET' && allowed.includes(pathname)) { - try { - const fileName = pathname === '/' ? 'index.html' : pathname.slice(1), - filePath = path.join(publicDir, fileName), - ext = path.extname(fileName).toLowerCase(), - type = ext === '.js' ? 'application/javascript' : ext === '.css' ? 'text/css' : 'text/html; charset=utf-8', - content = fs.readFileSync(filePath, 'utf8') - return res.writeHead(200, { 'content-type': type }).end(content); - } catch { - return res.writeHead(404).end('ui not found'); - } - - } - - // DO NOT PUT ANY GET REQUESTS BELOW THIS LINE, THEY WILL FAIL - - //#endregion - - // create/update a user-scoped schedule - if (req.method === 'POST' && pathname === '/schedules') { - const userId = requireUserId(req), - input = await readBodyJson(req), - def = toScheduleDef({ ...input, userId }) - scheduleOrReplace(def) - return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true })) - } - - // run a job now for the calling user (no schedule) - if (req.method === 'POST' && pathname === '/run-now') { - const userId = requireUserId(req), - input = await readBodyJson(req) - await runContainer({ - displayName: input.name, - template: input.template, - parameters: input.parameters || {}, - userId, - entrypoint: input.entrypoint - }) - return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true })) - } - - // delete a schedule owned by the calling user - if (req.method === 'DELETE' && pathname.startsWith('/schedules/')) { - const userId = requireUserId(req), - nameParam = decodeURIComponent(pathname.split('/').pop()), - // stored names are already scoped; accept either raw or scoped - key = tasks.has(nameParam) ? nameParam : scopedName(nameParam, userId), - existing = tasks.get(key)?.def - - if (!existing) return res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'not found' })) - if (existing.userId !== userId) return res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'forbidden: schedule not owned by this user' })) - - try { tasks.get(key).task.stop() } catch { } - tasks.delete(key) - persist() - return res.writeHead(204).end() - } - - res.writeHead(404).end('not found') - } catch (e) { - const code = Number(e.status) || 500 - res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: e.message || String(e) })) - } -}) - -// boot -restore() -server.listen(PORT, "0.0.0.0", () => console.log(`schedules api listening on ${server.address().address}:${PORT}`)); diff --git a/scheduler/server.ts b/scheduler/server.ts new file mode 100644 index 0000000..d86bb8f --- /dev/null +++ b/scheduler/server.ts @@ -0,0 +1,636 @@ +import http, { IncomingMessage } from 'http' +import fs from 'fs' +import path from 'path' +import crypto from 'node:crypto' +import { fileURLToPath } from 'url' +// import Docker from 'dockerode' +import cron from 'node-cron' +import loginUser, { getUser } from './helpers/resolve-user' +import { authCall, callNewChat, ollamaInp, schedInp } from './helpers/ollamaCalls' + +const DEFAULT_TZ = "America/New_York" + +const openApiSpec = { + openapi: '3.0.1', + info: { + title: 'Scheduler API', + description: 'API for managing scheduled prompts. Cron expressions run in America/New_York (EST).', + version: '1.0.0' + }, + servers: [ + { + url: '/', + description: 'Current server' + } + ], + paths: { + '/api/schedules': { + get: { + summary: 'List schedules', + security: [{ BearerAuth: [] }], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean', example: true }, + items: { + type: 'array', + items: { $ref: '#/components/schemas/Schedule' } + } + } + } + } + } + }, + 401: { $ref: '#/components/responses/Unauthorized' } + } + }, + post: { + summary: 'Create or replace a schedule', + security: [{ BearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ScheduleInput' } + } + } + }, + responses: { + 201: { + description: 'Schedule created or updated', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean', example: true } + } + } + } + } + }, + 400: { $ref: '#/components/responses/BadRequest' }, + 401: { $ref: '#/components/responses/Unauthorized' } + } + } + }, + '/api/schedules/{name}': { + delete: { + summary: 'Delete a schedule', + security: [{ BearerAuth: [] }], + parameters: [ + { + name: 'name', + in: 'path', + description: 'Schedule name (raw or scoped)', + required: true, + schema: { type: 'string' } + } + ], + responses: { + 204: { description: 'Schedule deleted' }, + 401: { $ref: '#/components/responses/Unauthorized' }, + 403: { $ref: '#/components/responses/Forbidden' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + } + }, + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT' + } + }, + schemas: { + Schedule: { + type: 'object', + properties: { + name: { type: 'string', description: 'Scoped identifier used internally' }, + displayName: { type: 'string' }, + userId: { type: 'string' }, + schedules: { + type: 'array', + items: { type: 'string', description: 'Cron expression' } + }, + startAt: { type: 'string', format: 'date-time', nullable: true }, + oneShot: { type: 'boolean' }, + templateRef: { $ref: '#/components/schemas/TemplateRef' }, + prompt: { type: 'string' }, + model: { type: 'string' }, + tools: { type: 'array', items: { type: 'string' } }, + features: { + type: 'object', + additionalProperties: { type: 'boolean' } + }, + files: { + type: 'array', + items: { + type: 'object', + properties: { + fname: { type: 'string' }, + fkey: { type: 'string' } + } + } + } + }, + required: ['name', 'userId', 'schedules', 'oneShot'] + }, + ScheduleInput: { + type: 'object', + properties: { + name: { type: 'string' }, + when: { $ref: '#/components/schemas/ScheduleWhen' }, + oneShot: { type: 'boolean', default: false }, + template: { $ref: '#/components/schemas/TemplateInput' }, + parameters: { + type: 'object', + additionalProperties: true + }, + files: { + type: 'array', + items: { + type: 'object', + properties: { + fname: { type: 'string' }, + fkey: { + type: 'string', + description: 'Optional previously stored file reference' + }, + content: { + type: 'string', + description: 'Base64 encoded file contents' + } + }, + required: ['fname'] + } + }, + prompt: { type: 'string' }, + model: { type: 'string' }, + tools: { type: 'array', items: { type: 'string' } }, + features: { + type: 'object', + additionalProperties: { type: 'boolean' } + } + }, + required: ['name', 'when', 'template', 'parameters', 'prompt', 'model', 'tools'] + }, + ScheduleWhen: { + type: 'object', + properties: { + cron: { + type: 'string', + description: '5-field cron expression evaluated in America/New_York' + }, + start: { + type: 'string', + format: 'date-time', + description: 'Optional start gate; required when oneShot is true' + } + }, + required: [] + }, + TemplateInput: { + type: 'object', + properties: { + name: { type: 'string' }, + clusterScope: { type: 'boolean', default: false } + }, + required: ['name'] + }, + TemplateRef: { + type: 'object', + properties: { + name: { type: 'string' }, + clusterScope: { type: 'boolean' } + }, + required: ['name'] + } + }, + responses: { + Unauthorized: { + description: 'Missing or invalid credentials' + }, + BadRequest: { + description: 'Invalid request payload', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean', example: false }, + error: { type: 'string' } + } + } + } + } + }, + Forbidden: { + description: 'Schedule exists but belongs to another user' + }, + NotFound: { + description: 'Schedule not found' + } + } + } +} as const; + +const __filename = fileURLToPath(import.meta.url), + __dirname = path.dirname(__filename), + + // folders/files + DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), + SCHEDULES_FILE = path.join(DATA_DIR, 'schedules.json'), + TEMPLATES_FILE = process.env.TEMPLATES_FILE || path.join(__dirname, 'templates.json'), + FILES_DIR_PREFERRED = process.env.FILES_DIR || '/app/data/files', + FILES_DIR_FALLBACK = path.join(DATA_DIR, 'files'); + +let FILES_DIR = FILES_DIR_PREFERRED; + + // defaults + PORT = Number(process.env.PORT) || 12253 + +// connect to docker (via local socket by default) +// const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' }) + +// in-memory schedule registry +const tasks = new Map() // key -> { task, def } + +export const readBodyJson = (req: http.IncomingMessage): Promise => new Promise((resolve, reject) => { + let d = ''; req.on('data', c => d += c) + req.on('end', () => { try { resolve(JSON.parse(d || '{}')) } catch (e) { reject(e) } }) + req.on('error', reject) +}); + +const ensureDir = (p: string) => { try { fs.mkdirSync(p, { recursive: true }) } catch { } }, + readJsonFile = (p: string, fallback: any = null) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return fallback } }, + writeJsonFile = (p: string, obj: object) => fs.writeFileSync(p, JSON.stringify(obj, null, 2)) + +// build cron string from an iso timestamp (treated as America/New_York) +const cronFromISO = (iso?: string) => { + if (!iso) return; + const dt = new Date(iso); + if (Number.isNaN(dt.getTime())) return; + const parts: { year: number, month: number, day: number, hour: number, minute: number } = new Intl.DateTimeFormat('en-US', { + timeZone: DEFAULT_TZ, year: 'numeric', month: 'numeric', day: 'numeric', + hour: 'numeric', minute: '2-digit', hour12: false + }).formatToParts(dt).reduce((a, p) => (a[p.type] = Number(p.value), a), {} as any); + + return `${parts.minute} ${parts.hour} ${parts.day} ${parts.month} *`; +} + +const normalizeFeatures = (value: unknown): Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries(Object.entries(value as Record).map(([k, v]) => [k, Boolean(v)])); +}; + +type IncomingFile = { fname?: string, fkey?: string, content?: string }; + +const sanitizeBase64 = (raw?: string) => typeof raw === 'string' ? raw.replace(/^data:[^;]+;base64,/, '') : ''; + +const prepareIncomingFiles = (value: unknown): { fname: string, fkey: string }[] | string => { + if (!value) return []; + if (!Array.isArray(value)) return 'files must be an array'; + + const saved: { fname: string, fkey: string }[] = []; + for (const entry of value as IncomingFile[]) { + if (!entry || typeof entry !== 'object') return 'invalid file entry'; + const fname = typeof entry.fname === 'string' ? entry.fname.trim() : ''; + if (!fname) return 'file entries require fname'; + + if (entry.content && typeof entry.content === 'string') { + const b64 = sanitizeBase64(entry.content); + if (!b64) return `file ${fname} is missing content`; + const fkey = crypto.randomUUID(); + const target = path.join(FILES_DIR, fkey); + try { + fs.writeFileSync(target, Buffer.from(b64, 'base64')); + } catch (err) { + console.error('failed to store file', fname, err); + return `failed to store file ${fname}`; + } + saved.push({ fname, fkey }); + continue; + } + + if (entry.fkey && typeof entry.fkey === 'string' && entry.fkey.trim()) { + saved.push({ fname, fkey: entry.fkey.trim() }); + continue; + } + + return `file ${fname} is missing content or fkey`; + } + + return saved; +}; + +// derive a docker-safe, user-scoped name and preserve a human display name +const scopedName = (name: string, userId: string) => { + const base = String(name).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40), + suffix = String(userId).toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 8) || 'anon'; + + return `${base}--u-${suffix}`; +} + +const fetchUserID = async (req: IncomingMessage) => { + const authheader = String(req.headers['authorization'] || '').trim(); + if (!authheader) throw Object.assign(new Error('Missing authorization header'), { status: 401 }); + if (!authheader.startsWith('Bearer')) throw Object.assign(new Error('Invalid token'), { status: 401 }); + + try { + const token = authheader.split(' ')[1], + user = await authCall(token); + return user.id; + } + catch { + throw Object.assign(new Error('Invalid token'), { status: 401 }); + } +} + +// load templates (maps "name" -> { image, command?, args?, env? }) +const loadTemplates = () => readJsonFile(TEMPLATES_FILE, { items: [] }).items || []; + +// persistence of schedules (for restart durability) +ensureDir(DATA_DIR) +ensureDir(FILES_DIR) +if (!fs.existsSync(FILES_DIR)) { + FILES_DIR = FILES_DIR_FALLBACK; + ensureDir(FILES_DIR); +} +const persist = () => { + const defs = [...tasks.values()].map(v => v.def) + writeJsonFile(SCHEDULES_FILE, { items: defs }) +}; + +const restore = () => { + const saved = readJsonFile(SCHEDULES_FILE, { items: [] }).items || []; + for (const def of saved) scheduleOrReplace(def); +}; + +// schedule management (create/update) +function scheduleOrReplace(defInput: ollamaInp) { + const def: ollamaInp = { ...defInput, features: normalizeFeatures(defInput?.features) }; + + // stop existing + const key = def.name + if (tasks.has(key)) { try { tasks.get(key).task.stop() } catch { } tasks.delete(key) } + + const startTs = def.startAt ? new Date(def.startAt).getTime() : null; + + // forbid overlapping runs per schedule (like argo's Forbid) + let running = false + const task = cron.schedule(def.schedule, async () => { + if (running) return; + if (startTs && Date.now() < startTs) return; + + running = true + try { + await callNewChat(def); + + // one-shot: stop after first success + if (def.oneShot) { + try { task.stop() } catch { } + tasks.delete(key) + persist() + } + } catch (err) { + // TODO: collect errors here + console.error(err); + + // this failed, stop the task now + try { task.stop() } catch { } + try { tasks.delete(key) } catch { } + persist() + } finally { + running = false + } + }, { timezone: DEFAULT_TZ }) + + tasks.set(key, { task, def }) + task.start() + persist() +} + +// also does validation +const toScheduleDef = ({ name, when, oneShot = false, template = { name: '' }, parameters = {}, prompt, userId, model, tools, features = {}, files = [] }: schedInp): ollamaInp | string => { + const normalizedFeatures = normalizeFeatures(features); + const startAt = when.start?.trim() || ''; + const hasStartAt = Boolean(startAt); + const processedFiles = prepareIncomingFiles(files); + if (typeof processedFiles === 'string') return processedFiles; + + if (oneShot) { + if (!hasStartAt) return "One-shot schedules require a start date"; + const schedule = cronFromISO(startAt); + if (!schedule) return "Invalid start date"; + const startDate = new Date(startAt); + if (startDate.getTime() < Date.now()) return "Date can not be in the past"; + return { + name: scopedName(name, userId), + displayName: name, + userId, + schedule, + startAt, + oneShot: true, + template, + parameters, + prompt, + model, + tools, + features: normalizedFeatures, + cookie: '', + files: processedFiles + }; + } + + const schedule = when.cron?.trim(); + if (!schedule) return "Cron expression is required"; + + if (hasStartAt) { + const startDate = new Date(startAt); + if (Number.isNaN(startDate.getTime())) return "Invalid start date"; + if (startDate.getTime() < Date.now()) return "Start date can not be in the past"; + } + + return { + name: scopedName(name, userId), + displayName: name, + userId, + schedule, + startAt: hasStartAt ? startAt : undefined, + oneShot: false, + template, + parameters, + prompt, + model, + tools, + features: normalizedFeatures, + cookie: '', + files: processedFiles + }; +} + +const publicDir = path.join(__dirname, 'public'); + +function reqToURL(req: IncomingMessage) { + let pathname = '/'; + try { + pathname = new URL(req.url!, `http://${req.headers.host || 'localhost'}`).pathname; + } catch { + pathname = req.url || '/'; + } + + return pathname; +} + +// http server +const server = http.createServer(async (req, res) => { + try { + // very light cors + const origin = req.headers.origin || '*' + res.setHeader('access-control-allow-origin', origin) + res.setHeader('vary', 'origin') + res.setHeader('access-control-allow-headers', 'content-type, x-user-id') + res.setHeader('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS') + if (req.method === 'OPTIONS') return res.writeHead(204).end() + + const pathname = reqToURL(req), + allowed = ['/', ...fs.readdirSync(publicDir).map(f => `/${f}`)]; + + if (req.method === 'GET' && pathname === '/openapi.json') { + return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(openApiSpec, null, 2)); + } + + // list schedules for the calling user + if (req.method === 'GET' && pathname === '/api/schedules') { + const userId = await fetchUserID(req), + items = [...tasks.values()] + .map(v => v.def) + .filter(d => d.userId === userId) + .map(d => ({ + name: d.name, + displayName: d.displayName, + userId: d.userId, + schedules: [d.schedule], + startAt: d.startAt, + oneShot: d.oneShot, + templateRef: d.template, + prompt: d.prompt, + model: d.model, + tools: d.tools, + features: d.features, + files: d.files + })); + + return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items })) + } + + // list "workflow templates" => just expose templates.json names + if (req.method === 'GET' && pathname === '/api/workflowtemplates') { + const items = loadTemplates().map((t: { name: string }) => ({ name: t.name })) + return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items })) + } + + if (req.method === 'GET' && allowed.includes(pathname)) { + try { + const fileName = pathname === '/' ? 'index.html' : pathname.slice(1), + filePath = path.join(publicDir, fileName), + ext = path.extname(fileName).toLowerCase(), + type = ext === '.js' ? 'application/javascript' : ext === '.css' ? 'text/css' : 'text/html; charset=utf-8', + content = fs.readFileSync(filePath, 'utf8') + return res.writeHead(200, { 'content-type': type }).end(content); + } catch { + return res.writeHead(404).end('ui not found'); + } + } + + + // create/update a user-scoped schedule + if (req.method === 'POST' && pathname === '/api/schedules') { + const userId = await fetchUserID(req), + input = await readBodyJson(req), + def = toScheduleDef({ ...input, userId }); + + if (typeof def === 'string') { + return res.writeHead(400, { 'content-type': 'application/json' }) + .end(JSON.stringify({ error: def, ok: false })); + } + + def.cookie = req.headers['authorization'].split(' ')[1]; + scheduleOrReplace(def); + return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true })); + } + + if (req.method === 'POST' && pathname.startsWith('/login')) { + return await loginUser(req, res).catch((err) => { + console.error(err); + res.writeHead(500).end(); + }); + } + + if (req.method === 'GET' && pathname.startsWith('/api/me')) { + return await getUser(req, res).catch((err) => { + console.error(err); + res.writeHead(500).end(); + }); + } + + // delete a schedule owned by the calling user + if (req.method === 'DELETE' && pathname.startsWith('/api/schedules/')) { + const paramRaw = pathname.split('/').pop(); + if (!paramRaw) { + return res.writeHead(400).end({ error: "missing schedule ID" }); + } + + const userId = await fetchUserID(req), + nameParam = decodeURIComponent(paramRaw), + // stored names are already scoped; accept either raw or scoped + key = tasks.has(nameParam) ? nameParam : scopedName(nameParam, userId), + existing = tasks.get(key)?.def; + + if (!existing) { + return res.writeHead(404, { + 'content-type': 'application/json' + }).end(JSON.stringify({ + ok: false, + error: 'not found' + })); + } + + if (existing.userId !== userId) { + return res.writeHead(403, { + 'content-type': 'application/json' + }).end(JSON.stringify({ + ok: false, + error: 'forbidden: schedule not owned by this user' + })); + } + + try { tasks.get(key).task.stop(); } catch { } + tasks.delete(key); + persist(); + + return res.writeHead(204).end(); + } + + res.writeHead(404).end('not found'); + } catch (e: any) { + console.error(e); + const code = Number(e.status) || 500 + res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: e.message || String(e) })) + } +}) + +// boot +restore(); +server.listen(PORT, "0.0.0.0", () => { + const addr = server!.address(); + console.log(`schedules api listening on ${typeof addr === 'string' ? addr : addr?.address}:${PORT}`) +}); diff --git a/tmp/pods.txt b/tmp/pods.txt deleted file mode 100644 index a8434fe..0000000 --- a/tmp/pods.txt +++ /dev/null @@ -1,22 +0,0 @@ -NAMESPACE NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES -argocd argocd-application-controller-0 1/1 Running 0 52s 10.244.0.142 minikube -argocd argocd-applicationset-controller-54f96997f8-g2gzf 1/1 Running 0 52s 10.244.0.170 minikube -argocd argocd-dex-server-798cbff4c7-n4crz 1/1 Running 0 52s 10.244.0.42 minikube -argocd argocd-notifications-controller-644f66f7df-xcxxl 1/1 Running 0 52s 10.244.0.86 minikube -argocd argocd-redis-6684c6947f-9c4ps 1/1 Running 0 52s 10.244.0.190 minikube -argocd argocd-repo-server-6fccc5759b-gp4js 1/1 Running 0 52s 10.244.0.169 minikube -argocd argocd-server-64d5fcbd58-p99wl 1/1 Running 0 52s 10.244.0.75 minikube -ingress-nginx ingress-nginx-admission-create-xwcms 0/1 Completed 2 2m22s 10.244.0.125 minikube -ingress-nginx ingress-nginx-admission-patch-v46s6 0/1 Completed 2 2m22s 10.244.0.133 minikube -ingress-nginx ingress-nginx-controller-67c5cb88f-9zlz7 1/1 Running 0 2m22s 10.244.0.201 minikube -kube-system cilium-6g8th 1/1 Running 0 2m23s 192.168.49.2 minikube -kube-system cilium-envoy-vz775 1/1 Running 0 2m23s 192.168.49.2 minikube -kube-system cilium-operator-5876b8b787-7225g 1/1 Running 0 2m22s 192.168.49.2 minikube -kube-system coredns-674b8bbfcf-s74qz 1/1 Running 0 101s 10.244.0.113 minikube -kube-system etcd-minikube 1/1 Running 0 2m27s 192.168.49.2 minikube -kube-system kube-apiserver-minikube 1/1 Running 0 2m27s 192.168.49.2 minikube -kube-system kube-controller-manager-minikube 1/1 Running 0 2m27s 192.168.49.2 minikube -kube-system kube-ingress-dns-minikube 1/1 Running 0 68s 192.168.49.2 minikube -kube-system kube-proxy-pmxbc 1/1 Running 0 2m23s 192.168.49.2 minikube -kube-system kube-scheduler-minikube 1/1 Running 0 2m28s 192.168.49.2 minikube -kube-system storage-provisioner 1/1 Running 1 (113s ago) 2m25s 192.168.49.2 minikube