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:
@@ -61,6 +61,40 @@
|
||||
}
|
||||
.sb-queue-item:hover .btn-small { opacity: 1; }
|
||||
|
||||
/* Collapsed sidebar — show icon only */
|
||||
.sidebar.collapsed .sb-queue-item { justify-content: center; padding: 5px 0; margin: 0 6px; gap: 0; }
|
||||
.sidebar.collapsed .sb-queue-label { display: none; }
|
||||
.sidebar.collapsed .sb-queue-item .btn-small { display: none; }
|
||||
.sidebar.collapsed .sb-queue-badge { display: none !important; }
|
||||
|
||||
/* Unpin button */
|
||||
.sb-queue-unpin {
|
||||
opacity: 0; background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; font-size: 14px; padding: 0 4px; transition: opacity 0.15s;
|
||||
}
|
||||
.sb-queue-item:hover .sb-queue-unpin { opacity: 1; }
|
||||
.sb-queue-unpin:hover { color: var(--text); }
|
||||
.sidebar.collapsed .sb-queue-unpin { display: none; }
|
||||
|
||||
/* Browse workflows button */
|
||||
.sb-queue-browse {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; cursor: pointer; border-radius: 6px;
|
||||
font-size: 12px; color: var(--text-3); transition: background 0.15s;
|
||||
}
|
||||
.sb-queue-browse:hover { background: var(--bg-raised); color: var(--text-2); }
|
||||
.sidebar.collapsed .sb-queue-browse .sb-queue-label { display: none; }
|
||||
.sidebar.collapsed .sb-queue-browse { justify-content: center; padding: 5px 0; margin: 0 6px; gap: 0; }
|
||||
|
||||
/* Browse dialog */
|
||||
.wf-browse-dialog { max-height: 300px; overflow-y: auto; }
|
||||
.wf-browse-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; cursor: pointer; border-radius: 6px;
|
||||
font-size: 13px; transition: background 0.15s;
|
||||
}
|
||||
.wf-browse-row:hover { background: var(--bg-raised); }
|
||||
|
||||
/* ── Team queue panel ────────────────── */
|
||||
|
||||
.wf-queue-panel { padding: 4px 0; }
|
||||
|
||||
128
src/js/workflow-monitor.js
Normal file
128
src/js/workflow-monitor.js
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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">⚡</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">×</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">📋</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]) + '\'>✓</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">⚡</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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user