diff --git a/.gitignore b/.gitignore index 00f68e676..d57ef064b 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,8 @@ results.*.json codegen-examples/examples/swebench_agent_run/results/* codegen-examples/examples/swebench_agent_run/predictions/* codegen-examples/examples/swebench_agent_run/logs/* + +# CodegenRestDashboard secrets +CodegenRestDashboard/.env +CodegenRestDashboard/mock/*.json-local + diff --git a/CodegenRestDashboard/.env.example b/CodegenRestDashboard/.env.example new file mode 100644 index 000000000..d933ad931 --- /dev/null +++ b/CodegenRestDashboard/.env.example @@ -0,0 +1,11 @@ +# Copy to .env and fill your values. Do NOT commit the .env file. +CODEGEN_API_BASE=https://api.codegen.com +CODEGEN_ORG_ID=323 +CODEGEN_TOKEN=sk-REPLACE_ME +# Optional: run in mock/offline mode (server serves fixtures; no network calls) +CODEGEN_OFFLINE=0 +# Optional: Webhook HMAC secret (if you enable signature verification) +CODEGEN_WEBHOOK_SECRET= +# Server port +PORT=8787 + diff --git a/CodegenRestDashboard/README.md b/CodegenRestDashboard/README.md new file mode 100644 index 000000000..31905bec4 --- /dev/null +++ b/CodegenRestDashboard/README.md @@ -0,0 +1,69 @@ +# CodegenRestDashboard (No-deps REST UI + Commands) + +This package adds a dependency-free dashboard and Node.js command scripts to interact with the Codegen REST API. + +Important: +- Do NOT commit real secrets. Create CodegenRestDashboard/.env locally (see .env.example) +- The browser UI never receives your API token; a tiny local proxy server injects auth headers +- Cloudflare Worker webhook is provided separately for production webhooks + +## Quick start + +1) Copy .env.example to .env and fill in your values +``` +cp CodegenRestDashboard/.env.example CodegenRestDashboard/.env +``` + +2) Start the local server (serves UI and proxies API) +``` +node CodegenRestDashboard/server.js +``` +Then open http://localhost:8787 + +3) Use commands (examples) +``` +node CodegenRestDashboard/commands/create_agent_run.js --prompt "Hello" --model "Sonnet 4.5" +node CodegenRestDashboard/commands/list_agent_runs.js --state active --limit 20 +node CodegenRestDashboard/commands/get_agent_run.js --id 123 +node CodegenRestDashboard/commands/resume_agent_run.js --id 123 --prompt "Continue" +node CodegenRestDashboard/commands/generate_setup_commands.js --repo_id 999 +``` + +4) Mock mode (no network) +``` +CODEGEN_OFFLINE=1 node CodegenRestDashboard/server.js +``` + +## Files +- commands/: Node CLI scripts, no external deps +- dashboard/: Vanilla HTML/CSS/JS UI +- utils/env.js: safe .env loader (Node-only) +- utils/apiClient.js: shared REST client for Node context +- server.js: local static server + API proxy (injects Authorization) +- webhook_server.js: Cloudflare Worker handler for /webhook +- mock/: local fixtures for offline development + +## Security +- .env is in .gitignore. Do not commit it. +- The token is used only in Node (server/commands). The browser never sees it. + +## Webhook (Cloudflare) +- Deploy CodegenRestDashboard/webhook_server.js as a Worker (route /webhook) +- Configure your DNS so https://www.pixelium.uk/webhook points to the Worker +- Optionally set CODEGEN_WEBHOOK_SECRET and verify HMAC in the worker + +## New features +- Auto-refresh only UI (no manual refresh button) +- Header shows only Active count (hover reveals top active runs) +- Compact run cards with status dots; click a completed run to open logs/resume dialog; click an active run’s “Chain” to configure multi-template chaining +- Per-run template selection and chaining (Templates tab manages templates) +- Follow-up automation sends templates in sequence on each completion cycle +- Desktop notifications (optional, via browser permission) + in-app toasts + +## Extra commands +``` +node CodegenRestDashboard/commands/get_agent_run_logs.js --id 123 --skip 0 --limit 100 +node CodegenRestDashboard/commands/ban_agent_run.js --id 123 [--before ] [--after ] +node CodegenRestDashboard/commands/unban_agent_run.js --id 123 [--before ] [--after ] +``` + diff --git a/CodegenRestDashboard/commands/ban_agent_run.js b/CodegenRestDashboard/commands/ban_agent_run.js new file mode 100644 index 000000000..abec4dafa --- /dev/null +++ b/CodegenRestDashboard/commands/ban_agent_run.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +const { apiPost } = require('../utils/apiClient'); +const { loadEnv } = require('../utils/env'); +const path = require('path'); +loadEnv(path.join(__dirname, '..', '.env')); + +function pathBan(org){ return `/v1/organizations/${org}/agent/run/ban`; } + +async function main(){ + const args = process.argv.slice(2); + let id; let before=null; let after=null; + for (let i=0;i [--before ] [--after ]'); process.exit(2); } + const org = process.env.CODEGEN_ORG_ID; + const body = { agent_run_id: id }; + if (before!==null) body.before_card_order_id = before; + if (after!==null) body.after_card_order_id = after; + const data = await apiPost(pathBan(org), body); + console.log(JSON.stringify(data, null, 2)); +} + +if (require.main===module){ main().catch(e=>{ console.error(e.message||e); process.exit(1); }); } +module.exports = main; + diff --git a/CodegenRestDashboard/commands/create_agent_run.js b/CodegenRestDashboard/commands/create_agent_run.js new file mode 100644 index 000000000..8a77f5d6a --- /dev/null +++ b/CodegenRestDashboard/commands/create_agent_run.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +const { apiPost, pathCreate } = require('../utils/apiClient'); + +const MODELS = [ + 'Sonnet 4.5', + 'GPT-5', + 'GPT 5 Codex', + 'Claude opus 4.5', + 'Grok 4', + 'Grok 4 Fast reasoning', + 'Grok Code Fast 1', +]; + +async function main() { + const args = process.argv.slice(2); + let prompt = ''; + let model = ''; + let repo_id = undefined; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--prompt') prompt = args[++i] || ''; + else if (args[i] === '--model') model = args[++i] || ''; + else if (args[i] === '--repo_id') repo_id = Number(args[++i]); + } + + if (!prompt) { + console.error('Usage: create_agent_run.js --prompt "..." [--model "Sonnet 4.5"|...] [--repo_id 123]'); + process.exit(2); + } + if (model && !MODELS.includes(model)) { + console.error(`Model must be one of: ${MODELS.join(', ')}`); + process.exit(2); + } + + const body = { prompt }; + if (model) body.model = model; + if (repo_id) body.repo_id = repo_id; + + const data = await apiPost(pathCreate(), body); + console.log(JSON.stringify(data, null, 2)); +} + +if (require.main === module) { + main().catch((e) => { console.error(e.message || e); process.exit(1); }); +} + +module.exports = main; + diff --git a/CodegenRestDashboard/commands/generate_setup_commands.js b/CodegenRestDashboard/commands/generate_setup_commands.js new file mode 100644 index 000000000..3c9c21051 --- /dev/null +++ b/CodegenRestDashboard/commands/generate_setup_commands.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +const { apiPost, pathGenerateSetup } = require('../utils/apiClient'); + +async function main() { + const args = process.argv.slice(2); + let repo_id = undefined; + let language = undefined; // optional + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--repo_id') repo_id = Number(args[++i]); + else if (args[i] === '--language') language = args[++i] || undefined; + } + + if (!repo_id) { + console.error('Usage: generate_setup_commands.js --repo_id 123 [--language node|python|...]'); + process.exit(2); + } + + const body = { repo_id }; + if (language) body.language = language; + + const data = await apiPost(pathGenerateSetup(), body); + console.log(JSON.stringify(data, null, 2)); +} + +if (require.main === module) { + main().catch((e) => { console.error(e.message || e); process.exit(1); }); +} + +module.exports = main; + diff --git a/CodegenRestDashboard/commands/get_agent_run.js b/CodegenRestDashboard/commands/get_agent_run.js new file mode 100644 index 000000000..3964ba833 --- /dev/null +++ b/CodegenRestDashboard/commands/get_agent_run.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +const { apiGet, pathGet, pathLogs } = require('../utils/apiClient'); + +async function main() { + const args = process.argv.slice(2); + let id = undefined; + let withLogs = false; + let skip = 0; + let limit = 50; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--id') id = Number(args[++i]); + else if (args[i] === '--logs') withLogs = true; + else if (args[i] === '--skip') skip = Number(args[++i]); + else if (args[i] === '--limit') limit = Number(args[++i]); + } + + if (!id) { + console.error('Usage: get_agent_run.js --id 123 [--logs] [--skip 0] [--limit 50]'); + process.exit(2); + } + + const res = await apiGet(pathGet(id)); + if (!withLogs) return console.log(JSON.stringify(res, null, 2)); + + const logs = await apiGet(pathLogs(id), { skip, limit }); + console.log(JSON.stringify({ run: res, logs }, null, 2)); +} + +if (require.main === module) { + main().catch((e) => { console.error(e.message || e); process.exit(1); }); +} + +module.exports = main; + diff --git a/CodegenRestDashboard/commands/get_agent_run_logs.js b/CodegenRestDashboard/commands/get_agent_run_logs.js new file mode 100644 index 000000000..d806b4e51 --- /dev/null +++ b/CodegenRestDashboard/commands/get_agent_run_logs.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node +const { apiGet, pathLogs } = require('../utils/apiClient'); + +async function main(){ + const args = process.argv.slice(2); + let id; let skip=0; let limit=100; + for (let i=0;i [--skip 0] [--limit 100]'); process.exit(2); } + const data = await apiGet(pathLogs(id), { skip, limit }); + console.log(JSON.stringify(data, null, 2)); +} + +if (require.main===module){ main().catch(e=>{ console.error(e.message||e); process.exit(1); }); } +module.exports = main; + diff --git a/CodegenRestDashboard/commands/index.js b/CodegenRestDashboard/commands/index.js new file mode 100644 index 000000000..86c4121ee --- /dev/null +++ b/CodegenRestDashboard/commands/index.js @@ -0,0 +1,15 @@ +#!/usr/bin/env node +const path = require('path'); +const { loadEnv } = require('../utils/env'); +loadEnv(path.join(__dirname, '..', '.env')); + +module.exports = { + create: require('./create_agent_run'), + resume: require('./resume_agent_run'), + list: require('./list_agent_runs'), + get: require('./get_agent_run'), + logs: require('./get_agent_run_logs'), + genSetup: require('./generate_setup_commands'), + ban: require('./ban_agent_run'), + unban: require('./unban_agent_run'), +}; diff --git a/CodegenRestDashboard/commands/list_agent_runs.js b/CodegenRestDashboard/commands/list_agent_runs.js new file mode 100644 index 000000000..3c00b7258 --- /dev/null +++ b/CodegenRestDashboard/commands/list_agent_runs.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +const { apiGet, pathList } = require('../utils/apiClient'); + +async function main() { + const args = process.argv.slice(2); + let state = ''; + let page = 1; + let limit = 50; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--state') state = args[++i] || ''; + else if (args[i] === '--page') page = Number(args[++i]); + else if (args[i] === '--limit') limit = Number(args[++i]); + } + + const params = { page, limit }; + if (state) params.state = state; // server may ignore if unsupported + + const data = await apiGet(pathList(), params); + console.log(JSON.stringify(data, null, 2)); +} + +if (require.main === module) { + main().catch((e) => { console.error(e.message || e); process.exit(1); }); +} + +module.exports = main; + diff --git a/CodegenRestDashboard/commands/render_template.js b/CodegenRestDashboard/commands/render_template.js new file mode 100644 index 000000000..095d944cc --- /dev/null +++ b/CodegenRestDashboard/commands/render_template.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +const path = require('path'); +const { loadEnv } = require('../utils/env'); +loadEnv(path.join(__dirname, '..', '.env')); + +// Minimal inlined renderer to avoid depending on dashboard file +function getPath(obj, p){ try { return p.split('.').reduce((a,k)=>(a&&a[k]!=null?a[k]:undefined), obj);} catch(_){ return undefined; } } +function renderTemplate(tpl, vars){ return String(tpl||'').replace(/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g,(_,k)=>{ const v=getPath(vars||{},k); return v==null? '': String(v); }); } + +async function main(){ + const args = process.argv.slice(2); + let template = ''; + let varsJson = '{}'; + for (let i=0;i{ console.error(e.message||e); process.exit(1); }); } +module.exports = main; + diff --git a/CodegenRestDashboard/commands/resume_agent_run.js b/CodegenRestDashboard/commands/resume_agent_run.js new file mode 100644 index 000000000..ddf4938c2 --- /dev/null +++ b/CodegenRestDashboard/commands/resume_agent_run.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +const { apiPost, pathResume } = require('../utils/apiClient'); + +async function main() { + const args = process.argv.slice(2); + let id = undefined; + let prompt = ''; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--id') id = Number(args[++i]); + else if (args[i] === '--prompt') prompt = args[++i] || ''; + } + + if (!id) { + console.error('Usage: resume_agent_run.js --id 123 [--prompt "..."]'); + process.exit(2); + } + + const body = { agent_run_id: id }; + if (prompt) body.prompt = prompt; + + const data = await apiPost(pathResume(), body); + console.log(JSON.stringify(data, null, 2)); +} + +if (require.main === module) { + main().catch((e) => { console.error(e.message || e); process.exit(1); }); +} + +module.exports = main; + diff --git a/CodegenRestDashboard/commands/runCommand.js b/CodegenRestDashboard/commands/runCommand.js new file mode 100644 index 000000000..9b90a4afc --- /dev/null +++ b/CodegenRestDashboard/commands/runCommand.js @@ -0,0 +1,15 @@ +#!/usr/bin/env node +const cmds = require('./index'); + +async function main() { + const [,, name, ...rest] = process.argv; + if (!name || !cmds[name]) { + console.error('Usage: runCommand.js [args...]'); + process.exit(2); + } + // Re-dispatch by spawning module main + await cmds[name](); +} + +main().catch((e)=>{ console.error(e.message || e); process.exit(1); }); + diff --git a/CodegenRestDashboard/commands/unban_agent_run.js b/CodegenRestDashboard/commands/unban_agent_run.js new file mode 100644 index 000000000..a07cb8808 --- /dev/null +++ b/CodegenRestDashboard/commands/unban_agent_run.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +const { apiPost } = require('../utils/apiClient'); +const { loadEnv } = require('../utils/env'); +const path = require('path'); +loadEnv(path.join(__dirname, '..', '.env')); + +function pathUnban(org){ return `/v1/organizations/${org}/agent/run/unban`; } + +async function main(){ + const args = process.argv.slice(2); + let id; let before=null; let after=null; + for (let i=0;i [--before ] [--after ]'); process.exit(2); } + const org = process.env.CODEGEN_ORG_ID; + const body = { agent_run_id: id }; + if (before!==null) body.before_card_order_id = before; + if (after!==null) body.after_card_order_id = after; + const data = await apiPost(pathUnban(org), body); + console.log(JSON.stringify(data, null, 2)); +} + +if (require.main===module){ main().catch(e=>{ console.error(e.message||e); process.exit(1); }); } +module.exports = main; + diff --git a/CodegenRestDashboard/dashboard/api/client.js b/CodegenRestDashboard/dashboard/api/client.js new file mode 100644 index 000000000..284a9a040 --- /dev/null +++ b/CodegenRestDashboard/dashboard/api/client.js @@ -0,0 +1,23 @@ +// Browser-side client calls the local proxy server under /api +(function(){ + const api = {}; + const base = '/api'; + + function get(path, params={}){ + const qs = new URLSearchParams(params).toString(); + return fetch(`${base}${path}${qs?`?${qs}`:''}`).then(r=>r.json()); + } + function post(path, body={}){ + return fetch(`${base}${path}`, { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify(body) }).then(r=>r.json()); + } + + // Helpers mirroring Node client paths + function createAgentRun(payload){ return post(`/v1/organizations/${CG_ENV.ORG_ID}/agent/run`, payload); } + function listAgentRuns(params){ return get(`/v1/organizations/${CG_ENV.ORG_ID}/agent/runs`, params); } + function getAgentRun(id){ return get(`/v1/organizations/${CG_ENV.ORG_ID}/agent/run/${id}`); } + function getAgentLogs(id, params){ return get(`/v1/alpha/organizations/${CG_ENV.ORG_ID}/agent/run/${id}/logs`, params); } + function resumeAgentRun(payload){ return post(`/v1/organizations/${CG_ENV.ORG_ID}/agent/run/resume`, payload); } + + window.CGApi = { createAgentRun, listAgentRuns, getAgentRun, getAgentLogs, resumeAgentRun }; +})(); + diff --git a/CodegenRestDashboard/dashboard/automation/followUpManager.js b/CodegenRestDashboard/dashboard/automation/followUpManager.js new file mode 100644 index 000000000..bf20246e5 --- /dev/null +++ b/CodegenRestDashboard/dashboard/automation/followUpManager.js @@ -0,0 +1,48 @@ +(function(){ + // Monitors watched runs; when a run moves to COMPLETED, automatically send a resume request + // using the run's configured chain of templates (if any), one-by-one per completion cycle. + let lastStatuses = new Map(); + + async function check(){ + const watched = Object.entries(CGStore.state.watched).filter(([,v])=>v).map(([k])=>Number(k)); + for (const id of watched){ + try { + const cur = await CGApi.getAgentRun(id); + const prev = lastStatuses.get(id); + if (prev && prev!=='COMPLETED' && cur.status==='COMPLETED'){ + const chain = CGStore.getChain(id) || []; + const prog = CGStore.getChainProgress(id) || 0; + if (prog < chain.length){ + const tplIdx = chain[prog]; + const tpls = CGStore.state.templates||[]; + if (tpls[tplIdx]){ + const text = tpls[tplIdx].text || ''; + if (text){ + const vars = { + run_id: id, + status: cur.status, + title: cur.title, + summary: cur.summary, + result: cur.result, + created_at: cur.created_at, + now: new Date().toISOString(), + agent_run: cur, + }; + const prompt = (window.CGTemplate && CGTemplate.renderTemplate) + ? CGTemplate.renderTemplate(text, vars) + : text; + await CGApi.resumeAgentRun({ agent_run_id: id, prompt }); + CGStore.setChainProgress(id, prog+1); + CGToast.toast(`Auto-follow-up ${prog+1}/${chain.length} sent for #${id}`); + } + } + } + } + lastStatuses.set(id, cur.status); + } catch (e){ /* ignore */ } + } + } + + function start(){ setInterval(check, 4000); } + window.CGFollowUp = { start }; +})(); diff --git a/CodegenRestDashboard/dashboard/components/header.js b/CodegenRestDashboard/dashboard/components/header.js new file mode 100644 index 000000000..c25a50464 --- /dev/null +++ b/CodegenRestDashboard/dashboard/components/header.js @@ -0,0 +1,44 @@ +(function(){ + const header = document.getElementById('app-header'); + + function render(state){ + header.innerHTML = ''; + + // Left: App title + const title = document.createElement('div'); + title.textContent = 'Codegen REST Dashboard'; + header.appendChild(title); + + // Active count with hover list + const activeWrap = document.createElement('div'); + activeWrap.className = 'header-item'; + const label = document.createElement('span'); + label.textContent = 'Active Runs'; + const badge = document.createElement('span'); + badge.className = 'badge'; + badge.textContent = String(state.activeCount); + activeWrap.appendChild(label); + activeWrap.appendChild(badge); + + const hover = document.createElement('div'); + hover.className = 'hover-card'; + state.runs.filter(r=>r.status==='ACTIVE'||r.status==='PENDING').slice(0,10).forEach((r)=>{ + const row = document.createElement('div'); + row.className = 'run-row'; + row.textContent = `#${r.id} ${r.title || ''}`; + row.onclick = ()=> window.CGRunDialog.open(r.id); + hover.appendChild(row); + }); + if (!hover.childElementCount) { const e=document.createElement('div'); e.className='run-row'; e.textContent='No active runs'; hover.appendChild(e); } + activeWrap.appendChild(hover); + + header.appendChild(activeWrap); + + // Right: Tabs (Runs / Templates) + const tabs = window.CGTabControl.render(); + header.appendChild(tabs); + } + + window.CGHeader = { render }; +})(); + diff --git a/CodegenRestDashboard/dashboard/components/notifications.js b/CodegenRestDashboard/dashboard/components/notifications.js new file mode 100644 index 000000000..c83751b23 --- /dev/null +++ b/CodegenRestDashboard/dashboard/components/notifications.js @@ -0,0 +1,12 @@ +(function(){ + const root = document.getElementById('toasts'); + function toast(msg, timeout=3000){ + const el = document.createElement('div'); + el.className = 'toast'; + el.textContent = msg; + root.appendChild(el); + setTimeout(()=>{ root.removeChild(el); }, timeout); + } + window.CGToast = { toast }; +})(); + diff --git a/CodegenRestDashboard/dashboard/components/pinnedRuns.js b/CodegenRestDashboard/dashboard/components/pinnedRuns.js new file mode 100644 index 000000000..96a60c5c3 --- /dev/null +++ b/CodegenRestDashboard/dashboard/components/pinnedRuns.js @@ -0,0 +1,51 @@ +(function(){ + const root = document.getElementById('pinned'); + + function render(state){ + root.innerHTML = ''; + if (!state.pinned.length) return; + const wrap = document.createElement('div'); + const title = document.createElement('div'); + title.textContent = 'Pinned'; title.style.marginBottom='6px'; + wrap.appendChild(title); + + state.pinned.forEach(id=>{ + const run = state.runs.find(r=>r.id===id) || { id, status: 'UNKNOWN' }; + const card = document.createElement('div'); card.className='pin-card'; + const head = document.createElement('div'); head.textContent = `#${id} ${run.title||''}`; head.style.fontWeight='600'; + const status = document.createElement('div'); status.textContent = ''; status.className = run.status==='ACTIVE'?'status-active': (run.status==='COMPLETED'?'status-completed':''); status.title = run.status; + const controls = document.createElement('div'); controls.style.display='flex'; controls.style.gap='6px'; + const unpin = document.createElement('button'); unpin.className='btn'; unpin.textContent='Unpin'; unpin.onclick=()=> CGStore.unpin(id); + const open = document.createElement('button'); open.className='btn'; open.textContent='Open'; open.onclick=()=> CGRunDialog.open(id); + + // Inline chain selector for active runs + if (run.status==='ACTIVE' || run.status==='PENDING'){ + const chain = document.createElement('button'); chain.className='btn'; chain.textContent='Chain'; + const panel = document.createElement('div'); panel.style.display='none'; panel.style.marginTop='6px'; + const listBox = document.createElement('div'); + const tpls = CGStore.state.templates || []; + const selected = new Set((CGStore.getChain(id)||[]).map(Number)); + tpls.forEach((t,i)=>{ + const lbl = document.createElement('label'); lbl.style.display='flex'; lbl.style.alignItems='center'; lbl.style.gap='6px'; lbl.style.fontSize='12px'; + const cb = document.createElement('input'); cb.type='checkbox'; cb.checked=selected.has(i); + cb.onchange = ()=>{ if (cb.checked) selected.add(i); else selected.delete(i); }; + const span = document.createElement('span'); span.textContent=t.name||`Template ${i+1}`; + lbl.appendChild(cb); lbl.appendChild(span); listBox.appendChild(lbl); + }); + const save = document.createElement('button'); save.className='btn primary'; save.textContent='Save'; save.style.marginTop='6px'; + save.onclick = ()=> { CGStore.setChain(id, Array.from(selected)); panel.style.display='none'; CGToast.toast('Chain updated'); }; + panel.appendChild(listBox); panel.appendChild(save); + chain.onclick = ()=>{ panel.style.display = panel.style.display==='none'?'block':'none'; }; + controls.appendChild(chain); controls.appendChild(panel); + } + + controls.appendChild(unpin); controls.appendChild(open); + card.appendChild(head); card.appendChild(status); card.appendChild(controls); + wrap.appendChild(card); + }); + + root.appendChild(wrap); + } + + window.CGPinnedRuns = { render }; +})(); diff --git a/CodegenRestDashboard/dashboard/components/runDialog.js b/CodegenRestDashboard/dashboard/components/runDialog.js new file mode 100644 index 000000000..d145af269 --- /dev/null +++ b/CodegenRestDashboard/dashboard/components/runDialog.js @@ -0,0 +1,113 @@ +(function(){ + const dialogs = document.getElementById('dialogs'); + + function fmtLog(l){ return `[${l.timestamp}] ${l.level||'INFO'} - ${l.message}`; } + + function open(id){ + const wrapper = document.createElement('div'); + wrapper.className = 'dialog'; + wrapper.innerHTML = `
+
+
Run #${id}
+
+ + +
+
+
+
+
+ + + +
+
+
+
`; + + function close(){ clearInterval(t); dialogs.removeChild(wrapper); } + wrapper.querySelector('#closeBtn').onclick = close; + + // Populate template selector and persist default single-template selection + const sel = wrapper.querySelector('#tplSel'); + function refreshTemplateSelector(){ + const tpls = CGStore.state.templates || []; + sel.innerHTML = ''; + const noneOpt = document.createElement('option'); noneOpt.value = ''; noneOpt.textContent = 'None'; sel.appendChild(noneOpt); + tpls.forEach((t,i)=>{ const o=document.createElement('option'); o.value=String(i); o.textContent=t.name||`Template ${i+1}`; sel.appendChild(o); }); + const existing = (CGStore.getChain(id)||[]); + if (existing.length>0) sel.value = String(existing[0]); + } + refreshTemplateSelector(); + + wrapper.querySelector('#applyTplBtn').onclick = ()=>{ + const v = sel.value; const arr = v===''? [] : [Number(v)]; + CGStore.setChain(id, arr); + CGToast.toast('Default template updated'); + }; + + wrapper.querySelector('#resumeBtn').onclick = async ()=>{ + const tpls = CGStore.state.templates || []; + const chain = CGStore.getChain(id) || []; + let promptText = ''; + if (chain.length>0 && tpls[chain[0]]) { + promptText = tpls[chain[0]].text || ''; + } else { + const pick = promptWindow('Follow-up prompt (leave empty to cancel):'); + if (!pick) return; + promptText = pick; + } + // Resolve template variables using current run meta + let meta = {}; + try { meta = await CGApi.getAgentRun(id); } catch(_){} + const vars = { + run_id: id, + status: meta.status, + title: meta.title, + summary: meta.summary, + result: meta.result, + created_at: meta.created_at, + now: new Date().toISOString(), + agent_run: meta, + }; + const finalPrompt = (window.CGTemplate && CGTemplate.renderTemplate) + ? CGTemplate.renderTemplate(promptText, vars) + : promptText; + + await CGApi.resumeAgentRun({ agent_run_id: id, prompt: finalPrompt }); + CGToast.toast('Resume requested'); + }; + + dialogs.appendChild(wrapper); + + // Poll run + logs for streaming effect + let skip = 0; + async function refresh(){ + try { + const meta = await CGApi.getAgentRun(id); + wrapper.querySelector('#runMeta').textContent = `Status: ${meta.status}`; + const logs = await CGApi.getAgentLogs(id, { skip, limit: 100 }); + const box = wrapper.querySelector('#logBox'); + (logs.logs || []).forEach(l=>{ + const line = document.createElement('div'); + line.textContent = fmtLog(l); + box.appendChild(line); + }); + skip += (logs.logs || []).length; + if (meta.status === 'COMPLETED' || meta.status === 'FAILED' || meta.status === 'CANCELLED') { + clearInterval(t); + } + } catch (e) { console.error(e); } + } + refresh(); + const t = setInterval(refresh, 2000); + } + + function promptWindow(msg){ + // Avoid blocking native prompt for better UX, but spec asks no dependencies; use window.prompt + return window.prompt(msg || ''); + } + + window.CGRunDialog = { open }; +})(); + diff --git a/CodegenRestDashboard/dashboard/components/runList.js b/CodegenRestDashboard/dashboard/components/runList.js new file mode 100644 index 000000000..db90936a8 --- /dev/null +++ b/CodegenRestDashboard/dashboard/components/runList.js @@ -0,0 +1,104 @@ +(function(){ + const runsRoot = document.getElementById('runs'); + const controls = document.getElementById('controls'); + + const MODELS = [ + 'Sonnet 4.5', 'GPT-5', 'GPT 5 Codex', 'Claude opus 4.5', 'Grok 4', 'Grok 4 Fast reasoning', 'Grok Code Fast 1' + ]; + + function renderControls(state){ + controls.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'controls'; + + // Filter toggle (Active/Past) + const sel = document.createElement('select'); + ;['active','past'].forEach(v=>{ + const o = document.createElement('option'); o.value = v; o.textContent = v==='active'?'Active':'Past'; + if (state.filter===v) o.selected = true; sel.appendChild(o); + }); + sel.onchange = ()=> CGStore.setFilter(sel.value); + wrap.appendChild(sel); + + // Create run prompt input + const prompt = document.createElement('input'); + prompt.placeholder = 'New agent prompt...'; prompt.style.minWidth = '260px'; + wrap.appendChild(prompt); + + // Model dropdown + const model = document.createElement('select'); + MODELS.forEach(m=>{ const o=document.createElement('option'); o.value=m; o.textContent=m; model.appendChild(o); }); + wrap.appendChild(model); + + // Repo id (optional) + const repo = document.createElement('input'); repo.placeholder='repo_id (optional)'; repo.type='number'; repo.style.width='140px'; + wrap.appendChild(repo); + + // Create button + const btn = document.createElement('button'); + btn.className = 'btn primary'; btn.textContent = 'Create Agent Run'; + btn.onclick = async ()=>{ + const body = { prompt: prompt.value, model: model.value }; + if (!body.prompt) { CGToast.toast('Prompt required'); return; } + if (repo.value) body.repo_id = Number(repo.value); + await CGApi.createAgentRun(body); + CGToast.toast('Agent run created'); + // No manual refresh button: watcher will auto-refresh the list shortly + prompt.value = ''; + }; + wrap.appendChild(btn); + + controls.appendChild(wrap); + } + + function renderList(state){ + runsRoot.innerHTML = ''; + const list = document.createElement('div'); + const filtered = state.runs.filter(r=> state.filter==='active' ? (r.status==='ACTIVE'||r.status==='PENDING') : (r.status==='COMPLETED'||r.status==='FAILED'||r.status==='CANCELLED')); + filtered.forEach(r=>{ + const row = document.createElement('div'); row.className='run-row'; + const title = document.createElement('div'); title.textContent=`#${r.id} ${r.title||''}`; title.style.flex='1'; + const status = document.createElement('div'); status.textContent=''; status.className = r.status==='ACTIVE'?'status-active': (r.status==='COMPLETED'?'status-completed':''); status.title=r.status; + const pinBtn = document.createElement('button'); pinBtn.className='btn'; pinBtn.textContent = CGStore.state.pinned.includes(r.id)?'Unpin':'Pin'; + pinBtn.onclick = ()=> CGStore.state.pinned.includes(r.id) ? CGStore.unpin(r.id) : CGStore.pin(r.id); + const watchBtn = document.createElement('button'); watchBtn.className='btn'; watchBtn.textContent = CGStore.state.watched[r.id]?'Unwatch':'Watch'; + watchBtn.onclick = ()=> CGStore.setWatched(r.id, !CGStore.state.watched[r.id]); + + // If ACTIVE: show inline chain selector; if not: clicking row opens dialog for resume/logs + const actions = document.createElement('div'); actions.style.display='flex'; actions.style.gap='6px'; + if (r.status==='ACTIVE' || r.status==='PENDING') { + const chainWrap = document.createElement('div'); chainWrap.style.position='relative'; + const chainBtn = document.createElement('button'); chainBtn.className='btn'; chainBtn.textContent='Chain'; + const panel = document.createElement('div'); panel.style.display='none'; panel.style.position='absolute'; panel.style.top='28px'; panel.style.right='0'; panel.style.background='white'; panel.style.border='1px solid #e5e7eb'; panel.style.padding='8px'; panel.style.borderRadius='6px'; panel.style.zIndex='5'; + const listBox = document.createElement('div'); listBox.style.maxHeight='200px'; listBox.style.overflow='auto'; + const tpls = CGStore.state.templates || []; + const selected = new Set((CGStore.getChain(r.id)||[]).map(Number)); + tpls.forEach((t,i)=>{ + const lbl = document.createElement('label'); lbl.style.display='flex'; lbl.style.alignItems='center'; lbl.style.gap='6px'; lbl.style.fontSize='12px'; + const cb = document.createElement('input'); cb.type='checkbox'; cb.checked=selected.has(i); + cb.onchange = ()=>{ if (cb.checked) selected.add(i); else selected.delete(i); }; + const span = document.createElement('span'); span.textContent=t.name||`Template ${i+1}`; + lbl.appendChild(cb); lbl.appendChild(span); listBox.appendChild(lbl); + }); + const saveBtn = document.createElement('button'); saveBtn.className='btn primary'; saveBtn.textContent='Save'; saveBtn.style.marginTop='6px'; + saveBtn.onclick = ()=>{ CGStore.setChain(r.id, Array.from(selected)); panel.style.display='none'; CGToast.toast('Chain updated'); }; + panel.appendChild(listBox); panel.appendChild(saveBtn); + chainBtn.onclick = ()=>{ panel.style.display = panel.style.display==='none'?'block':'none'; }; + chainWrap.appendChild(chainBtn); chainWrap.appendChild(panel); + actions.appendChild(chainWrap); + // Clicking title opens dialog too for logs + title.style.cursor='pointer'; title.onclick=()=> CGRunDialog.open(r.id); + } else { + // Completed or failed: clicking title resumes via dialog + title.style.cursor='pointer'; title.onclick=()=> CGRunDialog.open(r.id); + } + actions.appendChild(pinBtn); actions.appendChild(watchBtn); + row.appendChild(title); row.appendChild(status); row.appendChild(actions); + list.appendChild(row); + }); + runsRoot.appendChild(list); + } + + function render(state){ renderControls(state); renderList(state); } + window.CGRunList = { render }; +})(); diff --git a/CodegenRestDashboard/dashboard/components/tabControl.js b/CodegenRestDashboard/dashboard/components/tabControl.js new file mode 100644 index 000000000..742b662fa --- /dev/null +++ b/CodegenRestDashboard/dashboard/components/tabControl.js @@ -0,0 +1,31 @@ +(function(){ + function render(){ + const wrap = document.createElement('div'); + const runsBtn = document.createElement('button'); runsBtn.className='btn'; runsBtn.textContent='Runs'; + const tplBtn = document.createElement('button'); tplBtn.className='btn'; tplBtn.textContent='Templates'; + + function sel(which){ + window.CGTabControl.active = which; + document.getElementById('controls').style.display = which==='runs'?'block':'none'; + document.getElementById('runs').style.display = which==='runs'?'block':'none'; + document.getElementById('pinned').style.display = which==='runs'?'block':'none'; + const main = document.getElementById('app-main'); + const existing = document.getElementById('tplView'); + if (which==='templates') { + if (!existing) { + const v = document.createElement('div'); v.id='tplView'; v.appendChild(CGTemplates.render()); main.appendChild(v); + } + } else { + if (existing) existing.remove(); + } + } + + runsBtn.onclick = ()=> sel('runs'); + tplBtn.onclick = ()=> sel('templates'); + + wrap.appendChild(runsBtn); wrap.appendChild(tplBtn); + return wrap; + } + window.CGTabControl = { render, active: 'runs' }; +})(); + diff --git a/CodegenRestDashboard/dashboard/components/templateManager.js b/CodegenRestDashboard/dashboard/components/templateManager.js new file mode 100644 index 000000000..829f536fb --- /dev/null +++ b/CodegenRestDashboard/dashboard/components/templateManager.js @@ -0,0 +1,35 @@ +(function(){ + function render(){ + const c = document.createElement('div'); + c.style.padding = '10px 0'; + + const list = document.createElement('div'); + (CGStore.state.templates||[]).forEach((t, i)=>{ + const row = document.createElement('div'); row.className='run-row'; + const name = document.createElement('div'); name.textContent = t.name; name.style.flex='1'; + const edit = document.createElement('button'); edit.className='btn'; edit.textContent='Edit'; edit.onclick=()=> editTpl(i); + const del = document.createElement('button'); del.className='btn'; del.textContent='Delete'; del.onclick=()=> CGStore.deleteTemplate(i); + row.appendChild(name); row.appendChild(edit); row.appendChild(del); list.appendChild(row); + }); + + const add = document.createElement('button'); add.className='btn primary'; add.textContent='Add Template'; add.onclick=()=> addTpl(); + + c.appendChild(list); c.appendChild(add); + return c; + } + + function addTpl(){ + const name = window.prompt('Template name:'); if (!name) return; + const text = window.prompt('Template text:'); if (text==null) return; + CGStore.addTemplate({ name, text }); + } + function editTpl(idx){ + const cur = CGStore.state.templates[idx]; + const name = window.prompt('Template name:', cur.name); if (!name) return; + const text = window.prompt('Template text:', cur.text); if (text==null) return; + CGStore.updateTemplate(idx, { name, text }); + } + + window.CGTemplates = { render }; +})(); + diff --git a/CodegenRestDashboard/dashboard/index.html b/CodegenRestDashboard/dashboard/index.html new file mode 100644 index 000000000..2382e332f --- /dev/null +++ b/CodegenRestDashboard/dashboard/index.html @@ -0,0 +1,33 @@ + + + + + + Codegen REST Dashboard + + + +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/CodegenRestDashboard/dashboard/main.js b/CodegenRestDashboard/dashboard/main.js new file mode 100644 index 000000000..47cd96858 --- /dev/null +++ b/CodegenRestDashboard/dashboard/main.js @@ -0,0 +1,20 @@ +(function(){ + // Inject CG_ENV for browser (org id only; token stays server-side!) + window.CG_ENV = { ORG_ID: (new URLSearchParams(location.search).get('org') || '323') }; + + // Render reactive UI + CGStore.subscribe((state)=>{ + CGHeader.render(state); + CGPinnedRuns.render(state); + if (window.CGTabControl.active==='runs') CGRunList.render(state); + }); + + // Request Notification permission (optional) + if ('Notification' in window && Notification.permission==='default'){ + try { Notification.requestPermission().catch(()=>{}); } catch(_){} + } + + // Start background watchers (auto-refresh; no manual refresh button) + CGWatcher.start(); + CGFollowUp.start(); +})(); diff --git a/CodegenRestDashboard/dashboard/services/watcher.js b/CodegenRestDashboard/dashboard/services/watcher.js new file mode 100644 index 000000000..c8a7e3646 --- /dev/null +++ b/CodegenRestDashboard/dashboard/services/watcher.js @@ -0,0 +1,53 @@ +(function(){ + let interval = null; + + async function tick(){ + try { + const data = await CGApi.listAgentRuns({ page: 1, limit: 50 }); + const runs = (data.data||data.runs||[]); + CGStore.setRuns(runs); + + // Watch pinned/watched runs for status transitions (poll individually) + const watchedIds = Object.entries(CGStore.state.watched) + .filter(([,v])=>v) + .map(([k])=>Number(k)); + const ids = new Set([ ...watchedIds, ...CGStore.state.pinned ]); + for (const id of ids){ + try { + const r = await CGApi.getAgentRun(id); + const old = CGStore.state.runs.find(x=>x.id===id); + if (old && old.status!==r.status && (r.status==='COMPLETED'||r.status==='FAILED'||r.status==='CANCELLED')){ + CGToast.toast(`Run #${id} ${r.status}`); + if ('Notification' in window && Notification.permission==='granted'){ + try { new Notification(`Run #${id} ${r.status}`); } catch(_){} + } + } + } catch (e){ /* ignore individual errors */ } + } + + // Consume webhook events once per tick (outside per-run loop) + try { + const ev = await fetch('/api/events').then(r=>r.json()).catch(()=>({events:[]})); + (ev.events||[]).forEach(async (e)=>{ + const id = e?.body?.agent_run_id || e?.body?.id; + if (!id) return; + try { + const r = await CGApi.getAgentRun(id); + const old = CGStore.state.runs.find(x=>x.id===id); + if (old && old.status!==r.status){ + CGToast.toast(`Run #${id} ${r.status}`); + if ('Notification' in window && Notification.permission==='granted'){ + try { new Notification(`Run #${id} ${r.status}`); } catch(_){ } + } + } + } catch(_){ /* ignore */ } + }); + } catch(_){ /* ignore webhook errors */ } + + } catch (e) { console.error('watcher error', e); } + } + + function start(){ if (interval) clearInterval(interval); tick(); interval = setInterval(tick, 3000); } + window.CGWatcher = { start }; +})(); + diff --git a/CodegenRestDashboard/dashboard/state/store.js b/CodegenRestDashboard/dashboard/state/store.js new file mode 100644 index 000000000..4a87d8eef --- /dev/null +++ b/CodegenRestDashboard/dashboard/state/store.js @@ -0,0 +1,43 @@ +// Tiny store with pub/sub (no deps) +(function(){ + const state = { + activeCount: 0, + runs: [], + filter: 'active', // 'active' | 'past' + pinned: [], // array of run ids + watched: {}, // id -> boolean + templates: JSON.parse(localStorage.getItem('cg_templates')||'[]'), + followUpTemplateMap: {}, // runId -> array of template indices (chain) + chainProgressMap: {}, // runId -> integer pointer into chain + }; + const subs = []; + function notify(){ subs.forEach(fn=>fn(state)); save(); } + function save(){ + localStorage.setItem('cg_pins', JSON.stringify(state.pinned)); + localStorage.setItem('cg_templates', JSON.stringify(state.templates)); + localStorage.setItem('cg_watched', JSON.stringify(state.watched)); + localStorage.setItem('cg_chain_map', JSON.stringify(state.followUpTemplateMap)); + localStorage.setItem('cg_chain_prog', JSON.stringify(state.chainProgressMap)); + } + function init(){ + try { state.pinned = JSON.parse(localStorage.getItem('cg_pins')||'[]'); } catch(_){ } + try { state.watched = JSON.parse(localStorage.getItem('cg_watched')||'{}'); } catch(_){ } + try { state.followUpTemplateMap = JSON.parse(localStorage.getItem('cg_chain_map')||'{}'); } catch(_){ } + try { state.chainProgressMap = JSON.parse(localStorage.getItem('cg_chain_prog')||'{}'); } catch(_){ } + } + function setChain(runId, tplIdxArr){ state.followUpTemplateMap[runId] = Array.isArray(tplIdxArr)? tplIdxArr.slice(0) : []; notify(); } + function setChainProgress(runId, n){ state.chainProgressMap[runId] = n|0; notify(); } + function getChain(runId){ return state.followUpTemplateMap[runId] || []; } + function getChainProgress(runId){ return (state.chainProgressMap[runId] | 0); } + function subscribe(fn){ subs.push(fn); fn(state); return ()=>{ const i=subs.indexOf(fn); if(i>=0) subs.splice(i,1); } } + function setRuns(runs){ state.runs = runs; state.activeCount = runs.filter(r=>r.status==='ACTIVE' || r.status==='PENDING' ).length; notify(); } + function setFilter(f){ state.filter = f; notify(); } + function pin(id){ if(!state.pinned.includes(id)) { state.pinned.unshift(id); notify(); } } + function unpin(id){ state.pinned = state.pinned.filter(x=>x!==id); notify(); } + function setWatched(id, v){ state.watched[id] = !!v; notify(); } + function addTemplate(t){ state.templates.push(t); notify(); } + function updateTemplate(i, t){ state.templates[i]=t; notify(); } + function deleteTemplate(i){ state.templates.splice(i,1); notify(); } + init(); + window.CGStore = { state, subscribe, setRuns, setFilter, pin, unpin, setWatched, addTemplate, updateTemplate, deleteTemplate, setChain, setChainProgress, getChain, getChainProgress }; +})(); diff --git a/CodegenRestDashboard/dashboard/styles.css b/CodegenRestDashboard/dashboard/styles.css new file mode 100644 index 000000000..8969a1bef --- /dev/null +++ b/CodegenRestDashboard/dashboard/styles.css @@ -0,0 +1,32 @@ +* { box-sizing: border-box; } +body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; color: #1f2937; } +header { position: sticky; top: 0; background: #0f172a; color: white; padding: 10px 16px; display: flex; align-items: center; gap: 16px; z-index: 10; } +#app-main { padding: 16px; } +.badge { background: #10b981; color: white; border-radius: 999px; padding: 2px 8px; font-size: 12px; } +.header-item { position: relative; } +.hover-card { position: absolute; top: 30px; left: 0; background: white; border: 1px solid #e5e7eb; box-shadow: 0 10px 20px rgba(0,0,0,0.1); border-radius: 8px; min-width: 240px; display: none; } +.header-item:hover .hover-card { display: block; } +.run-row { display: flex; gap: 8px; padding: 6px 10px; border-bottom: 1px solid #f3f4f6; align-items: center; } +.run-row:last-child { border-bottom: none; } +.btn { border: 1px solid #d1d5db; background: white; padding: 6px 10px; border-radius: 6px; cursor: pointer; } +.btn.primary { background: #2563eb; color: white; border-color: #1d4ed8; } +.controls { display: flex; gap: 12px; align-items: center; margin: 10px 0 16px; } +#pinned { margin-bottom: 16px; } +.pin-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; } +.status-active { color: #0891b2; } +.status-completed { color: #059669; } +.status-failed { color: #dc2626; } +.toast { position: fixed; right: 16px; bottom: 16px; background: #111827; color: white; padding: 10px 14px; border-radius: 8px; opacity: 0.96; } +.dialog { position: fixed; inset: 0; background: rgba(0,0,0,.4); display: flex; align-items: center; justify-content: center; } +.dialog .panel { width: min(900px, 92vw); max-height: 80vh; background: white; border-radius: 12px; box-shadow: 0 25px 50px rgba(0,0,0,0.2); display: flex; flex-direction: column; } +.dialog-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid #e5e7eb; } +.dialog-body { padding: 10px 14px; overflow: auto; } +.logs { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 8px; } +.status-active::before, .status-completed::before, .status-failed::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 999px; margin-right: 6px; vertical-align: middle; } +.status-active::before { background: #06b6d4; } +.status-completed::before { background: #10b981; } +.status-failed::before { background: #ef4444; } +.run-row { padding: 4px 8px; gap: 6px; } +.btn { padding: 4px 8px; font-size: 12px; } +.controls { gap: 8px; } + diff --git a/CodegenRestDashboard/mock/mockData.json b/CodegenRestDashboard/mock/mockData.json new file mode 100644 index 000000000..ad5d89c7f --- /dev/null +++ b/CodegenRestDashboard/mock/mockData.json @@ -0,0 +1,7 @@ +{ + "runs": [ + { "id": 1, "status": "ACTIVE", "title": "Demo active" }, + { "id": 2, "status": "COMPLETED", "title": "Demo past" } + ] +} + diff --git a/CodegenRestDashboard/server.js b/CodegenRestDashboard/server.js new file mode 100644 index 000000000..f786cb309 --- /dev/null +++ b/CodegenRestDashboard/server.js @@ -0,0 +1,104 @@ +// Simple static server + proxy without external deps +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { loadEnv } = require('./utils/env'); +loadEnv(path.join(__dirname, '.env')); + +const PORT = Number(process.env.PORT || 8787); +const API_BASE = (process.env.CODEGEN_API_BASE || 'https://api.codegen.com').replace(/\/$/, ''); +const TOKEN = process.env.CODEGEN_TOKEN || ''; +const ORG_ID = process.env.CODEGEN_ORG_ID || ''; +const OFFLINE = String(process.env.CODEGEN_OFFLINE || '0') === '1'; + +// In-memory webhook events cache (best-effort; dev only) +const webhookEvents = []; + +function serveWebhook(req, res){ + let body = ''; + req.on('data', c => body += c); + req.on('end', ()=>{ + try { + const json = JSON.parse(body || '{}'); + webhookEvents.push({ ts: Date.now(), body: json }); + } catch(_) { + webhookEvents.push({ ts: Date.now(), raw: body }); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +function serveEvents(req, res){ + // Return and clear (so each event is processed once). Client can poll /api/events frequently. + const copy = webhookEvents.splice(0, webhookEvents.length); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ events: copy })); +} + + +function contentType(filePath) { + if (filePath.endsWith('.html')) return 'text/html; charset=utf-8'; + if (filePath.endsWith('.css')) return 'text/css; charset=utf-8'; + if (filePath.endsWith('.js')) return 'application/javascript; charset=utf-8'; + if (filePath.endsWith('.json')) return 'application/json; charset=utf-8'; + return 'text/plain; charset=utf-8'; +} + +function serveStatic(req, res) { + let file = req.url.split('?')[0] || '/index.html'; + if (file === '/' || file === '') file = '/index.html'; + // Prevent path traversal and fix leading slash + const baseDir = path.join(__dirname, 'dashboard'); + const rel = file.replace(/^\/+/, ''); + const p = path.normalize(path.join(baseDir, rel)); + if (!p.startsWith(baseDir)) { + res.writeHead(403); return res.end('Forbidden'); + } + fs.readFile(p, (err, data) => { + if (err) { res.writeHead(404); return res.end('Not found'); } + res.writeHead(200, { 'Content-Type': contentType(p) }); + res.end(data); + }); +} + +async function proxyApi(req, res) { + if (!TOKEN || !ORG_ID) { res.writeHead(500); return res.end('Missing CODEGEN_TOKEN or CODEGEN_ORG_ID'); } + let body = ''; + req.on('data', (c) => body += c); + req.on('end', async () => { + try { + // Map /api/* to real endpoints; the incoming path already contains /v1/... + const target = `${API_BASE}${req.url.replace(/^\/api/, '')}`; + if (OFFLINE) { + // Minimal offline mock + res.writeHead(200, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ok: true, offline: true, url: target })); + } + const r = await fetch(target, { + method: req.method, + headers: { + 'Authorization': `Bearer ${TOKEN}`, + 'Content-Type': req.headers['content-type'] || 'application/json' + }, + body: ['POST','PUT','PATCH'].includes(req.method) ? body : undefined, + }); + const txt = await r.text(); + res.writeHead(r.status, { 'Content-Type': r.headers.get('content-type') || 'application/json' }); + res.end(txt); + } catch (e) { + res.writeHead(500); res.end(e.message || 'Proxy error'); + } + }); +} + +const server = http.createServer((req, res) => { + if (req.url.startsWith('/api/events') && req.method==='GET') return serveEvents(req, res); + if (req.url === '/webhook' && req.method==='POST') return serveWebhook(req, res); + if (req.url.startsWith('/api/')) return proxyApi(req, res); + return serveStatic(req, res); +}); + +server.listen(PORT, () => { + console.log(`[CodegenRestDashboard] Server listening on http://localhost:${PORT}`); +}); diff --git a/CodegenRestDashboard/utils/apiClient.js b/CodegenRestDashboard/utils/apiClient.js new file mode 100644 index 000000000..04eee146e --- /dev/null +++ b/CodegenRestDashboard/utils/apiClient.js @@ -0,0 +1,126 @@ +// Minimal API client (Node-only) +const { loadEnv } = require('./env'); +const http = require('http'); +const https = require('https'); + +loadEnv(); + +const API_BASE = process.env.CODEGEN_API_BASE?.replace(/\/$/, '') || 'https://api.codegen.com'; +const ORG_ID = process.env.CODEGEN_ORG_ID; +const TOKEN = process.env.CODEGEN_TOKEN; +const OFFLINE = String(process.env.CODEGEN_OFFLINE || '0') === '1'; + +function nodeFetch(url, options) { + // Prefer global fetch if available (Node 18+), else fallback to https + if (typeof fetch === 'function') { + return fetch(url, options); + } + return new Promise((resolve, reject) => { + const u = new URL(url); + const mod = u.protocol === 'http:' ? http : https; + const req = mod.request({ + method: options?.method || 'GET', + hostname: u.hostname, + port: u.port || (u.protocol === 'http:' ? 80 : 443), + path: u.pathname + u.search, + headers: options?.headers || {}, + }, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + resolve({ + ok: res.statusCode >= 200 && res.statusCode < 300, + status: res.statusCode, + json: async () => JSON.parse(data || '{}'), + text: async () => data, + }); + }); + }); + req.on('error', reject); + if (options?.body) req.write(options.body); + req.end(); + }); +} + +function authHeaders() { + if (!TOKEN || !ORG_ID) throw new Error('Missing CODEGEN_TOKEN or CODEGEN_ORG_ID'); + return { + 'Authorization': `Bearer ${TOKEN}`, + 'Content-Type': 'application/json', + }; +} + +async function apiGet(path, params = {}) { + if (OFFLINE) return mockResponse(path, params, 'GET'); + const qs = new URLSearchParams(params).toString(); + const url = `${API_BASE}${path}${qs ? `?${qs}` : ''}`; + const res = await nodeFetch(url, { method: 'GET', headers: authHeaders() }); + if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`); + return res.json(); +} + +async function apiPost(path, body = {}) { + if (OFFLINE) return mockResponse(path, body, 'POST'); + const url = `${API_BASE}${path}`; + const res = await nodeFetch(url, { method: 'POST', headers: authHeaders(), body: JSON.stringify(body) }); + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(`POST ${path} failed: ${res.status} ${txt}`); + } + return res.json(); +} + +// Mock data for offline mode +function mockResponse(path, payload, method) { + if (path.includes('/agent/runs')) { + return Promise.resolve({ + data: [ + { id: 101, status: 'ACTIVE', title: 'Active run A', created_at: new Date().toISOString() }, + { id: 99, status: 'COMPLETED', title: 'Past run X', created_at: new Date(Date.now()-86400000).toISOString() }, + ], + total: 2, + page: 1, + limit: 50, + }); + } + if (path.includes('/agent/run/resume')) { + return Promise.resolve({ success: true, resumed: true, ...payload }); + } + if (path.includes('/agent/run/') && method === 'GET' && path.match(/\/agent\/run\/(\d+)/)) { + const id = Number(path.match(/\/(\d+)$/)?.[1] || 0); + return Promise.resolve({ id, status: id % 2 ? 'ACTIVE' : 'COMPLETED', title: `Run ${id}` }); + } + if (path.includes('/agent/run') && method === 'POST') { + return Promise.resolve({ id: Math.floor(Math.random()*1000)+200, status: 'PENDING', ...payload }); + } + if (path.includes('/setup-commands/generate')) { + return Promise.resolve({ status: 'QUEUED', repo_id: payload.repo_id, agent_run_id: 555 }); + } + if (path.includes('/logs')) { + return Promise.resolve({ + agent_run: { id: payload.id || 999, status: 'ACTIVE' }, + logs: [ { id: 1, message: 'Mock log line 1', level: 'INFO', timestamp: new Date().toISOString() } ], + pagination: { skip: 0, limit: 50, total: 1 } + }); + } + return Promise.resolve({ ok: true, echo: { path, payload, method } }); +} + +// High-level API wrappers +function pathCreate() { return `/v1/organizations/${ORG_ID}/agent/run`; } +function pathGet(id) { return `/v1/organizations/${ORG_ID}/agent/run/${id}`; } +function pathList() { return `/v1/organizations/${ORG_ID}/agent/runs`; } +function pathResume() { return `/v1/organizations/${ORG_ID}/agent/run/resume`; } +function pathLogs(id) { return `/v1/alpha/organizations/${ORG_ID}/agent/run/${id}/logs`; } +function pathGenerateSetup() { return `/v1/organizations/${ORG_ID}/setup-commands/generate`; } + +module.exports = { + apiGet, + apiPost, + pathCreate, + pathGet, + pathList, + pathResume, + pathLogs, + pathGenerateSetup, +}; diff --git a/CodegenRestDashboard/utils/env.js b/CodegenRestDashboard/utils/env.js new file mode 100644 index 000000000..15753d7c6 --- /dev/null +++ b/CodegenRestDashboard/utils/env.js @@ -0,0 +1,25 @@ +// Minimal .env loader without dependencies +// Usage: loadEnv(path.join(__dirname, '..', '.env')) +const fs = require('fs'); +const path = require('path'); + +function loadEnv(envPath) { + try { + if (!envPath) envPath = path.join(process.cwd(), 'CodegenRestDashboard', '.env'); + if (!fs.existsSync(envPath)) return; + const content = fs.readFileSync(envPath, 'utf8'); + content.split(/\r?\n/).forEach((line) => { + if (!line || line.trim().startsWith('#')) return; + const idx = line.indexOf('='); + if (idx === -1) return; + const key = line.slice(0, idx).trim(); + const val = line.slice(idx + 1).trim(); + if (key && !(key in process.env)) process.env[key] = val; + }); + } catch (e) { + console.error('[env] Failed to load .env:', e.message); + } +} + +module.exports = { loadEnv }; + diff --git a/CodegenRestDashboard/utils/templateEngine.js b/CodegenRestDashboard/utils/templateEngine.js new file mode 100644 index 000000000..1520a4ff9 --- /dev/null +++ b/CodegenRestDashboard/utils/templateEngine.js @@ -0,0 +1,22 @@ +// Simple, safe templating: replaces {{var}} with values from vars (supports dot paths) +(function(){ + function getPath(obj, path) { + try { + return path.split('.').reduce((acc, key) => (acc && acc[key] != null ? acc[key] : undefined), obj); + } catch (_) { return undefined; } + } + + function renderTemplate(template, vars) { + if (typeof template !== 'string') return ''; + return template.replace(/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g, (m, key) => { + const v = getPath(vars || {}, key); + if (v == null) return ''; + return String(v); + }); + } + + // Expose for browser and Node + if (typeof window !== 'undefined') window.CGTemplate = { renderTemplate }; + module.exports = { renderTemplate }; +})(); + diff --git a/CodegenRestDashboard/webhook_server.js b/CodegenRestDashboard/webhook_server.js new file mode 100644 index 000000000..7513401da --- /dev/null +++ b/CodegenRestDashboard/webhook_server.js @@ -0,0 +1,52 @@ +// Cloudflare Worker: handle Codegen webhook callbacks at /webhook +// Deploy with Wrangler or Cloudflare dashboard. No external deps. +// Optionally set CODEGEN_WEBHOOK_SECRET in Worker secrets to verify HMAC. + +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + if (url.pathname !== '/webhook') + return new Response('Not Found', { status: 404 }); + + if (request.method !== 'POST') + return new Response('Method Not Allowed', { status: 405 }); + + // Read body + const bodyText = await request.text(); + + // Optional signature check + const sig = request.headers.get('X-Codegen-Signature'); + const secret = env.CODEGEN_WEBHOOK_SECRET || ''; + if (secret) { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] + ); + const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(bodyText)); + const expected = [...new Uint8Array(mac)].map(b => b.toString(16).padStart(2,'0')).join(''); + if (!sig || sig !== expected) { + return new Response('Invalid signature', { status: 401 }); + } + } + + // Persist minimal event in KV (optional) or forward to your backend + // Here, we simply log and return 200. + console.log('Codegen webhook:', bodyText.slice(0, 500)); + + // Optional forward to another endpoint (e.g., your local proxy /webhook) + const forwardUrl = env.WEBHOOK_FORWARD_URL || ''; + if (forwardUrl) { + try { + await fetch(forwardUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: bodyText }); + } catch (e) { + console.log('Forward failed:', e.message || e); + } + } + + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } +};