refactor(dashboard): slim view modules and add package alignment tests.

Delegate heavy dashboard views to shared modules, register shell header branding slot, and add tests for auth/api views and jobs UI config.
This commit is contained in:
MervinPraison committed 2026-05-21 11:45:36 +01:00
1 parent 7099e9f2ce
commit 39615089ba
13 files changed
+170 -1250

No files matched your search

@@ -2355,6 +2355,17 @@ function onContentChange(root) {
}
}
window.aiui.registerSlot('shell:header', () => {
const el = document.createElement('div');
el.className = 'db-shell-header-brand';
el.style.cssText = 'font-size:12px;color:var(--db-text-dim);padding:4px 0';
fetch('/ui-config.json').then((r) => r.json()).then((cfg) => {
const t = cfg?.site?.title;
if (t) el.textContent = t;
}).catch(() => {});
return el;
});
window.aiui.registerSlot('shell:sidebar:footer', () => {
const el = document.createElement('div');
el.innerHTML = '<span class="db-health-dot" style="color:var(--db-text-dim)">●</span> <span class="db-health-label">Checking…</span>';
@@ -9,6 +9,37 @@ import { showConfirm } from './toast.js';
let jobsData = null;
let refreshInterval = null;
let activeStreams = {};
let _jobsCfg = { apiBase: '/api/jobs', backend: 'aiui' };
async function loadJobsConfig() {
if (window.__aiuiJobsConfig) {
_jobsCfg = window.__aiuiJobsConfig;
return;
}
try {
const res = await fetch('/ui-config.json');
const data = await res.json();
if (data.jobs) {
_jobsCfg = { apiBase: data.jobs.apiBase || '/api/jobs', backend: data.jobs.backend || 'aiui' };
}
window.__aiuiJobsConfig = _jobsCfg;
} catch (_) { /* defaults */ }
}
function jobsUrl(subpath) {
const base = (_jobsCfg.apiBase || '/api/jobs').replace(/\/$/, '');
return subpath ? `${base}/${subpath}` : base;
}
function normalizeJob(raw) {
const id = raw.id || raw.job_id;
return { ...raw, id, job_id: id };
}
function normalizeJobsResponse(data) {
const jobs = (data.jobs || []).map(normalizeJob);
return { jobs, total: data.total ?? jobs.length };
}
const STATUS_CONFIG = {
queued: { icon: '⏳', color: '#6b7280', label: 'Queued' },
@@ -20,9 +51,9 @@ const STATUS_CONFIG = {
async function fetchJobs() {
try {
const resp = await fetch('/api/jobs');
const resp = await fetch(jobsUrl());
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const data = normalizeJobsResponse(await resp.json());
jobsData = data;
return data;
} catch (e) {
@@ -33,7 +64,7 @@ async function fetchJobs() {
async function cancelJob(jobId) {
try {
const resp = await fetch(`/api/jobs/${jobId}/cancel`, { method: 'POST' });
const resp = await fetch(jobsUrl(`${jobId}/cancel`), { method: 'POST' });
const data = await resp.json();
if (resp.ok) {
showNotification(`Job "${jobId}" cancelled`, 'success');
@@ -49,8 +80,12 @@ async function cancelJob(jobId) {
async function deleteJob(jobId) {
if (!await showConfirm('Delete Job', `Delete job "${jobId}"?`)) return;
if (_jobsCfg.backend === 'praisonai') {
showNotification('Delete is not supported for praisonai jobs API', 'error');
return;
}
try {
const resp = await fetch(`/api/jobs/${jobId}`, { method: 'DELETE' });
const resp = await fetch(jobsUrl(jobId), { method: 'DELETE' });
if (resp.ok) {
showNotification(`Job "${jobId}" deleted`, 'success');
await fetchJobs();
@@ -162,9 +197,7 @@ function renderJobRow(job) {
⏹️
</button>
` : `
<button class="aiui-btn aiui-btn-sm aiui-btn-delete" onclick="window.aiuiDeleteJob('${job.id}')" title="Delete">
🗑️
</button>
${_jobsCfg.backend !== 'praisonai' ? `<button class="aiui-btn aiui-btn-sm aiui-btn-delete" onclick="window.aiuiDeleteJob('${job.id}')" title="Delete">🗑️</button>` : ''}
`}
<button class="aiui-btn aiui-btn-sm aiui-btn-view" onclick="window.aiuiViewJob('${job.id}')" title="View Details">
👁️
@@ -176,7 +209,7 @@ function renderJobRow(job) {
async function viewJob(jobId) {
try {
const resp = await fetch(`/api/jobs/${jobId}`);
const resp = await fetch(jobsUrl(jobId));
const job = await resp.json();
const modal = document.createElement('div');
@@ -238,7 +271,7 @@ function renderJobsUI() {
<div class="aiui-empty-icon">📋</div>
<h3>No Jobs</h3>
<p>Submit agent jobs via the API to see them here.</p>
<p class="aiui-empty-hint">POST /api/jobs with a prompt to get started.</p>
<p class="aiui-empty-hint">POST ${jobsUrl()} with a prompt to get started.</p>
</div>
`;
return;
@@ -570,8 +603,9 @@ window.aiuiViewJob = viewJob;
export default {
name: 'jobs',
async init() {
await loadJobsConfig();
injectStyles();
console.debug('[AIUI:jobs] Plugin loaded');
console.debug('[AIUI:jobs] Plugin loaded', _jobsCfg);
},
onContentChange(root) {
checkForJobsPage(root);
@@ -10,6 +10,9 @@ console.log('[AIUI Loader] Starting...');
* - name: string — unique identifier
* - init(): Promise<void> — called once on load
* - onContentChange(root): void — called when SPA content changes
*
* Dashboard manifest plugins (CSS/JS) are loaded by dashboard.js
* loadDashboardPlugins() after /api/dashboard/plugins — not here.
*/
(function () {
'use strict';
@@ -1,130 +1,11 @@
/**
* Agents View — CRUD agent management
* API: /api/agents/definitions, /api/agents/models
* Agents view — delegates to the agents dashboard module.
*/
import { showToast, showConfirm } from '../toast.js';
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let agents = [];
let models = ['gpt-4o-mini'];
try {
const res = await fetch('/api/agents/definitions');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
agents = data.agents || data || [];
if (!Array.isArray(agents)) agents = Object.values(agents);
} catch(e) {
// Show empty state on error
agents = [];
}
try {
const mRes = await fetch('/api/agents/models');
if (mRes.ok) { const md = await mRes.json(); models = md.models || models; }
} catch(e) {}
container.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<span style="color:var(--db-text-dim);font-size:13px">${agents.length} agent(s)</span>
<button id="agent-add-btn" style="background:var(--db-accent);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px">+ New Agent</button>
</div>
<div id="agents-grid" class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(320px,1fr))"></div>
<div id="agent-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center"></div>
`;
const grid = container.querySelector('#agents-grid');
agents.forEach(a => {
const card = document.createElement('div');
card.className = 'db-card';
card.style.cursor = 'pointer';
const name = a.name || a.id || 'Unnamed';
const model = a.model || 'default';
const icon = a.icon || '🤖';
const desc = a.description || a.instructions || '';
const tools = a.tools || [];
card.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<span style="font-size:28px">${icon}</span>
<div>
<div style="font-weight:600;font-size:15px">${name}</div>
<div style="font-size:12px;color:var(--db-text-dim)">${model}</div>
</div>
</div>
${desc ? `<div style="font-size:13px;color:var(--db-text-dim);margin-bottom:10px;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical">${desc}</div>` : ''}
${tools.length ? `<div style="display:flex;gap:6px;flex-wrap:wrap">${tools.map(t => `<span style="font-size:11px;padding:2px 8px;background:var(--db-hover);border-radius:12px;color:var(--db-text-dim)">${t}</span>`).join('')}</div>` : ''}
<div style="margin-top:12px;display:flex;gap:8px">
<button class="agent-run-btn" data-id="${a.id||name}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">▶ Run</button>
<button class="agent-del-btn" data-id="${a.id||name}" style="font-size:11px;padding:4px 12px;border:1px solid rgba(239,68,68,0.3);background:transparent;color:#ef4444;border-radius:6px;cursor:pointer">Delete</button>
</div>
`;
grid.appendChild(card);
});
if (agents.length === 0) {
grid.innerHTML = `
<div style="grid-column:1/-1;text-align:center;padding:60px 20px">
<div style="font-size:48px;margin-bottom:12px;opacity:0.5">🤖</div>
<h3 style="margin:0 0 8px;font-size:18px;color:var(--db-text)">No Agents</h3>
<p style="margin:0 0 16px;color:var(--db-text-dim);font-size:14px">Create your first agent to get started.</p>
<button onclick="document.getElementById('agent-add-btn')?.click()" style="background:var(--db-accent);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px">Create Agent</button>
</div>
`;
}
// Add agent button
container.querySelector('#agent-add-btn')?.addEventListener('click', () => showAgentForm(container, models));
// Run: navigate to Chat with this agent pre-selected
container.querySelectorAll('.agent-run-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const agentId = btn.dataset.id;
const chatUrl = '/chat?agent=' + encodeURIComponent(agentId);
history.pushState({ pageId: 'chat' }, '', chatUrl);
window.dispatchEvent(new PopStateEvent('popstate'));
});
});
// Delete handlers
container.querySelectorAll('.agent-del-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!await showConfirm('Delete agent?', 'This action cannot be undone.')) return;
try { await fetch(`/api/agents/definitions/${btn.dataset.id}`, {method:'DELETE'}); render(container); } catch(e) {}
});
});
}
function showAgentForm(container, models) {
const modal = container.querySelector('#agent-modal');
modal.style.display = 'flex';
const modelOptions = (models || ['gpt-4o-mini']).map(m => `<option value="${m}">${m}</option>`).join('');
modal.innerHTML = `
<div style="background:var(--db-sidebar-bg);border:1px solid var(--db-border);border-radius:12px;padding:28px;width:440px;max-height:80vh;overflow-y:auto">
<h3 style="margin:0 0 20px;font-size:18px">New Agent</h3>
<label style="display:block;margin-bottom:16px">
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Name</span>
<input id="af-name" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box" />
</label>
<label style="display:block;margin-bottom:16px">
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Instructions</span>
<textarea id="af-instructions" rows="3" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;resize:vertical;box-sizing:border-box"></textarea>
</label>
<label style="display:block;margin-bottom:20px">
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Model</span>
<select id="af-model" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box">${modelOptions}</select>
</label>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button id="af-cancel" style="padding:8px 16px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:8px;cursor:pointer">Cancel</button>
<button id="af-save" style="padding:8px 16px;background:var(--db-accent);color:#fff;border:none;border-radius:8px;cursor:pointer">Create</button>
</div>
</div>
`;
modal.querySelector('#af-cancel').addEventListener('click', () => modal.style.display = 'none');
modal.querySelector('#af-save').addEventListener('click', async () => {
const body = { name: modal.querySelector('#af-name').value, instructions: modal.querySelector('#af-instructions').value, model: modal.querySelector('#af-model').value };
try { await fetch('/api/agents/definitions', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); modal.style.display='none'; render(container); } catch(e) { showToast('Failed to create agent', 'error'); }
});
modal.addEventListener('click', (e) => { if (e.target === modal) modal.style.display = 'none'; });
container.setAttribute('data-page', 'agents');
container.classList.add('agents-page');
const mod = await import('../agents.js');
if (mod.default?.init) await mod.default.init();
const root = container.closest('#root') || document.getElementById('root');
if (mod.default?.onContentChange && root) mod.default.onContentChange(root);
}
@@ -1,153 +1,8 @@
/**
* Channels View — Multi-platform messaging channel management.
*
* Enhanced with: per-platform configuration forms (Slack, Discord, Telegram,
* WhatsApp, Signal, Google Chat, Nostr), connection testing,
* platform-specific setup steps, status details.
*
* API: /api/channels, /api/channels/platforms
*/
import { showToast, showConfirm } from '../toast.js';
/** Channels view — delegates to channels dashboard module. */
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let channels = [], platforms = [];
try { const r = await fetch('/api/channels'); const d = await r.json(); channels = d.channels || d || []; if (!Array.isArray(channels)) channels = Object.entries(channels).map(([id,c]) => ({id,...(typeof c==='object'?c:{platform:c})})); } catch(e) {}
try { const r = await fetch('/api/channels/platforms'); const d = await r.json(); platforms = d.platforms || d || []; } catch(e) {}
// envKey kept for reference only (not auto-filled)
const platformMeta = {
discord: { icon: '🎮', color: '#5865F2', fields: ['Bot Token'] },
slack: { icon: '💬', color: '#4A154B', fields: ['Bot Token', 'App Token', 'Signing Secret (Optional)'] },
telegram: { icon: '✈️', color: '#229ED9', fields: ['Bot Token'] },
whatsapp: { icon: '📱', color: '#25D366', fields: ['Access Token', 'Phone Number ID', 'Verify Token (Optional)', 'Webhook Port (Optional)'] },
imessage: { icon: '🍎', color: '#007AFF', fields: ['Apple ID', 'Handler Path'] },
signal: { icon: '🔒', color: '#3A76F0', fields: ['Phone Number', 'API URL'] },
googlechat: { icon: '💼', color: '#00AC47', fields: ['Service Account', 'Space Name'] },
nostr: { icon: '🟣', color: '#9B59B6', fields: ['Private Key (nsec)', 'Relay URL'] },
};
container.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<div>
<span style="color:var(--db-text-dim);font-size:13px">${channels.length} channel(s)</span>
<span style="margin-left:8px;font-size:11px;color:var(--db-text-dim)">${platforms.length || Object.keys(platformMeta).length} platforms supported</span>
</div>
<button id="ch-add" style="background:var(--db-accent);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500">+ Add Channel</button>
</div>
<div id="ch-grid" class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(340px,1fr))"></div>
<div id="ch-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center"></div>
`;
const grid = container.querySelector('#ch-grid');
channels.forEach(ch => {
const card = document.createElement('div');
card.className = 'db-card';
const platform = (ch.platform || ch.type || ch.id || '').toLowerCase();
const meta = platformMeta[platform] || { icon: '📡', color: 'var(--db-accent)', fields: [] };
const enabled = ch.enabled !== false;
const isRunning = ch.running === true;
const hasError = ch.start_error && ch.start_error !== '';
const statusLabel = !enabled ? 'Disabled' : isRunning ? 'Connected' : hasError ? 'Error' : 'Stopped';
const isConnected = isRunning;
card.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<div style="width:42px;height:42px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:22px;background:${meta.color}22">${meta.icon}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:15px">${ch.name || ch.id || platform}</div>
<div style="font-size:12px;color:var(--db-text-dim);margin-top:1px">${platform.charAt(0).toUpperCase() + platform.slice(1)}</div>
</div>
<span style="font-size:11px;padding:3px 10px;border-radius:12px;${isConnected ? 'background:rgba(34,197,94,.15);color:#22c55e' : hasError ? 'background:rgba(234,179,8,.15);color:#eab308' : 'background:rgba(239,68,68,.15);color:#ef4444'}">${isConnected ? '●' : hasError ? '⚠' : '○'} ${statusLabel}</span>
</div>
${ch.last_message ? `<div style="font-size:11px;color:var(--db-text-dim);margin-bottom:8px;padding:6px 10px;background:rgba(0,0,0,.03);border-radius:6px">Last: ${ch.last_message}</div>` : ''}
<div style="display:flex;gap:6px;margin-top:8px">
<button class="ch-toggle" data-id="${ch.id}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">${enabled ? 'Disable' : 'Enable'}</button>
<button class="ch-restart" data-id="${ch.id}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">⟳ Restart</button>
<button class="ch-test" data-id="${ch.id}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">🔍 Test</button>
<button class="ch-del" data-id="${ch.id}" style="font-size:11px;padding:4px 12px;border:1px solid rgba(239,68,68,0.3);background:transparent;color:#ef4444;border-radius:6px;cursor:pointer">Delete</button>
</div>
`;
grid.appendChild(card);
});
if (channels.length === 0) grid.innerHTML = '<div class="db-viewer"><pre>No channels configured. Click "+ Add Channel" to connect a platform.</pre></div>';
// Event listeners
container.querySelectorAll('.ch-toggle').forEach(b => b.addEventListener('click', async () => { try { await fetch(`/api/channels/${b.dataset.id}/toggle`, {method:'POST'}); render(container); } catch(e){} }));
container.querySelectorAll('.ch-restart').forEach(b => b.addEventListener('click', async () => { try { await fetch(`/api/channels/${b.dataset.id}/restart`, {method:'POST'}); } catch(e){} }));
container.querySelectorAll('.ch-test').forEach(b => b.addEventListener('click', async () => {
try {
const r = await fetch(`/api/channels/${b.dataset.id}/test`, {method:'POST'});
const d = await r.json();
showToast(d.success ? 'Connection test passed!' : 'Test failed: ' + (d.error || 'Unknown error'), d.success ? 'success' : 'error');
if (d.success) render(container);
} catch(e) { showToast('Test failed', 'error'); }
}));
container.querySelectorAll('.ch-del').forEach(b => b.addEventListener('click', async () => { if (!await showConfirm('Delete channel?', 'This action cannot be undone.')) return; try { await fetch(`/api/channels/${b.dataset.id}`, {method:'DELETE'}); render(container); } catch(e){} }));
// Add Channel Modal with platform-specific forms
container.querySelector('#ch-add')?.addEventListener('click', () => {
const m = container.querySelector('#ch-modal'); m.style.display = 'flex';
const platformList = Array.isArray(platforms) && platforms.length > 0 ? platforms : Object.keys(platformMeta);
m.innerHTML = `<div style="background:var(--db-sidebar-bg);border:1px solid var(--db-border);border-radius:12px;padding:28px;width:480px;max-height:80vh;overflow-y:auto">
<h3 style="margin:0 0 20px;font-size:18px">Add Channel</h3>
<label style="display:block;margin-bottom:14px"><span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Platform</span>
<select id="chf-platform" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box">
${platformList.map(p => {
const pm = platformMeta[p] || { icon: '📡' };
return `<option value="${p}">${pm.icon} ${p.charAt(0).toUpperCase() + p.slice(1)}</option>`;
}).join('')}
</select>
</label>
<label style="display:block;margin-bottom:14px"><span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Channel Name <span style="opacity:.5">(Optional)</span></span><input id="chf-name" placeholder="my-bot" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box"></label>
<div id="chf-fields" style="margin-bottom:14px"></div>
<div id="chf-setup-hint" style="margin-bottom:20px;padding:10px 14px;background:rgba(var(--db-accent-rgb,100,100,255),.06);border-radius:8px;font-size:12px;color:var(--db-text-dim)"></div>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button id="chf-cancel" style="padding:8px 16px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:8px;cursor:pointer">Cancel</button>
<button id="chf-save" style="padding:8px 16px;background:var(--db-accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:500">Add Channel</button>
</div>
</div>`;
function updatePlatformFields() {
const platform = m.querySelector('#chf-platform').value;
const pm = platformMeta[platform] || { fields: [], icon: '📡' };
const fieldsDiv = m.querySelector('#chf-fields');
const hintDiv = m.querySelector('#chf-setup-hint');
fieldsDiv.innerHTML = pm.fields.map((f, i) => {
const isOptional = f.includes('(Optional)');
const cleanName = f.replace(' (Optional)', '');
const fieldKey = cleanName.toLowerCase().replace(/\s+/g, '_');
const isToken = f.toLowerCase().includes('token') || f.toLowerCase().includes('secret') || f.toLowerCase().includes('key');
const optTag = isOptional ? ' <span style="opacity:.5">(Optional)</span>' : '';
return `<label style="display:block;margin-bottom:10px">
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">${cleanName}${optTag}</span>
<input class="chf-field" data-field="${fieldKey}" type="${isToken ? 'password' : 'text'}" placeholder="${cleanName}" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box">
</label>`;
}).join('');
const hints = {
discord: '1. Create a Bot at discord.com/developers\n2. Enable Message Content Intent\n3. Copy the Bot Token\n4. Invite bot to your server\n\nServer ID and Channel ID are auto-discovered.',
slack: '1. Create a Slack App at api.slack.com\n2. Add Bot Token Scopes (chat:write, app_mentions:read)\n3. Enable Socket Mode → copy App Token (xapp-...)\n4. Install to workspace\n5. Copy Bot Token (xoxb-...)',
telegram: '1. Message @BotFather on Telegram\n2. Create a new bot with /newbot\n3. Copy the Bot Token\n\nChat ID is auto-detected when users message the bot.',
whatsapp: '1. Create a Meta Business App\n2. Set up WhatsApp Business API\n3. Copy Phone Number ID and Access Token',
};
hintDiv.innerHTML = hints[platform] ? '<strong>Setup Steps:</strong><pre style="margin:6px 0 0;white-space:pre-wrap">' + hints[platform] + '</pre>' : `Configure your ${platform} integration above.`;
}
updatePlatformFields();
m.querySelector('#chf-platform').addEventListener('change', updatePlatformFields);
m.querySelector('#chf-cancel').addEventListener('click', () => m.style.display = 'none');
m.querySelector('#chf-save').addEventListener('click', async () => {
const platform = m.querySelector('#chf-platform').value;
const config = {};
m.querySelectorAll('.chf-field').forEach(f => { config[f.dataset.field] = f.value; });
const body = { platform, name: m.querySelector('#chf-name').value || platform + '-bot', config };
try { await fetch('/api/channels', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); m.style.display='none'; render(container); } catch(e){}
});
m.addEventListener('click', e => { if (e.target === m) m.style.display = 'none'; });
});
container.setAttribute('data-page', 'channels');
const mod = await import('../channels.js');
if (mod.default?.init) await mod.default.init();
const root = container.closest('#root') || document.getElementById('root');
if (mod.default?.onContentChange && root) mod.default.onContentChange(root);
}
@@ -1,281 +1,8 @@
/**
* Config View — Schema-driven runtime configuration editor.
*
* Enhanced with: sidebar section navigation, search/filter, Form/Raw YAML toggle,
* section collapse/expand, change history, validation feedback.
*
* API: /api/config, /api/config/schema, /api/config/history
*/
import { helpBanner } from '/plugins/views/_helpers.js';
let currentSection = null;
let rawMode = false;
let searchQuery = '';
/** Config view — delegates to config dashboard module. */
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let config = {}, schema = {}, history = [];
try { const r = await fetch('/api/config'); config = await r.json(); } catch(e) {}
try { const r = await fetch('/api/config/schema'); schema = await r.json(); } catch(e) {}
try { const r = await fetch('/api/config/history'); const d = await r.json(); history = d.history || d || []; } catch(e) {}
const properties = schema.properties || {};
const sections = Object.entries(properties);
if (sections.length > 0 && !currentSection) currentSection = sections[0][0];
container.innerHTML = `
<div style="display:flex;gap:0;border:1px solid var(--db-border);border-radius:12px;overflow:hidden;min-height:500px">
<!-- Sidebar Navigation -->
<div style="width:200px;background:var(--db-sidebar-bg,var(--db-card-bg));border-right:1px solid var(--db-border);display:flex;flex-direction:column">
<div style="padding:12px">
<input id="cfg-search" type="text" placeholder="Search…" value="${searchQuery}" style="width:100%;padding:6px 10px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:12px;box-sizing:border-box">
</div>
<div id="cfg-sections" style="flex:1;overflow-y:auto;padding:0 8px 8px">
${sections.map(([key, def]) => {
const title = def.title || key;
const hidden = searchQuery && !title.toLowerCase().includes(searchQuery.toLowerCase());
return hidden ? '' : `<button class="cfg-section-btn${currentSection === key ? ' active' : ''}" data-section="${key}" style="display:block;width:100%;text-align:left;padding:8px 12px;border:none;background:${currentSection === key ? 'rgba(var(--db-accent-rgb,100,100,255),.12)' : 'transparent'};color:${currentSection === key ? 'var(--db-accent)' : 'var(--db-text)'};border-radius:6px;cursor:pointer;font-size:13px;margin-bottom:2px;font-weight:${currentSection === key ? '600' : '400'};transition:all .15s">${title}</button>`;
}).join('')}
${sections.length === 0 ? '<div style="padding:12px;font-size:12px;color:var(--db-text-dim)">No schema available</div>' : ''}
</div>
<div style="padding:8px;border-top:1px solid var(--db-border)">
<button id="cfg-mode-toggle" style="width:100%;padding:6px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer;font-size:11px;font-weight:500">${rawMode ? '📝 Form Mode' : '📄 Raw YAML'}</button>
</div>
</div>
<!-- Content Area -->
<div style="flex:1;display:flex;flex-direction:column">
<div style="padding:16px 20px;border-bottom:1px solid var(--db-border);display:flex;justify-content:space-between;align-items:center">
<h3 style="margin:0;font-size:16px;font-weight:600">${currentSection ? (properties[currentSection]?.title || currentSection) : 'Configuration'}</h3>
<div style="display:flex;gap:8px">
<button id="cfg-validate" style="padding:6px 14px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer;font-size:12px">✓ Validate</button>
<button id="cfg-save" style="padding:6px 14px;background:var(--db-accent);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500">Save & Apply</button>
</div>
</div>
<div style="flex:1;overflow-y:auto;padding:20px" id="cfg-content">
${rawMode ? renderRawEditor(config) : renderFormEditor(config, properties, currentSection)}
</div>
<div id="cfg-feedback" style="padding:10px 20px;font-size:13px;border-top:1px solid var(--db-border);min-height:20px"></div>
</div>
<!-- History Panel -->
<div style="width:220px;border-left:1px solid var(--db-border);background:var(--db-sidebar-bg,var(--db-card-bg))">
<div style="padding:12px 14px;border-bottom:1px solid var(--db-border);font-size:13px;font-weight:600">Change History</div>
<div id="cfg-history" style="max-height:500px;overflow-y:auto;padding:8px 14px">
${(Array.isArray(history) ? history : []).slice(0, 20).map(h => `
<div style="font-size:11px;padding:8px 0;border-bottom:1px solid var(--db-border)">
<div style="font-weight:500;color:var(--db-text)">${h.key || 'config'}: ${h.action || 'updated'}</div>
<div style="color:var(--db-text-dim);margin-top:2px">${h.timestamp ? new Date(h.timestamp * 1000).toLocaleString() : ''}</div>
</div>
`).join('') || '<div style="font-size:12px;color:var(--db-text-dim)">No changes yet</div>'}
</div>
</div>
</div>
${helpBanner({
title: 'Configuration',
what: 'This is your settings panel. You can adjust how your AI agents behave, which AI model they use, and how the server runs.',
howToUse: 'Browse sections in the left sidebar, change any value, and click <b>Save & Apply</b>. Changes take effect immediately — no restart needed.',
tip: 'Use the search box to quickly find a setting. Toggle between Form and Raw YAML mode at the bottom of the sidebar.',
collapsed: true,
})}
`;
// Bind section navigation
container.querySelectorAll('.cfg-section-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentSection = btn.dataset.section;
render(container);
});
});
// Search
container.querySelector('#cfg-search')?.addEventListener('input', (e) => {
searchQuery = e.target.value;
render(container);
});
// Mode toggle
container.querySelector('#cfg-mode-toggle')?.addEventListener('click', () => {
rawMode = !rawMode;
render(container);
});
// Save
const feedback = container.querySelector('#cfg-feedback');
container.querySelector('#cfg-save')?.addEventListener('click', async () => {
const patch = rawMode ? collectRawData(container) : collectFormData(container.querySelector('#cfg-content'));
try {
const r = await fetch('/api/config/apply', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(patch)});
const d = await r.json();
feedback.innerHTML = `<span style="color:#22c55e">✓ ${d.message || 'Applied successfully'}</span>`;
} catch(e) {
feedback.innerHTML = `<span style="color:#ef4444">✗ Failed to apply</span>`;
}
});
// Validate
container.querySelector('#cfg-validate')?.addEventListener('click', async () => {
const patch = rawMode ? collectRawData(container) : collectFormData(container.querySelector('#cfg-content'));
try {
const r = await fetch('/api/config/validate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(patch)});
const d = await r.json();
feedback.innerHTML = d.valid
? `<span style="color:#22c55e">✓ Configuration is valid</span>`
: `<span style="color:#ef4444">✗ ${(d.errors||[]).join(', ')}</span>`;
} catch(e) {
feedback.innerHTML = `<span style="color:#ef4444">✗ Validation error</span>`;
}
});
}
function renderFormEditor(config, properties, section) {
if (!section || !properties[section]) {
if (Object.keys(properties).length === 0) {
return `<div class="db-viewer"><pre>${toYaml(config.config || config)}</pre></div>`;
}
return '<div style="color:var(--db-text-dim);font-size:13px">Select a section from the sidebar</div>';
}
const schemaDef = properties[section];
const sectionProps = schemaDef.properties || {};
let html = '';
if (schemaDef.description) {
html += `<div style="margin-bottom:16px;padding:10px 14px;background:rgba(var(--db-accent-rgb,100,100,255),.06);border-radius:8px;font-size:12px;color:var(--db-text-dim)">${schemaDef.description}</div>`;
}
for (const [key, propDef] of Object.entries(sectionProps)) {
const value = (config[section] && config[section][key]) ?? propDef.default ?? '';
const inputType = propDef.format === 'password' ? 'password' : propDef.type === 'integer' ? 'number' : 'text';
const desc = propDef.description ? `<div style="font-size:11px;color:var(--db-text-dim);margin-top:2px">${propDef.description}</div>` : '';
if (propDef.type === 'boolean') {
html += `<label style="display:flex;align-items:center;gap:10px;margin:14px 0;font-size:13px;cursor:pointer;padding:8px 12px;border-radius:8px;transition:background .1s" onmouseenter="this.style.background='rgba(var(--db-accent-rgb,100,100,255),.04)'" onmouseleave="this.style.background=''">
<input type="checkbox" data-section="${section}" data-key="${key}" ${value ? 'checked' : ''} style="accent-color:var(--db-accent);width:16px;height:16px">
<div><div style="font-weight:500">${propDef.title || key}</div>${desc}</div>
</label>`;
} else if (propDef.enum) {
html += `<label style="display:block;margin:14px 0">
<span style="font-size:12px;font-weight:500">${propDef.title || key}</span>${desc}
<select data-section="${section}" data-key="${key}" style="display:block;width:100%;margin-top:6px;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:8px;color:var(--db-text);font-size:13px;box-sizing:border-box">
${propDef.enum.map(e => `<option value="${e}" ${e === value ? 'selected' : ''}>${e}</option>`).join('')}
</select>
</label>`;
} else {
html += `<label style="display:block;margin:14px 0">
<span style="font-size:12px;font-weight:500">${propDef.title || key}</span>${desc}
<input type="${inputType}" data-section="${section}" data-key="${key}" value="${value}" ${propDef.minimum !== undefined ? `min="${propDef.minimum}"` : ''} ${propDef.maximum !== undefined ? `max="${propDef.maximum}"` : ''} placeholder="${propDef.default || ''}" style="display:block;width:100%;margin-top:6px;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:8px;color:var(--db-text);font-size:13px;box-sizing:border-box" />
</label>`;
}
}
return html || '<div style="color:var(--db-text-dim);font-size:13px">No properties in this section</div>';
}
function renderRawEditor(config) {
const yamlContent = toYaml(config.config || config);
return `<textarea id="cfg-raw-editor" style="width:100%;height:400px;padding:12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:8px;color:var(--db-text);font-family:monospace;font-size:12px;line-height:1.6;resize:vertical;box-sizing:border-box">${yamlContent}</textarea>`;
}
function collectFormData(form) {
const data = {};
if (!form) return data;
form.querySelectorAll('[data-section]').forEach(el => {
const s = el.dataset.section, k = el.dataset.key;
if (!data[s]) data[s] = {};
data[s][k] = el.type === 'checkbox' ? el.checked : el.type === 'number' ? Number(el.value) : el.value;
});
return data;
}
function collectRawData(container) {
const textarea = container.querySelector('#cfg-raw-editor');
try {
const text = textarea?.value || '';
// Try JSON first (backward compat), then parse YAML-like
try { return JSON.parse(text); } catch(_) {}
return parseSimpleYaml(text);
} catch(e) { return {}; }
}
// ── Lightweight YAML serializer/parser (no external deps) ──────────
/** Convert a JS object to human-readable YAML string. */
function toYaml(obj, indent = 0) {
if (obj === null || obj === undefined) return 'null';
if (typeof obj === 'string') return obj.includes('\n') ? `|\n${obj.split('\n').map(l => ' '.repeat(indent + 1) + l).join('\n')}` : quoteIfNeeded(obj);
if (typeof obj === 'number' || typeof obj === 'boolean') return String(obj);
if (Array.isArray(obj)) {
if (obj.length === 0) return '[]';
return obj.map(item => {
const val = toYaml(item, indent + 1);
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
const lines = val.split('\n');
return ' '.repeat(indent) + '- ' + lines[0] + (lines.length > 1 ? '\n' + lines.slice(1).map(l => ' '.repeat(indent) + ' ' + l).join('\n') : '');
}
return ' '.repeat(indent) + '- ' + val;
}).join('\n');
}
if (typeof obj === 'object') {
const entries = Object.entries(obj);
if (entries.length === 0) return '{}';
return entries.map(([k, v]) => {
const val = toYaml(v, indent + 1);
if (typeof v === 'object' && v !== null && val.includes('\n')) {
return ' '.repeat(indent) + k + ':\n' + val;
}
return ' '.repeat(indent) + k + ': ' + val;
}).join('\n');
}
return String(obj);
}
function quoteIfNeeded(s) {
if (s === '' || s === 'true' || s === 'false' || s === 'null' || /^[\d.]+$/.test(s) || /[:#{}\[\],&*?|<>=!%@`]/.test(s)) {
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
return s;
}
/** Parse simple YAML (flat key: value, one level of nesting). */
function parseSimpleYaml(text) {
const result = {};
let currentKey = null;
for (const line of text.split('\n')) {
const trimmed = line.trimEnd();
if (!trimmed || trimmed.startsWith('#')) continue;
const topMatch = trimmed.match(/^(\w[\w.-]*):\s*(.*)$/);
if (topMatch && !line.startsWith(' ')) {
currentKey = topMatch[1];
const val = topMatch[2].trim();
if (val) {
result[currentKey] = parseYamlValue(val);
currentKey = null;
} else {
result[currentKey] = {};
}
} else if (currentKey && line.startsWith(' ')) {
const nested = trimmed.trim().match(/^(\w[\w.-]*):\s*(.*)$/);
if (nested) {
if (typeof result[currentKey] !== 'object') result[currentKey] = {};
result[currentKey][nested[1]] = parseYamlValue(nested[2].trim());
}
}
}
return result;
}
function parseYamlValue(s) {
if (s === 'true') return true;
if (s === 'false') return false;
if (s === 'null' || s === '~') return null;
if (s === '[]') return [];
if (s === '{}') return {};
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) return s.slice(1, -1);
return s;
container.setAttribute('data-page', 'config');
const mod = await import('../config.js');
if (mod.default?.init) await mod.default.init();
const root = container.closest('#root') || document.getElementById('root');
if (mod.default?.onContentChange && root) mod.default.onContentChange(root);
}
@@ -10,6 +10,18 @@
// ── All features with their endpoints ──────────────────────────
const FEATURES = [
{ group: "PraisonAI Package", items: [
{ name: "discovery", endpoints: [
{ label: "Serve Discovery", method: "GET", url: "/__praisonai__/discovery" },
{ label: "Gateway Info", method: "GET", url: "/info" },
]},
{ name: "jobs_v1", endpoints: [
{ label: "List Runs", method: "GET", url: "/api/v1/runs" },
]},
{ name: "dash_plugins", endpoints: [
{ label: "Dashboard Plugins", method: "GET", url: "/api/dashboard/plugins" },
]},
]},
{ group: "PraisonAI Gateway", items: [
{ name: "health", endpoints: [
{ label: "Health", method: "GET", url: "/health" },
@@ -1,95 +1,8 @@
/**
* Logs View — real-time WebSocket log viewer
* API: /api/logs/stream (WebSocket), /api/logs/levels, /api/logs/stats
*/
import { helpBanner } from '/plugins/views/_helpers.js';
let ws = null;
/** Logs view — delegates to logs dashboard module. */
export async function render(container) {
// Clean up previous connection
if (ws) { try { ws.close(); } catch(e) {} ws = null; }
let stats = {}, levels = {};
try { const r = await fetch('/api/logs/stats'); stats = await r.json(); } catch(e) {}
try { const r = await fetch('/api/logs/levels'); levels = await r.json(); } catch(e) {}
const levelList = levels.levels || Object.keys(levels) || ['DEBUG','INFO','WARNING','ERROR'];
container.innerHTML = `
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
<select id="log-level" style="padding:6px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:13px">
<option value="">All Levels</option>
${(Array.isArray(levelList) ? levelList : Object.keys(levelList)).map(l => `<option value="${typeof l === 'string' ? l : l.name || l}">${typeof l === 'string' ? l : l.name || l}</option>`).join('')}
</select>
<input id="log-search" placeholder="Filter logs…" style="flex:1;padding:6px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:13px" />
<button id="log-clear" style="padding:6px 14px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer;font-size:12px">Clear</button>
<span id="log-status" style="font-size:12px;color:var(--db-text-dim)">Connecting…</span>
</div>
<div class="db-columns" style="grid-template-columns:repeat(4,1fr);margin-bottom:16px">
<div class="db-card" style="padding:12px 16px">
<div class="db-card-title">Total</div>
<div class="db-card-value" style="font-size:20px">${stats.total || 0}</div>
</div>
<div class="db-card" style="padding:12px 16px">
<div class="db-card-title">Errors</div>
<div class="db-card-value" style="font-size:20px;color:#ef4444">${stats.by_level?.ERROR || 0}</div>
</div>
<div class="db-card" style="padding:12px 16px">
<div class="db-card-title">Warnings</div>
<div class="db-card-value" style="font-size:20px;color:#eab308">${stats.by_level?.WARNING || 0}</div>
</div>
<div class="db-card" style="padding:12px 16px">
<div class="db-card-title">Buffer</div>
<div class="db-card-value" style="font-size:20px">${stats.buffer_size || 0}</div>
</div>
</div>
<div id="log-output" style="background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:10px;padding:16px;height:calc(100vh - 400px);overflow-y:auto;font-family:monospace;font-size:12px;line-height:1.8"></div>
${helpBanner({
title: 'Logs',
what: 'A live feed of everything happening on your server. See agent activity, errors, and system messages as they happen.',
howToUse: 'Logs stream automatically — just watch! Use the <b>level filter</b> to show only errors or warnings, and the <b>search box</b> to find specific messages.',
tip: 'This page is always useful for troubleshooting. If something isn\'t working, check here first.',
collapsed: true,
})}
`;
const output = container.querySelector('#log-output');
const statusEl = container.querySelector('#log-status');
const levelSelect = container.querySelector('#log-level');
const searchInput = container.querySelector('#log-search');
// Connect WebSocket
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
try {
ws = new WebSocket(`${proto}//${location.host}/api/logs/stream`);
ws.onopen = () => { statusEl.textContent = '● Connected'; statusEl.style.color = '#22c55e'; };
ws.onclose = () => { statusEl.textContent = '○ Disconnected'; statusEl.style.color = '#ef4444'; };
ws.onmessage = (e) => {
try {
const entry = JSON.parse(e.data);
const level = levelSelect.value;
const search = searchInput.value.toLowerCase();
if (level && entry.level !== level) return;
if (search && !(entry.message || '').toLowerCase().includes(search)) return;
appendLogEntry(output, entry);
} catch(err) {}
};
} catch(e) { statusEl.textContent = 'WS unavailable'; }
container.querySelector('#log-clear')?.addEventListener('click', async () => {
try { await fetch('/api/logs/clear', {method:'POST'}); output.innerHTML = ''; } catch(e) {}
});
container.setAttribute('data-page', 'logs');
const mod = await import('../logs.js');
if (mod.default?.init) await mod.default.init();
const root = container.closest('#root') || document.getElementById('root');
if (mod.default?.onContentChange && root) mod.default.onContentChange(root);
}
function appendLogEntry(output, entry) {
const colors = { DEBUG: '#6b7280', INFO: '#3b82f6', WARNING: '#eab308', ERROR: '#ef4444', CRITICAL: '#dc2626' };
const color = colors[entry.level] || '#6b7280';
const line = document.createElement('div');
const time = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
line.innerHTML = `<span style="color:var(--db-text-dim)">${time}</span> <span style="color:${color};font-weight:600">[${entry.level || 'LOG'}]</span> ${entry.message || entry.msg || JSON.stringify(entry)}`;
output.appendChild(line);
output.scrollTop = output.scrollHeight;
}
export function cleanup() { if (ws) { try { ws.close(); } catch(e) {} ws = null; } }
@@ -4,6 +4,7 @@
* API: /api/memory, /api/memory/search, /api/memory/status, /api/memory/context
*/
import { showToast, showConfirm } from '../toast.js';
import { searchInput } from './_helpers.js';
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
@@ -43,7 +44,7 @@ export async function render(container) {
<!-- Search + Actions -->
<div style="display:flex;gap:8px;margin-bottom:20px;align-items:center">
<input id="mem-search" type="text" placeholder="Search memory..." style="flex:1;padding:8px 14px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:8px;color:var(--db-text);font-size:13px;outline:none" />
<div style="flex:1">${searchInput('Search memory…', 'mem-search')}</div>
<button id="mem-search-btn" style="padding:8px 18px;background:var(--db-accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:12px;font-weight:600">🔍 Search</button>
<button id="mem-store-btn" style="padding:8px 18px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:8px;cursor:pointer;font-size:12px">+ Store</button>
<button id="mem-clear-btn" style="padding:8px 18px;border:1px solid #ef4444;background:transparent;color:#ef4444;border-radius:8px;cursor:pointer;font-size:12px" title="Clear all memories">🗑 Clear All</button>
@@ -1,201 +1,11 @@
import { showConfirm } from '../toast.js';
/**
* Schedules View — Cron/interval job management with run history.
*
* Enhanced with: run history log, next-run countdown, channel targeting,
* schedule type toggle (cron vs interval), advanced form fields.
*
* API: /api/schedules
* Schedules view — delegates to the schedules dashboard module.
*/
import { helpBanner } from '/plugins/views/_helpers.js';
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let jobs = [], history = [];
try {
const r = await fetch('/api/schedules');
const d = await r.json();
jobs = d.jobs || d.schedules || d || [];
if (!Array.isArray(jobs)) jobs = Object.values(jobs);
} catch(e) {}
try {
const r = await fetch('/api/schedules/history');
const d = await r.json();
history = d.history || d.runs || d || [];
if (!Array.isArray(history)) history = [];
} catch(e) {}
const enabledCount = jobs.filter(j => j.enabled !== false).length;
container.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<div>
<span style="color:var(--db-text-dim);font-size:13px">${jobs.length} schedule(s)</span>
<span style="margin-left:8px;font-size:11px;padding:2px 8px;border-radius:10px;background:rgba(34,197,94,.15);color:#22c55e">${enabledCount} active</span>
</div>
<button id="sched-add" style="background:var(--db-accent);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500">+ Add Schedule</button>
</div>
<div style="display:flex;gap:20px">
<!-- Schedule List -->
<div style="flex:1" id="sched-list"></div>
<!-- Run History Panel -->
<div style="width:300px">
<div class="db-card">
<div class="db-card-title">Run History</div>
<div id="sched-history" style="max-height:500px;overflow-y:auto;margin-top:8px">
${history.length === 0 ? '<div style="font-size:12px;color:var(--db-text-dim);padding:8px 0">No runs yet</div>' :
history.slice(0, 30).map(h => `
<div style="padding:8px 0;border-bottom:1px solid var(--db-border);font-size:12px">
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-weight:500">${h.name || h.job_id || 'unknown'}</span>
<span style="font-size:10px;padding:2px 6px;border-radius:8px;${h.status === 'succeeded' || h.status === 'success' ? 'background:rgba(34,197,94,.15);color:#22c55e' : h.status === 'failed' ? 'background:rgba(239,68,68,.15);color:#ef4444' : 'background:rgba(59,130,246,.15);color:#3b82f6'}">${h.status || 'unknown'}</span>
${h.delivered ? '<span style="font-size:10px;padding:2px 6px;border-radius:8px;background:rgba(168,85,247,.15);color:#a855f7;margin-left:4px">📤 delivered</span>' : ''}
</div>
<div style="color:var(--db-text-dim);font-size:11px;margin-top:2px">${h.timestamp ? new Date(typeof h.timestamp === 'number' ? h.timestamp * 1000 : h.timestamp).toLocaleString() : ''}</div>
${h.duration ? `<div style="color:var(--db-text-dim);font-size:11px">Duration: ${h.duration}s</div>` : ''}
${h.result ? `<div style="margin-top:4px;padding:6px 8px;background:var(--db-card-bg);border-radius:4px;font-size:11px;color:var(--db-text);max-height:80px;overflow-y:auto;white-space:pre-wrap;word-break:break-word">${String(h.result).substring(0, 500)}</div>` : ''}
</div>
`).join('')
}
</div>
</div>
</div>
</div>
${helpBanner({
title: 'Jobs & Schedules',
what: 'Set up your AI agents to run tasks automatically on a schedule — like daily reports, regular check-ins, or periodic data updates.',
howToUse: 'Click <b>+ Add Schedule</b> above to create a new recurring task. Give it a name, choose how often it runs (e.g. every hour, daily at 9am), and describe what the agent should do. You can pause, resume, or run any job instantly with the buttons on each card.',
tip: 'You can also ask your agent in chat: <i>"Remind me every morning at 9am to check the dashboard"</i> to create schedules conversationally.',
collapsed: true,
})}
<div id="sched-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center"></div>
`;
const list = container.querySelector('#sched-list');
jobs.forEach(j => {
const card = document.createElement('div');
card.className = 'db-card';
card.style.cssText = 'margin-bottom:10px;padding:16px 20px;';
const enabled = j.enabled !== false;
const sched = typeof j.schedule === 'object' && j.schedule
? (j.schedule.cron_expr || (j.schedule.every_seconds ? `every ${j.schedule.every_seconds}s` : j.schedule.kind || ''))
: (j.schedule || j.cron || j.interval || '');
const nextRun = j.next_run ? new Date(typeof j.next_run === 'number' ? j.next_run * 1000 : j.next_run) : null;
const nextRunStr = nextRun ? formatCountdown(nextRun) : '';
const channel = j.delivery ? `${j.delivery.channel || ''}→${j.delivery.channel_id || ''}` : (j.channel || j.target_channel || '');
const agentId = j.agent_id || '';
card.innerHTML = `
<div style="display:flex;align-items:flex-start;justify-content:space-between">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-weight:600;font-size:14px">${j.name || j.job_id || j.id || 'Unnamed'}</span>
<span style="font-size:11px;padding:2px 8px;border-radius:10px;${enabled ? 'background:rgba(34,197,94,.15);color:#22c55e' : 'background:rgba(239,68,68,.15);color:#ef4444'}">${enabled ? '● Active' : '○ Disabled'}</span>
</div>
<div style="font-size:12px;color:var(--db-text-dim);margin-top:6px">
<span style="margin-right:12px">📅 ${sched}</span>
${j.message ? `<span style="margin-right:12px">📝 ${j.message}</span>` : ''}
${channel ? `<span style="margin-right:12px">📢 ${channel}</span>` : ''}
${agentId ? `<span style="margin-right:12px">🤖 ${agentId}</span>` : ''}
</div>
${nextRunStr ? `<div style="font-size:11px;color:var(--db-accent);margin-top:4px">⏱ Next: ${nextRunStr}</div>` : ''}
</div>
<div style="display:flex;gap:6px;align-items:center;flex-shrink:0">
<button class="sched-toggle" data-id="${j.job_id||j.id}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">${enabled ? 'Disable' : 'Enable'}</button>
<button class="sched-run" data-id="${j.job_id||j.id}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">▶ Run Now</button>
<button class="sched-del" data-id="${j.job_id||j.id}" style="font-size:11px;padding:4px 12px;border:1px solid rgba(239,68,68,0.3);background:transparent;color:#ef4444;border-radius:6px;cursor:pointer">✕</button>
</div>
</div>
`;
list.appendChild(card);
});
if (jobs.length === 0) list.innerHTML = '<div class="db-viewer"><pre>No schedules configured</pre></div>';
// Event listeners
container.querySelectorAll('.sched-toggle').forEach(b => b.addEventListener('click', async () => { try { await fetch(`/api/schedules/${b.dataset.id}/toggle`, {method:'POST'}); render(container); } catch(e){} }));
container.querySelectorAll('.sched-run').forEach(b => b.addEventListener('click', async () => { try { await fetch(`/api/schedules/${b.dataset.id}/run`, {method:'POST'}); setTimeout(() => render(container), 1000); } catch(e){} }));
container.querySelectorAll('.sched-del').forEach(b => b.addEventListener('click', async () => { if (!await showConfirm('Delete Schedule', 'Delete this schedule?')) return; try { await fetch(`/api/schedules/${b.dataset.id}`, {method:'DELETE'}); render(container); } catch(e){} }));
// Add Schedule Modal
container.querySelector('#sched-add')?.addEventListener('click', () => {
const m = container.querySelector('#sched-modal'); m.style.display = 'flex';
m.innerHTML = `<div style="background:var(--db-sidebar-bg);border:1px solid var(--db-border);border-radius:12px;padding:28px;width:460px;max-height:85vh;overflow-y:auto">
<h3 style="margin:0 0 20px;font-size:18px">New Schedule</h3>
<label style="display:block;margin-bottom:14px"><span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Name</span><input id="sf-name" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box"></label>
<div style="display:flex;gap:12px;margin-bottom:14px">
<label style="flex:1"><span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Type</span>
<select id="sf-type" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box">
<option value="cron">Cron Expression</option>
<option value="interval">Interval (seconds)</option>
</select>
</label>
<label style="flex:2"><span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px" id="sf-schedule-label">Cron Expression</span><input id="sf-schedule" placeholder="*/5 * * * *" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box"></label>
</div>
<label style="display:block;margin-bottom:14px"><span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Message</span><input id="sf-message" placeholder="Send daily report" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box"></label>
<label style="display:block;margin-bottom:14px"><span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Agent ID <span style="opacity:.5">(optional)</span></span><input id="sf-agent" placeholder="support" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box"></label>
<div style="display:flex;gap:12px;margin-bottom:14px">
<label style="flex:1"><span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Channel <span style="opacity:.5">(platform)</span></span><input id="sf-channel" placeholder="telegram" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box"></label>
<label style="flex:1"><span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Channel ID <span style="opacity:.5">(chat ID)</span></span><input id="sf-channel-id" placeholder="12345" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box"></label>
</div>
<label style="display:block;margin-bottom:20px"><span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Session Mode</span>
<select id="sf-session" style="width:100%;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:14px;box-sizing:border-box">
<option value="isolated">Isolated (fresh context each run)</option>
<option value="main">Main (preserve conversation context)</option>
</select>
</label>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button id="sf-cancel" style="padding:8px 16px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:8px;cursor:pointer">Cancel</button>
<button id="sf-save" style="padding:8px 16px;background:var(--db-accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:500">Create</button>
</div>
</div>`;
// Toggle label based on type
m.querySelector('#sf-type').addEventListener('change', (e) => {
const label = m.querySelector('#sf-schedule-label');
const input = m.querySelector('#sf-schedule');
if (e.target.value === 'interval') {
label.textContent = 'Interval (seconds)';
input.placeholder = '300';
input.type = 'number';
} else {
label.textContent = 'Cron Expression';
input.placeholder = '*/5 * * * *';
input.type = 'text';
}
});
m.querySelector('#sf-cancel').addEventListener('click', () => m.style.display = 'none');
m.querySelector('#sf-save').addEventListener('click', async () => {
const type = m.querySelector('#sf-type').value;
const schedVal = m.querySelector('#sf-schedule').value;
const channel = m.querySelector('#sf-channel').value;
const channelId = m.querySelector('#sf-channel-id').value;
const body = {
name: m.querySelector('#sf-name').value,
schedule: type === 'interval' ? { every_seconds: parseInt(schedVal) || 60 } : schedVal,
message: m.querySelector('#sf-message').value,
agent_id: m.querySelector('#sf-agent').value || undefined,
channel: channel || undefined,
channel_id: channelId || undefined,
session_target: m.querySelector('#sf-session').value,
};
try { await fetch('/api/schedules', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); m.style.display='none'; render(container); } catch(e){}
});
m.addEventListener('click', e => { if (e.target === m) m.style.display = 'none'; });
});
}
function formatCountdown(date) {
const diff = date.getTime() - Date.now();
if (diff <= 0) return 'now';
const mins = Math.floor(diff / 60000);
const hours = Math.floor(mins / 60);
if (hours > 0) return `in ${hours}h ${mins % 60}m`;
return `in ${mins}m`;
container.setAttribute('data-page', 'schedules');
container.classList.add('schedules-page');
const mod = await import('../schedules.js');
if (mod.default?.init) await mod.default.init();
const root = container.closest('#root') || document.getElementById('root');
if (mod.default?.onContentChange && root) mod.default.onContentChange(root);
}
@@ -1,196 +1,11 @@
/**
* Sessions View — Session list with inline editing, filters, and usage.
*
* Enhanced with: inline label editing, per-session usage/token counts,
* active/closed filter, agent filter, delete action, link to chat.
*
* API: /api/sessions, /api/sessions/{id}/state, /api/sessions/{id}/preview,
* /api/sessions/{id}/labels, /api/sessions/{id}/usage
* Sessions view — delegates to the sessions dashboard module.
*/
import { showConfirm } from '../toast.js';
let filterStatus = 'all';
let filterAgent = '';
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let sessions = [];
try {
const res = await fetch('/api/sessions');
const data = await res.json();
sessions = data.sessions || data || [];
if (!Array.isArray(sessions)) sessions = Object.entries(sessions).map(([id,s]) => ({id,...(typeof s === 'object' ? s : {status:s})}));
} catch(e) {}
// Collect unique agents for filter
const agentNames = [...new Set(sessions.map(s => s.agent_id || s.agent || '').filter(Boolean))];
// Apply filters
let filtered = sessions;
if (filterStatus === 'active') filtered = filtered.filter(s => s.is_active !== false);
if (filterStatus === 'closed') filtered = filtered.filter(s => s.is_active === false);
if (filterAgent) filtered = filtered.filter(s => (s.agent_id || s.agent || '') === filterAgent);
container.innerHTML = `
<!-- Controls -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;gap:12px;flex-wrap:wrap">
<div style="display:flex;gap:6px;align-items:center">
${['all','active','closed'].map(s => `<button class="sess-filter-btn" data-filter="${s}" style="padding:5px 14px;border:1px solid var(--db-border);background:${filterStatus===s?'var(--db-accent)':'transparent'};color:${filterStatus===s?'#fff':'var(--db-text)'};border-radius:6px;cursor:pointer;font-size:12px;font-weight:500">${s.charAt(0).toUpperCase()+s.slice(1)}</button>`).join('')}
</div>
<div style="display:flex;gap:8px;align-items:center">
<select id="sess-agent-filter" style="padding:5px 10px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:12px">
<option value="">All Agents</option>
${agentNames.map(a => `<option value="${a}"${filterAgent===a?' selected':''}>${a}</option>`).join('')}
</select>
<span style="color:var(--db-text-dim);font-size:13px">${filtered.length} of ${sessions.length} session(s)</span>
</div>
</div>
<!-- Split layout -->
<div style="display:flex;gap:20px;height:calc(100vh - 230px)">
<div id="sess-list" style="width:380px;overflow-y:auto;padding-right:8px"></div>
<div id="sess-detail" style="flex:1;overflow-y:auto">
<div class="db-viewer"><pre>Select a session to view details</pre></div>
</div>
</div>
`;
// Bind filters
container.querySelectorAll('.sess-filter-btn').forEach(btn => {
btn.addEventListener('click', () => { filterStatus = btn.dataset.filter; render(container); });
});
container.querySelector('#sess-agent-filter')?.addEventListener('change', (e) => {
filterAgent = e.target.value;
render(container);
});
// Populate list
const list = container.querySelector('#sess-list');
filtered.forEach(s => {
const item = document.createElement('div');
item.className = 'db-card';
item.style.cssText = 'cursor:pointer;margin-bottom:8px;padding:12px 16px;transition:all .15s';
const id = s.id || s.session_id || 'unknown';
const agent = s.agent_id || s.agent || '';
const active = s.is_active !== false;
const created = s.created_at ? new Date(typeof s.created_at === 'number' ? s.created_at * 1000 : s.created_at).toLocaleString() : '';
const label = s.label || s.name || '';
const tokens = (s.input_tokens || 0) + (s.output_tokens || 0);
item.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span style="font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${label || id.substring(0,12) + '…'}</span>
<span style="font-size:10px;padding:2px 6px;border-radius:8px;flex-shrink:0;${active ? 'background:rgba(34,197,94,.15);color:#22c55e' : 'background:rgba(239,68,68,.15);color:#ef4444'}">${active ? 'active' : 'closed'}</span>
</div>
<div style="display:flex;gap:4px;flex-shrink:0">
<button class="sess-chat-link" data-id="${id}" style="font-size:10px;padding:2px 8px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:4px;cursor:pointer" title="Open in Chat">💬</button>
<button class="sess-delete-btn" data-id="${id}" style="font-size:10px;padding:2px 8px;border:1px solid rgba(239,68,68,.3);background:transparent;color:#ef4444;border-radius:4px;cursor:pointer" title="Delete">✕</button>
</div>
</div>
${agent ? `<div style="font-size:11px;color:var(--db-text-dim);margin-top:4px">🤖 ${agent}</div>` : ''}
<div style="font-size:11px;color:var(--db-text-dim);margin-top:2px;display:flex;gap:12px">
${created ? `<span>${created}</span>` : ''}
${tokens > 0 ? `<span>📊 ${tokens.toLocaleString()} tokens</span>` : ''}
</div>
`;
item.addEventListener('click', (e) => {
if (e.target.closest('.sess-delete-btn') || e.target.closest('.sess-chat-link')) return;
showSessionDetail(container, s);
list.querySelectorAll('.db-card').forEach(c => c.style.borderColor = '');
item.style.borderColor = 'var(--db-accent)';
});
list.appendChild(item);
});
if (filtered.length === 0) list.innerHTML = '<div class="db-viewer"><pre>No sessions match filters</pre></div>';
// Delete buttons
container.querySelectorAll('.sess-delete-btn').forEach(b => {
b.addEventListener('click', async (e) => {
e.stopPropagation();
if (!await showConfirm('Delete Session', 'Delete this session?')) return;
try { await fetch(`/api/sessions/${b.dataset.id}`, {method:'DELETE'}); render(container); } catch(e) {}
});
});
// Chat link buttons
container.querySelectorAll('.sess-chat-link').forEach(b => {
b.addEventListener('click', (e) => {
e.stopPropagation();
// Navigate to chat page with session ID
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: { page: 'chat', session: b.dataset.id } }));
});
});
}
async function showSessionDetail(container, session) {
const detail = container.querySelector('#sess-detail');
const id = session.id || session.session_id;
detail.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let state = {}, preview = {}, labels = {}, usage = {};
try { const r = await fetch(`/api/sessions/${id}/state`); state = await r.json(); } catch(e) {}
try { const r = await fetch(`/api/sessions/${id}/preview`); preview = await r.json(); } catch(e) {}
try { const r = await fetch(`/api/sessions/${id}/labels`); labels = await r.json(); } catch(e) {}
try { const r = await fetch(`/api/sessions/${id}/usage`); usage = await r.json(); } catch(e) {}
const currentLabel = labels.label || session.label || session.name || '';
detail.innerHTML = `
<h3 style="font-size:16px;margin:0 0 16px">Session: ${id}</h3>
<!-- Inline Label Editor -->
<div class="db-card" style="margin-bottom:16px;padding:14px 18px">
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:12px;color:var(--db-text-dim);font-weight:600">Label:</span>
<input id="sess-label-input" value="${currentLabel}" placeholder="Add a label…" style="flex:1;padding:6px 10px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:13px">
<button id="sess-save-label" style="padding:6px 14px;background:var(--db-accent);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px">Save</button>
</div>
</div>
<!-- Usage Stats -->
${usage && (usage.input_tokens || usage.output_tokens || usage.cost) ? `
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin-bottom:16px">
<div class="db-card"><div class="db-card-title">Input Tokens</div><div style="font-size:20px;font-weight:700;margin-top:6px">${(usage.input_tokens || 0).toLocaleString()}</div></div>
<div class="db-card"><div class="db-card-title">Output Tokens</div><div style="font-size:20px;font-weight:700;margin-top:6px">${(usage.output_tokens || 0).toLocaleString()}</div></div>
<div class="db-card"><div class="db-card-title">Cost</div><div style="font-size:20px;font-weight:700;margin-top:6px">$${(usage.cost || 0).toFixed(4)}</div></div>
</div>` : ''}
<!-- State & Preview -->
<div class="db-columns" style="grid-template-columns:1fr 1fr;margin-bottom:20px">
<div class="db-card">
<div class="db-card-title">State</div>
<pre style="font-size:12px;color:var(--db-text-dim);margin:8px 0 0;white-space:pre-wrap;max-height:300px;overflow-y:auto">${JSON.stringify(state, null, 2)}</pre>
</div>
<div class="db-card">
<div class="db-card-title">Preview</div>
<pre style="font-size:12px;color:var(--db-text-dim);margin:8px 0 0;white-space:pre-wrap;max-height:300px;overflow-y:auto">${JSON.stringify(preview, null, 2)}</pre>
</div>
</div>
<!-- Actions -->
<div style="display:flex;gap:8px">
<button id="sess-compact" style="font-size:12px;padding:6px 14px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">📦 Compact</button>
<button id="sess-reset" style="font-size:12px;padding:6px 14px;border:1px solid rgba(239,68,68,0.3);background:transparent;color:#ef4444;border-radius:6px;cursor:pointer">🗑 Reset Session</button>
</div>
`;
// Save label
detail.querySelector('#sess-save-label')?.addEventListener('click', async () => {
const label = detail.querySelector('#sess-label-input').value.trim();
try {
await fetch(`/api/sessions/${id}/labels`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({label}) });
render(container);
} catch(e) {}
});
detail.querySelector('#sess-reset')?.addEventListener('click', async () => {
if (!await showConfirm('Reset Session', 'Reset this session? All messages will be cleared.')) return;
try { await fetch(`/api/sessions/${id}/reset`, {method:'POST'}); render(container); } catch(e) {}
});
detail.querySelector('#sess-compact')?.addEventListener('click', async () => {
try { await fetch(`/api/sessions/${id}/compact`, {method:'POST'}); showSessionDetail(container, session); } catch(e) {}
});
container.setAttribute('data-page', 'sessions');
container.classList.add('sessions-page');
const mod = await import('../sessions.js');
if (mod.default?.init) await mod.default.init();
const root = container.closest('#root') || document.getElementById('root');
if (mod.default?.onContentChange && root) mod.default.onContentChange(root);
}
@@ -1,203 +1,8 @@
/**
* Usage View — Token/cost analytics with charts, filters, and export.
*
* Enhanced with: date-range presets, model/agent filters, session drill-down,
* CSV export, and improved visualizations.
*
* API: /api/usage/summary, /api/usage/models, /api/usage/timeseries,
* /api/usage/agents, /api/usage/sessions
*/
let activeRange = '7d';
let filterModel = '';
let filterAgent = '';
/** Usage view — delegates to usage dashboard module. */
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let summary = {}, models = {}, timeseries = [], agents = {}, sessions = {};
try { const r = await fetch('/api/usage/summary'); summary = (await r.json()).usage || await r.json(); } catch(e) {}
try { const r = await fetch('/api/usage/models'); models = (await r.json()).models || await r.json(); } catch(e) {}
try { const r = await fetch('/api/usage/timeseries'); timeseries = (await r.json()).data || []; } catch(e) {}
try { const r = await fetch('/api/usage/agents'); agents = (await r.json()).agents || await r.json(); } catch(e) {}
try { const r = await fetch('/api/usage/sessions'); sessions = (await r.json()).sessions || await r.json(); } catch(e) {}
const totalCost = summary.total_cost || 0;
const totalInput = summary.total_input_tokens || 0;
const totalOutput = summary.total_output_tokens || 0;
const totalReqs = summary.total_requests || 0;
const modelData = summary.by_model || models || {};
const agentData = typeof agents === 'object' ? agents : {};
const sessionData = typeof sessions === 'object' ? sessions : {};
// Build model and agent lists for filters
const modelNames = Object.keys(modelData);
const agentNames = Object.keys(agentData);
container.innerHTML = `
<!-- Controls Bar -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;gap:12px;flex-wrap:wrap">
<div style="display:flex;gap:6px;align-items:center">
${['1d','7d','30d','all'].map(r => `<button class="usage-range-btn${activeRange===r?' active':''}" data-range="${r}" style="padding:5px 14px;border:1px solid var(--db-border);background:${activeRange===r?'var(--db-accent)':'transparent'};color:${activeRange===r?'#fff':'var(--db-text)'};border-radius:6px;cursor:pointer;font-size:12px;font-weight:500">${r === 'all' ? 'All' : r}</button>`).join('')}
</div>
<div style="display:flex;gap:8px;align-items:center">
<select id="usage-filter-model" style="padding:5px 10px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:12px">
<option value="">All Models</option>
${modelNames.map(m => `<option value="${m}"${filterModel===m?' selected':''}>${m}</option>`).join('')}
</select>
<select id="usage-filter-agent" style="padding:5px 10px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:6px;color:var(--db-text);font-size:12px">
<option value="">All Agents</option>
${agentNames.map(a => `<option value="${a}"${filterAgent===a?' selected':''}>${a}</option>`).join('')}
</select>
<button id="usage-export" style="padding:5px 14px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer;font-size:12px">📥 Export CSV</button>
</div>
</div>
<!-- Summary Cards -->
<div class="db-columns" style="grid-template-columns:repeat(4,1fr);margin-bottom:24px">
<div class="db-card"><div class="db-card-title">Total Cost</div><div class="db-card-value" style="font-size:28px;font-weight:700;margin:8px 0">$${totalCost.toFixed(4)}</div><div style="font-size:11px;color:var(--db-text-dim)">USD</div></div>
<div class="db-card"><div class="db-card-title">Requests</div><div class="db-card-value" style="font-size:28px;font-weight:700;margin:8px 0">${totalReqs.toLocaleString()}</div><div style="font-size:11px;color:var(--db-text-dim)">total API calls</div></div>
<div class="db-card"><div class="db-card-title">Input Tokens</div><div class="db-card-value" style="font-size:28px;font-weight:700;margin:8px 0">${formatNum(totalInput)}</div><div style="font-size:11px;color:var(--db-text-dim)">prompt tokens</div></div>
<div class="db-card"><div class="db-card-title">Output Tokens</div><div class="db-card-value" style="font-size:28px;font-weight:700;margin:8px 0">${formatNum(totalOutput)}</div><div style="font-size:11px;color:var(--db-text-dim)">completion tokens</div></div>
</div>
<!-- Charts Row -->
<div class="db-columns" style="grid-template-columns:1fr 1fr;margin-bottom:24px">
<div class="db-card">
<div class="db-card-title">Cost by Model</div>
<div id="usage-model-chart" style="margin-top:12px"></div>
</div>
<div class="db-card">
<div class="db-card-title">Tokens Over Time</div>
<div id="usage-time-chart" style="margin-top:12px"></div>
</div>
</div>
<!-- Per-Model Table -->
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Per-Model Breakdown</h3>
<div class="db-viewer" style="margin-bottom:24px">
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tr>
<th style="text-align:left;padding:8px 12px;border-bottom:2px solid var(--db-border);color:var(--db-text-dim);font-weight:600;font-size:11px;text-transform:uppercase">Model</th>
<th style="text-align:right;padding:8px 12px;border-bottom:2px solid var(--db-border);color:var(--db-text-dim);font-weight:600;font-size:11px;text-transform:uppercase">Requests</th>
<th style="text-align:right;padding:8px 12px;border-bottom:2px solid var(--db-border);color:var(--db-text-dim);font-weight:600;font-size:11px;text-transform:uppercase">Input</th>
<th style="text-align:right;padding:8px 12px;border-bottom:2px solid var(--db-border);color:var(--db-text-dim);font-weight:600;font-size:11px;text-transform:uppercase">Output</th>
<th style="text-align:right;padding:8px 12px;border-bottom:2px solid var(--db-border);color:var(--db-text-dim);font-weight:600;font-size:11px;text-transform:uppercase">Cost</th>
<th style="text-align:right;padding:8px 12px;border-bottom:2px solid var(--db-border);color:var(--db-text-dim);font-weight:600;font-size:11px;text-transform:uppercase">% Total</th>
</tr>
${Object.entries(modelData).map(([m, d]) => {
const cost = d.cost || 0;
const pct = totalCost > 0 ? (cost / totalCost * 100).toFixed(1) : '0.0';
return `<tr style="transition:background .1s" onmouseenter="this.style.background='rgba(var(--db-accent-rgb,100,100,255),.05)'" onmouseleave="this.style.background=''">
<td style="padding:8px 12px;border-bottom:1px solid var(--db-border);font-weight:500">${m}</td>
<td style="text-align:right;padding:8px 12px;border-bottom:1px solid var(--db-border)">${(d.requests || d.count || 0).toLocaleString()}</td>
<td style="text-align:right;padding:8px 12px;border-bottom:1px solid var(--db-border)">${formatNum(d.input_tokens || 0)}</td>
<td style="text-align:right;padding:8px 12px;border-bottom:1px solid var(--db-border)">${formatNum(d.output_tokens || 0)}</td>
<td style="text-align:right;padding:8px 12px;border-bottom:1px solid var(--db-border);font-weight:600">$${cost.toFixed(4)}</td>
<td style="text-align:right;padding:8px 12px;border-bottom:1px solid var(--db-border)">
<span style="display:inline-block;width:40px;text-align:right;margin-right:6px">${pct}%</span>
<span style="display:inline-block;width:60px;height:4px;background:var(--db-border);border-radius:2px;vertical-align:middle"><span style="display:block;height:100%;width:${pct}%;background:var(--db-accent);border-radius:2px"></span></span>
</td>
</tr>`;
}).join('') || '<tr><td colspan="6" style="padding:8px 12px;color:var(--db-text-dim)">No usage data</td></tr>'}
</table>
</div>
<!-- Per-Agent Cards -->
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Per-Agent Usage</h3>
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(200px,1fr));margin-bottom:24px">
${Object.entries(agentData).map(([name, d]) => `
<div class="db-card" style="padding:14px 18px">
<div style="font-weight:500;font-size:13px">🤖 ${name}</div>
<div style="font-size:24px;font-weight:700;margin:8px 0">$${(d.cost || 0).toFixed(4)}</div>
<div style="font-size:11px;color:var(--db-text-dim)">${(d.requests || d.count || 0)} requests · ${formatNum((d.input_tokens||0)+(d.output_tokens||0))} tokens</div>
</div>
`).join('') || '<div class="db-viewer"><pre>No agent usage data</pre></div>'}
</div>
<!-- Per-Session Drill-Down -->
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Per-Session Breakdown</h3>
<div class="db-viewer" style="margin-bottom:24px">
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tr>
<th style="text-align:left;padding:8px 12px;border-bottom:2px solid var(--db-border);color:var(--db-text-dim);font-weight:600;font-size:11px;text-transform:uppercase">Session</th>
<th style="text-align:right;padding:8px 12px;border-bottom:2px solid var(--db-border);color:var(--db-text-dim);font-weight:600;font-size:11px;text-transform:uppercase">Requests</th>
<th style="text-align:right;padding:8px 12px;border-bottom:2px solid var(--db-border);color:var(--db-text-dim);font-weight:600;font-size:11px;text-transform:uppercase">Tokens</th>
<th style="text-align:right;padding:8px 12px;border-bottom:2px solid var(--db-border);color:var(--db-text-dim);font-weight:600;font-size:11px;text-transform:uppercase">Cost</th>
</tr>
${Object.entries(sessionData).slice(0, 20).map(([id, d]) => `
<tr style="transition:background .1s" onmouseenter="this.style.background='rgba(var(--db-accent-rgb,100,100,255),.05)'" onmouseleave="this.style.background=''">
<td style="padding:8px 12px;border-bottom:1px solid var(--db-border);font-family:monospace;font-size:12px">${id.length > 12 ? id.substring(0,12) + '…' : id}</td>
<td style="text-align:right;padding:8px 12px;border-bottom:1px solid var(--db-border)">${(d.requests || d.count || 0).toLocaleString()}</td>
<td style="text-align:right;padding:8px 12px;border-bottom:1px solid var(--db-border)">${formatNum((d.input_tokens||0) + (d.output_tokens||0))}</td>
<td style="text-align:right;padding:8px 12px;border-bottom:1px solid var(--db-border);font-weight:600">$${(d.cost || 0).toFixed(4)}</td>
</tr>
`).join('') || '<tr><td colspan="4" style="padding:8px 12px;color:var(--db-text-dim)">No session data</td></tr>'}
</table>
</div>
`;
// Render charts
renderBarChart(container.querySelector('#usage-model-chart'), modelData, 'cost');
renderTimeSeries(container.querySelector('#usage-time-chart'), timeseries);
// Bind date-range buttons
container.querySelectorAll('.usage-range-btn').forEach(btn => {
btn.addEventListener('click', () => {
activeRange = btn.dataset.range;
render(container);
});
});
// Bind filter selects
container.querySelector('#usage-filter-model')?.addEventListener('change', (e) => {
filterModel = e.target.value;
render(container);
});
container.querySelector('#usage-filter-agent')?.addEventListener('change', (e) => {
filterAgent = e.target.value;
render(container);
});
// Export CSV
container.querySelector('#usage-export')?.addEventListener('click', () => {
let csv = 'Model,Requests,Input Tokens,Output Tokens,Cost\n';
Object.entries(modelData).forEach(([m, d]) => {
csv += `"${m}",${d.requests||d.count||0},${d.input_tokens||0},${d.output_tokens||0},${(d.cost||0).toFixed(4)}\n`;
});
const blob = new Blob([csv], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'praisonai-usage-' + new Date().toISOString().slice(0,10) + '.csv';
a.click();
});
}
function formatNum(n) {
return n >= 1000000 ? `${(n/1000000).toFixed(1)}M` : n >= 1000 ? `${(n/1000).toFixed(1)}K` : String(n);
}
function renderBarChart(el, data, field) {
if (!el || !data) return;
const entries = Object.entries(data);
if (entries.length === 0) { el.innerHTML = '<div style="font-size:12px;color:var(--db-text-dim)">No data</div>'; return; }
const maxVal = Math.max(...entries.map(([,d]) => d[field] || 0)) || 1;
el.innerHTML = entries.map(([m, d]) => {
const val = d[field] || 0;
const pct = (val / maxVal * 100).toFixed(0);
return `<div style="margin-bottom:8px">
<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px"><span>${m}</span><span style="font-weight:600">$${val.toFixed(4)}</span></div>
<div style="height:6px;background:var(--db-border);border-radius:3px"><div style="height:100%;width:${pct}%;background:var(--db-accent);border-radius:3px;transition:width .3s"></div></div>
</div>`;
}).join('');
}
function renderTimeSeries(el, data) {
if (!el || !data || data.length === 0) { if(el) el.innerHTML = '<div style="font-size:12px;color:var(--db-text-dim)">No time series data</div>'; return; }
const maxTokens = Math.max(...data.map(d => (d.input_tokens || 0) + (d.output_tokens || 0))) || 1;
el.innerHTML = `<div style="display:flex;align-items:flex-end;gap:2px;height:80px">${data.slice(-24).map(d => {
const total = (d.input_tokens || 0) + (d.output_tokens || 0);
const pct = (total / maxTokens * 100).toFixed(0);
return `<div title="${d.hour || d.time || ''}: ${total} tokens" style="flex:1;height:${pct}%;min-height:2px;background:var(--db-accent);border-radius:2px 2px 0 0;opacity:0.8;transition:height .3s"></div>`;
}).join('')}</div>`;
container.setAttribute('data-page', 'usage');
const mod = await import('../usage.js');
if (mod.default?.init) await mod.default.init();
const root = container.closest('#root') || document.getElementById('root');
if (mod.default?.onContentChange && root) mod.default.onContentChange(root);
}
+53
View File
@@ -0,0 +1,53 @@
"""Dashboard module and jobs config tests."""
from __future__ import annotations
import pytest
@pytest.fixture
def dashboard_source():
path = (
__import__("pathlib").Path(__file__).resolve().parents[2]
/ "src"
/ "praisonaiui"
/ "templates"
/ "frontend"
/ "plugins"
/ "dashboard.js"
)
return path.read_text(encoding="utf-8")
@pytest.fixture
def jobs_source():
path = (
__import__("pathlib").Path(__file__).resolve().parents[2]
/ "src"
/ "praisonaiui"
/ "templates"
/ "frontend"
/ "plugins"
/ "jobs.js"
)
return path.read_text(encoding="utf-8")
def test_builtin_views_include_auth_and_api(dashboard_source):
assert "auth:" in dashboard_source
assert "api:" in dashboard_source
assert "/plugins/views/auth.js" in dashboard_source
assert "/plugins/views/api.js" in dashboard_source
def test_jobs_js_reads_ui_config(jobs_source):
assert "loadJobsConfig" in jobs_source
assert "/ui-config.json" in jobs_source
assert "jobsUrl" in jobs_source
assert "normalizeJob" in jobs_source
def test_view_wrappers_delegate(dashboard_source):
"""Views should exist in BUILTIN_VIEWS for modular pages."""
for page_id in ("agents", "sessions", "schedules", "config", "channels", "logs", "usage", "jobs"):
assert page_id in dashboard_source or f"'{page_id}'" in dashboard_source