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 = 'i';
+ 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 @@
-
-
+