feat: add console workflow and subscription controls

This commit is contained in:
2026-09-21 02:49:33 +08:00
parent e81af2666d
commit 875a66b0a4
5 changed files with 83 additions and 10 deletions

View File

@@ -1,5 +1,5 @@
(() => {
const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, selectedSubscription: null };
const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
const $ = (id) => document.getElementById(id);
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅' };
@@ -80,7 +80,7 @@
});
}
function renderSchedules(items) {
text('metric-schedules', String(items.length));
text('schedule-page-hint', `${items.length} 项 · 点击计划查看详情`);
const el = $('schedule-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>◷</span><div><b>当前没有定时计划</b><small>需要 schedules:read 权限</small></div></div>'; return; }
el.innerHTML = items.map(item => `<div class="list-row schedule-row" data-schedule-id="${escapeHTML(item.id)}" tabindex="0" title="打开计划详情"><div class="list-avatar">◷</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}</small></div><div class="list-meta">${escapeHTML(item.paused ? '已暂停' : '运行中')}</div></div>`).join('');
el.querySelectorAll('.schedule-row').forEach(row => {
@@ -216,14 +216,32 @@
finally { button.disabled = false; }
}
function showWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
function closeWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); }
function closeWorkflowAction() { state.workflowActionMode = ''; $('workflow-action-form').hidden = true; $('workflow-action-error').textContent = ''; }
function closeWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeWorkflowAction(); }
function showSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
function closeSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); }
function closeSubscriptionAction() { state.subscriptionActionMode = ''; $('subscription-action-form').hidden = true; $('subscription-action-error').textContent = ''; }
function closeSubscriptionRetry() { state.selectedDelivery = null; $('subscription-retry-form').hidden = true; $('subscription-retry-error').textContent = ''; }
function closeSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeSubscriptionAction(); closeSubscriptionRetry(); }
function openWorkflowAction(mode) {
if (!state.selectedWorkflow) return;
state.workflowActionMode = mode; $('workflow-action-form').hidden = false; $('workflow-action-error').textContent = ''; $('workflow-action-reason').value = '';
const labels = { PAUSE: ['暂停流程', '暂停会停止推进新步骤,但已经发出的任务和结果仍会被收集。'], RESUME: ['恢复流程', '恢复只解除暂停,不清除人工核查、取消意图或延长原截止时间。'], CANCEL: ['取消流程', '取消会保存持久意图并停止后继;在途工作仍需结清,未知效果会进入核查。'] };
text('workflow-action-title', labels[mode][0]); text('workflow-action-description', labels[mode][1]); text('workflow-action-submit', mode === 'CANCEL' ? '提交取消 →' : '提交控制 →'); $('workflow-action-reason').focus();
}
async function submitWorkflowAction(event) {
event.preventDefault(); if (!state.selectedWorkflow || !state.workflowActionMode) return;
const reason = $('workflow-action-reason').value.trim(); if (!reason) { $('workflow-action-error').textContent = '请填写操作原因。'; return; }
const workflow = state.selectedWorkflow; const button = $('workflow-action-submit'); button.disabled = true; $('workflow-action-error').textContent = '';
try { await request(`/v1/workflows/${encodeURIComponent(workflow.id)}/control`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(`workflow-${state.workflowActionMode.toLowerCase()}`) }, body: JSON.stringify({ operation: state.workflowActionMode, expected_control_revision: String(workflow.control_revision), reason }) }); showToast(state.workflowActionMode === 'CANCEL' ? '流程取消意图已提交' : (state.workflowActionMode === 'PAUSE' ? '流程已暂停' : '流程已恢复')); closeWorkflowAction(); await refresh(); await openWorkflowDetail(workflow.id); }
catch (error) { $('workflow-action-error').textContent = error.message || '流程控制失败'; }
finally { button.disabled = false; }
}
function renderWorkflowDetail(workflow) {
state.selectedWorkflow = workflow;
text('workflow-detail-title', workflow.business_key || '流程实例'); text('workflow-detail-id', workflow.id || '—'); text('workflow-detail-state', workflow.state || 'UNKNOWN'); $('workflow-detail-state').className = `state-pill ${stateClass(workflow.state)}`;
text('workflow-detail-updated', `最后更新 ${formatTime(workflow.updated_at)}`); text('workflow-detail-definition', workflow.definition_id || '—'); text('workflow-detail-version', workflow.version || '—'); text('workflow-detail-revision', workflow.revision || '—'); text('workflow-detail-control-revision', workflow.control_revision || '—'); text('workflow-detail-business-key', workflow.business_key || '—'); text('workflow-detail-deadline', formatTime(workflow.deadline_at));
const outputBlock = $('workflow-output-block'); const hasOutput = Object.prototype.hasOwnProperty.call(workflow, 'output'); outputBlock.hidden = !hasOutput; if (hasOutput) $('workflow-output').textContent = formatJSON(workflow.output) || 'null';
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(String(workflow.state || '')); const canCancel = !terminal && !workflow.cancel_requested_at && !workflow.stop_requested_at; $('workflow-actions').hidden = terminal || (!canCancel && !workflow.paused); $('workflow-control-button').hidden = terminal; $('workflow-cancel-button').hidden = !canCancel; text('workflow-control-button', workflow.paused ? '恢复流程' : '暂停流程'); text('workflow-detail-note', workflow.state === 'NEEDS_REVIEW' ? '流程处于人工核查状态;暂停/恢复/取消不会替代带证据的恢复 API。控制写入仍由服务端校验权限、控制修订和审计。' : '流程控制需要 workflows:control 权限;服务端会再次校验控制修订、状态、幂等键和操作原因。');
}
function renderWorkflowSteps(payload, error) {
const items = Array.isArray(payload?.steps) ? payload.steps : [];
@@ -241,7 +259,7 @@
async function openWorkflowDetail(id) {
if (!id) return;
if (!state.token) { showToast('请先连接具备 workflows:read 权限的凭据'); return; }
showWorkflowDrawer(); text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', shortID(id)); text('workflow-detail-state', '读取中'); $('workflow-steps').innerHTML = '<div class="detail-loading">正在读取步骤…</div>'; $('workflow-events').innerHTML = '<div class="detail-loading">正在读取事件…</div>';
showWorkflowDrawer(); closeWorkflowAction(); $('workflow-actions').hidden = true; text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', shortID(id)); text('workflow-detail-state', '读取中'); $('workflow-steps').innerHTML = '<div class="detail-loading">正在读取步骤…</div>'; $('workflow-events').innerHTML = '<div class="detail-loading">正在读取事件…</div>';
const encoded = encodeURIComponent(id); const [workflowResult, stepsResult, eventsResult] = await Promise.allSettled([request(`/v1/workflows/${encoded}`), request(`/v1/workflows/${encoded}/steps`), request(`/v1/workflows/${encoded}/events?limit=20`)]);
if (workflowResult.status !== 'fulfilled') { text('workflow-detail-title', '流程读取失败'); text('workflow-detail-note', workflowResult.reason?.message || '无法读取流程详情'); renderWorkflowSteps({}, true); renderWorkflowEvents({}, true); return; }
renderWorkflowDetail(extractData(workflowResult.value));
@@ -252,16 +270,44 @@
state.selectedSubscription = subscription;
text('subscription-detail-title', subscription.name || '结果订阅'); text('subscription-detail-id', subscription.id || '—'); text('subscription-detail-state', subscription.enabled === false ? '已暂停' : '已启用'); $('subscription-detail-state').className = `state-pill ${subscription.enabled === false ? 'waiting' : 'succeeded'}`;
text('subscription-detail-updated', `最后更新 ${formatTime(subscription.updated_at)}`); text('subscription-detail-name', subscription.name || '—'); text('subscription-detail-queue', shortID(subscription.queue_id)); text('subscription-detail-revision', subscription.revision || '—'); text('subscription-detail-pending', subscription.pending_count ?? '0'); text('subscription-detail-max-pending', subscription.max_pending ?? '—'); text('subscription-detail-max-attempts', subscription.max_attempts ?? '—'); text('subscription-detail-cursor', subscription.cursor_sequence ?? '0'); text('subscription-detail-types', Array.isArray(subscription.types) && subscription.types.length ? subscription.types.join(', ') : '全部结果类型');
$('subscription-actions').hidden = false; text('subscription-control-button', subscription.enabled === false ? '启用订阅' : '暂停订阅'); text('subscription-detail-note', '订阅控制需要 events:manage 权限;暂停只停止新交付,已有租约仍可确认。过期交付可追加预算重试,但不会重置原次数和事件身份。');
}
function renderSubscriptionDeliveries(payload, error) {
const items = Array.isArray(payload?.items) ? payload.items : [];
text('subscription-deliveries-count', error ? '权限不足' : `${items.length} 条`);
renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.state || 'UNKNOWN')} · 序号 ${escapeHTML(item.sequence || '—')}</b><small>${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}</small></div><em>${escapeHTML(shortID(item.event_id))}</em></div>`);
renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.state || 'UNKNOWN')} · 序号 ${escapeHTML(item.sequence || '—')}</b><small>${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}</small></div>${String(item.state || '') === 'EXPIRED' ? `<button class="detail-row-action" type="button" data-delivery-id="${escapeHTML(item.id)}">重试</button>` : `<em>${escapeHTML(shortID(item.event_id))}</em>`}</div>`);
$('subscription-deliveries').querySelectorAll('.detail-row-action').forEach(button => button.addEventListener('click', event => { event.stopPropagation(); openSubscriptionRetry(button.dataset.deliveryId, items.find(item => item.id === button.dataset.deliveryId)); }));
}
function openSubscriptionAction() {
if (!state.selectedSubscription) return;
state.subscriptionActionMode = state.selectedSubscription.enabled === false ? 'ENABLE' : 'DISABLE'; $('subscription-action-form').hidden = false; $('subscription-action-error').textContent = ''; $('subscription-action-reason').value = '';
const enabled = state.subscriptionActionMode === 'ENABLE'; text('subscription-action-title', enabled ? '启用订阅' : '暂停订阅'); text('subscription-action-description', enabled ? '启用后会继续物化和领取新的结果交付。' : '暂停只停止新交付,已有租约和待确认结果仍可由接收方处理。'); text('subscription-action-submit', enabled ? '提交启用 →' : '提交暂停 →'); $('subscription-action-reason').focus();
}
async function submitSubscriptionAction(event) {
event.preventDefault(); if (!state.selectedSubscription || !state.subscriptionActionMode) return;
const reason = $('subscription-action-reason').value.trim(); if (!reason) { $('subscription-action-error').textContent = '请填写操作原因。'; return; }
const subscription = state.selectedSubscription; const enabled = state.subscriptionActionMode === 'ENABLE'; const button = $('subscription-action-submit'); button.disabled = true; $('subscription-action-error').textContent = '';
try { await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}:configure`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(enabled ? 'subscription-enable' : 'subscription-disable') }, body: JSON.stringify({ expected_revision: String(subscription.revision), enabled, reason }) }); showToast(enabled ? '订阅已启用' : '订阅已暂停'); closeSubscriptionAction(); await refresh(); await openSubscriptionDetail(subscription.id); }
catch (error) { $('subscription-action-error').textContent = error.message || '订阅控制失败'; }
finally { button.disabled = false; }
}
function openSubscriptionRetry(id, delivery) {
if (!state.selectedSubscription || !id || !delivery) return;
state.selectedDelivery = delivery; $('subscription-retry-form').hidden = false; $('subscription-retry-error').textContent = ''; $('subscription-retry-reason').value = ''; $('subscription-retry-attempts').value = '1'; $('subscription-retry-form').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); $('subscription-retry-reason').focus();
}
async function submitSubscriptionRetry(event) {
event.preventDefault(); if (!state.selectedSubscription || !state.selectedDelivery) return;
const reason = $('subscription-retry-reason').value.trim(); if (!reason) { $('subscription-retry-error').textContent = '请填写操作原因。'; return; }
const additionalAttempts = Number($('subscription-retry-attempts').value); if (!Number.isSafeInteger(additionalAttempts) || additionalAttempts < 1 || additionalAttempts > 100) { $('subscription-retry-error').textContent = '追加次数必须是 1 到 100 的整数。'; return; }
const subscription = state.selectedSubscription; const delivery = state.selectedDelivery; const button = $('subscription-retry-submit'); button.disabled = true; $('subscription-retry-error').textContent = '';
try { await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}/deliveries/${encodeURIComponent(delivery.id)}:retry`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey('subscription-delivery-retry') }, body: JSON.stringify({ expected_revision: String(delivery.revision), reason, additional_attempts: additionalAttempts }) }); showToast('过期交付已追加预算并重新排队'); closeSubscriptionRetry(); await refresh(); await openSubscriptionDetail(subscription.id); }
catch (error) { $('subscription-retry-error').textContent = error.message || '交付重试失败'; }
finally { button.disabled = false; }
}
async function openSubscriptionDetail(id) {
if (!id) return;
if (!state.token) { showToast('请先连接具备 events:read 权限的凭据'); return; }
showSubscriptionDrawer(); text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', shortID(id)); $('subscription-deliveries').innerHTML = '<div class="detail-loading">正在读取交付历史…</div>';
showSubscriptionDrawer(); closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', shortID(id)); $('subscription-deliveries').innerHTML = '<div class="detail-loading">正在读取交付历史…</div>';
const encoded = encodeURIComponent(id); const [subscriptionResult, deliveriesResult] = await Promise.allSettled([request(`/v1/subscriptions/${encoded}`), request(`/v1/subscriptions/${encoded}/deliveries?limit=20`)]);
if (subscriptionResult.status !== 'fulfilled') { text('subscription-detail-title', '订阅读取失败'); text('subscription-detail-note', subscriptionResult.reason?.message || '无法读取订阅详情'); renderSubscriptionDeliveries({}, true); return; }
renderSubscriptionDetail(extractData(subscriptionResult.value)); renderSubscriptionDeliveries(deliveriesResult.status === 'fulfilled' ? extractData(deliveriesResult.value) : {}, deliveriesResult.status === 'rejected');
@@ -289,7 +335,7 @@
sessionStorage.setItem('orbit.console.queue', state.queue); refresh();
}
$('connect-button').addEventListener('click', connect); $('connect-hero').addEventListener('click', () => $('connection-panel').scrollIntoView({ behavior: 'smooth', block: 'center' })); $('refresh-button').addEventListener('click', refresh);
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
$('reveal-token').addEventListener('click', () => { const field = $('token'); field.type = field.type === 'password' ? 'text' : 'password'; $('reveal-token').textContent = field.type === 'password' ? '显示' : '隐藏'; });
$('credential-file').addEventListener('change', event => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const credential = JSON.parse(reader.result); $('token').value = credential.token || ''; $('queue-id').value = credential.queue_id || ''; showToast('凭据已读取,只保留在当前页面内'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); });
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));