Changeset 0.35.0 (#209)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-20 09:59:53 +00:00
committed by xcaliber
parent d16bb93177
commit bf8082e69f
37 changed files with 2324 additions and 129 deletions

128
src/js/workflow-monitor.js Normal file
View File

@@ -0,0 +1,128 @@
// workflow-monitor.js — v0.35.0 Workflow Monitoring Dashboard
//
// Admin monitoring tab showing active instances, stage funnels,
// SLA status indicators, and stale instance detection.
export function mountMonitorTab(container, { basePath }) {
container.innerHTML = `
<div class="wf-monitor">
<div class="wf-monitor-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="font-size:16px;font-weight:600">Workflow Monitor</h3>
<button id="monRefresh" class="btn btn-sm btn-outline">Refresh</button>
</div>
<div id="monInstances" style="margin-bottom:24px">Loading…</div>
<div id="monStale" style="margin-bottom:24px"></div>
</div>
`;
let refreshTimer = null;
async function loadInstances() {
try {
const resp = await fetch(`${basePath}/api/v1/admin/workflows/monitor/instances`);
if (!resp.ok) throw new Error(resp.statusText);
const { data } = await resp.json();
renderInstances(data);
} catch (e) {
document.getElementById('monInstances').textContent = 'Failed to load: ' + e.message;
}
}
async function loadStale() {
try {
const resp = await fetch(`${basePath}/api/v1/admin/workflows/monitor/stale`);
if (!resp.ok) return;
const { data } = await resp.json();
renderStale(data);
} catch (_) {}
}
function renderInstances(instances) {
const el = document.getElementById('monInstances');
if (!instances.length) {
el.innerHTML = '<p style="color:var(--text-3)">No active workflow instances.</p>';
return;
}
let html = '<table style="width:100%;border-collapse:collapse;font-size:13px">';
html += '<thead><tr style="border-bottom:2px solid var(--border);text-align:left">';
html += '<th style="padding:8px">Workflow</th><th style="padding:8px">Stage</th>';
html += '<th style="padding:8px">Age</th><th style="padding:8px">SLA</th>';
html += '</tr></thead><tbody>';
for (const inst of instances) {
const age = formatDuration(inst.age_seconds);
const sla = renderSLA(inst);
html += `<tr style="border-bottom:1px solid var(--border)">`;
html += `<td style="padding:8px">${esc(inst.workflow_name)}<br><span style="font-size:11px;color:var(--text-3)">${esc(inst.channel_title)}</span></td>`;
html += `<td style="padding:8px">${esc(inst.stage_name)} <span style="color:var(--text-3)">(${inst.current_stage})</span></td>`;
html += `<td style="padding:8px">${age}</td>`;
html += `<td style="padding:8px">${sla}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
el.innerHTML = html;
}
function renderSLA(inst) {
if (!inst.sla_seconds) return '<span style="color:var(--text-3)">—</span>';
if (inst.sla_breached) {
return '<span style="color:var(--danger,#e74c3c);font-weight:600">BREACHED</span>';
}
if (inst.sla_remaining_seconds != null) {
const remaining = inst.sla_remaining_seconds;
const pct = Math.max(0, remaining / inst.sla_seconds);
const color = pct > 0.5 ? 'var(--success,#2ecc71)' : pct > 0.2 ? 'var(--warning,#f39c12)' : 'var(--danger,#e74c3c)';
return `<span style="color:${color}">${formatDuration(remaining)}</span>`;
}
return '—';
}
function renderStale(instances) {
const el = document.getElementById('monStale');
if (!instances.length) {
el.innerHTML = '';
return;
}
let html = '<h4 style="font-size:14px;color:var(--danger,#e74c3c);margin-bottom:8px">Stale Instances (' + instances.length + ')</h4>';
html += '<ul style="list-style:none;padding:0">';
for (const inst of instances) {
html += `<li style="padding:4px 0;font-size:13px">${esc(inst.workflow_name)}${esc(inst.stage_name)} (${formatDuration(inst.age_seconds)} old)</li>`;
}
html += '</ul>';
el.innerHTML = html;
}
function formatDuration(seconds) {
if (seconds < 60) return seconds + 's';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
return Math.floor(seconds / 86400) + 'd ' + Math.floor((seconds % 86400) / 3600) + 'h';
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
// Initial load
loadInstances();
loadStale();
// Auto-refresh every 30s
refreshTimer = setInterval(() => {
loadInstances();
loadStale();
}, 30000);
document.getElementById('monRefresh')?.addEventListener('click', () => {
loadInstances();
loadStale();
});
// Return cleanup function
return () => {
if (refreshTimer) clearInterval(refreshTimer);
};
}

View File

@@ -37,6 +37,30 @@
this.refresh();
},
// ── Pinned set ────────────────────────
// Only show workflow channels the user has explicitly opened/pinned.
// Stored in localStorage to persist across sessions.
_getPinned() {
try {
return JSON.parse(localStorage.getItem('sb_wf_pinned') || '[]');
} catch (_) { return []; }
},
_pin(channelId) {
var pinned = this._getPinned();
if (pinned.indexOf(channelId) === -1) {
pinned.push(channelId);
localStorage.setItem('sb_wf_pinned', JSON.stringify(pinned));
}
},
_unpin(channelId) {
var pinned = this._getPinned().filter(function(id) { return id !== channelId; });
localStorage.setItem('sb_wf_pinned', JSON.stringify(pinned));
this.refresh();
},
// ── Refresh ─────────────────────────
// Called after loadChats() and on assignment changes.
@@ -45,10 +69,16 @@
var badge = document.getElementById('sbQueueBadge');
if (!body) return;
// 1. Workflow channels from App.chats
var workflows = (App.chats || []).filter(function(c) {
var self = this;
var pinned = this._getPinned();
// 1. Workflow channels — only show pinned ones
var allWorkflows = (App.chats || []).filter(function(c) {
return c.type === 'workflow';
});
var workflows = allWorkflows.filter(function(c) {
return pinned.indexOf(c.id) !== -1;
});
// 2. Claimed assignments (async, best-effort)
var assignments = [];
@@ -56,31 +86,32 @@
var resp = await API.listMyAssignments();
assignments = resp.data || [];
this._myAssignments = assignments;
// Auto-pin channels from assignments
assignments.forEach(function(a) {
if (a.channel_id) self._pin(a.channel_id);
});
} catch (e) {
// Non-critical — show channels even if assignments fail
}
var total = workflows.length + assignments.length;
var unpinnedCount = allWorkflows.length - workflows.length;
if (badge) {
badge.textContent = total;
badge.style.display = total > 0 ? '' : 'none';
}
if (total === 0) {
body.innerHTML = '<div class="sb-section-empty">No active workflows</div>';
return;
}
var html = '';
// Workflow channels
// Workflow channels (pinned only)
workflows.forEach(function(wf) {
var isActive = App.activeId === wf.id;
html += '<div class="sb-queue-item' + (isActive ? ' active' : '') + '" ' +
'data-action="selectChannel" data-args=\'' + JSON.stringify([wf.id]) + '\' ' +
'title="' + esc(wf.title) + '">' +
'<span class="sb-queue-icon"></span>' +
'<span class="sb-queue-icon">&#9889;</span>' +
'<span class="sb-queue-label">' + esc(wf.title || 'Workflow') + '</span>' +
'<button class="sb-queue-unpin" data-action="WorkflowQueue.unpin" data-args=\'' + JSON.stringify([wf.id]) + '\' title="Remove from sidebar">&times;</button>' +
'</div>';
});
@@ -92,19 +123,97 @@
html += '<div class="sb-queue-item" ' +
'data-action="WorkflowQueue.openAssignment" data-args=\'' + JSON.stringify([a.id, a.channel_id]) + '\'>' +
'<span class="sb-queue-icon">📋</span>' +
'<span class="sb-queue-icon">&#128203;</span>' +
'<span class="sb-queue-label">Assignment: Stage ' + a.stage + '</span>' +
'<button class="btn-small" data-action="WorkflowQueue.complete" data-args=\'' + JSON.stringify([a.id]) + '\'></button>' +
'<button class="btn-small" data-action="WorkflowQueue.complete" data-args=\'' + JSON.stringify([a.id]) + '\'>&check;</button>' +
'</div>';
});
}
// Browse button — shows when there are unpinned workflows available
if (unpinnedCount > 0 || total === 0) {
html += '<div class="sb-queue-browse" data-action="WorkflowQueue.browse">' +
'<span class="sb-queue-icon">+</span>' +
'<span class="sb-queue-label">Browse Workflows' + (unpinnedCount > 0 ? ' (' + unpinnedCount + ')' : '') + '</span>' +
'</div>';
}
body.innerHTML = html;
},
// ── Unpin ──────────────────────────
unpin(channelId) {
this._unpin(channelId);
},
// ── Browse dialog ─────────────────────
browse() {
var self = this;
var pinned = this._getPinned();
var allWorkflows = (App.chats || []).filter(function(c) {
return c.type === 'workflow';
});
var unpinned = allWorkflows.filter(function(c) {
return pinned.indexOf(c.id) === -1;
});
if (unpinned.length === 0) {
UI.toast('No available workflows', 'info');
return;
}
// Remove any existing browse overlay
var existing = document.getElementById('wfBrowseOverlay');
if (existing) existing.remove();
var overlay = document.createElement('div');
overlay.id = 'wfBrowseOverlay';
overlay.className = 'confirm-overlay';
var rows = '';
unpinned.forEach(function(wf) {
rows += '<div class="wf-browse-row" data-wf-id="' + wf.id + '">' +
'<span class="sb-queue-icon">&#9889;</span>' +
'<span>' + esc(wf.title || 'Workflow') + '</span>' +
'</div>';
});
overlay.innerHTML =
'<div class="confirm-dialog" style="max-width:400px">' +
'<div class="confirm-header">Browse Workflows</div>' +
'<div class="confirm-body" style="padding:0">' +
'<p style="color:var(--text-2);font-size:13px;margin:0;padding:12px 16px 8px">Select a workflow to add to your sidebar:</p>' +
'<div class="wf-browse-dialog">' + rows + '</div>' +
'</div>' +
'<div class="confirm-footer">' +
'<button class="btn-small" data-action="cancel">Cancel</button>' +
'</div>' +
'</div>';
overlay.addEventListener('click', function(e) {
var row = e.target.closest('.wf-browse-row');
if (row) {
var wfId = row.getAttribute('data-wf-id');
overlay.remove();
self._pin(wfId);
self.refresh();
if (typeof selectChannel === 'function') selectChannel(wfId);
return;
}
if (e.target === overlay || e.target.closest('[data-action="cancel"]')) {
overlay.remove();
}
});
document.body.appendChild(overlay);
},
// ── Open Assignment ─────────────────
openAssignment(assignmentId, channelId) {
this._pin(channelId);
if (typeof selectChannel === 'function') {
selectChannel(channelId);
}