From 875a66b0a4a661bd2970fc5520a0140b49f0dc7b Mon Sep 17 00:00:00 2001 From: k2safe Date: Mon, 21 Sep 2026 02:49:33 +0800 Subject: [PATCH] feat: add console workflow and subscription controls --- docs/08-development-plan.md | 2 +- docs/console.md | 4 +- internal/transport/httpserver/console/app.js | 62 ++++++++++++++++--- .../transport/httpserver/console/index.html | 24 +++++++ .../transport/httpserver/console/styles.css | 1 + 5 files changed, 83 insertions(+), 10 deletions(-) diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 4f91030..60b9090 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -38,7 +38,7 @@ 已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份和跨命名空间聚合仍不在控制台内提供。 -控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。流程实例详情已接入当前视图、步骤状态和有界事件时间线;结果订阅详情已接入配置、积压窗口和交付历史,权限不足时保留明确提示,不把页面操作视为绕过服务端事务边界的快捷入口。 +控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。流程实例详情已接入当前视图、步骤状态和有界事件时间线,并支持带 `control_revision` 的暂停、恢复和取消;结果订阅详情已接入配置、积压窗口和交付历史,并支持启停及 EXPIRED 交付追加预算重试。权限不足时保留明确提示,不把页面操作视为绕过服务端事务边界的快捷入口。 定时计划详情已接入当前版本、规则、下一次触发和 occurrence 历史;暂停/恢复沿用 `schedules:manage`、`expected_revision`、原因和幂等键。计划发布、跨命名空间管理身份和更高风险的交付控制仍按独立权限模型推进。 diff --git a/docs/console.md b/docs/console.md index 5c8cdf5..bfa78d5 100644 --- a/docs/console.md +++ b/docs/console.md @@ -16,6 +16,8 @@ http://127.0.0.1:8080/console/ - 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。 - 点击流程实例查看当前权威状态、固定版本、步骤状态和事件时间线;步骤读取沿用 `workflows:read`,事件时间线没有 `workflows:audit:read` 时会明确显示权限提示。 - 点击结果订阅查看冻结配置、游标、积压窗口和最近交付历史;页面不展示租约秘密,也不绕过 `events:read` / `events:manage` 权限。 +- 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。 +- 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。 - 左侧五个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows` 和 `#/subscriptions` 定位;切换视图不会把所有模块堆在一张长页面上。 - 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。 - 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。 @@ -34,4 +36,4 @@ http://127.0.0.1:8080/console/ 控制台不直接访问数据库,也不绕过 Bearer Token。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台不创建独立管理员身份,不支持跨命名空间聚合,不代替平台的权限与审计 API。 -不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、订阅交付重试和独立管理身份仍应通过受保护 API/SDK 设计后再接入页面。 +不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、流程人工核查恢复、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。 diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js index 7ca2204..94924b9 100644 --- a/internal/transport/httpserver/console/app.js +++ b/internal/transport/httpserver/console/app.js @@ -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 = '
◷
当前没有定时计划需要 schedules:read 权限
'; return; } el.innerHTML = items.map(item => `
◷
${escapeHTML(item.name || shortID(item.id))}${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}
${escapeHTML(item.paused ? '已暂停' : '运行中')}
`).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 = '
正在读取步骤…
'; $('workflow-events').innerHTML = '
正在读取事件…
'; + showWorkflowDrawer(); closeWorkflowAction(); $('workflow-actions').hidden = true; text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', shortID(id)); text('workflow-detail-state', '读取中'); $('workflow-steps').innerHTML = '
正在读取步骤…
'; $('workflow-events').innerHTML = '
正在读取事件…
'; 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 => `
${escapeHTML(item.state || 'UNKNOWN')} · 序号 ${escapeHTML(item.sequence || '—')}${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}
${escapeHTML(shortID(item.event_id))}
`); + renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `
${escapeHTML(item.state || 'UNKNOWN')} · 序号 ${escapeHTML(item.sequence || '—')}${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}
${String(item.state || '') === 'EXPIRED' ? `` : `${escapeHTML(shortID(item.event_id))}`}
`); + $('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 = '
正在读取交付历史…
'; + showSubscriptionDrawer(); closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', shortID(id)); $('subscription-deliveries').innerHTML = '
正在读取交付历史…
'; 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); })); diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index c43551c..25b5149 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -196,7 +196,15 @@
步骤状态加载中
正在读取步骤…
事件时间线加载中
正在读取事件…
+

流程详情只读取当前权威视图;事件审计需要 workflows:audit:read 权限,所有控制操作仍由服务端权限和并发版本校验。

+
最近交付加载中
正在读取交付历史…
+

交付详情不展示租约秘密;结果确认与人工重试必须通过受保护的消费或管理接口完成。

+ +
diff --git a/internal/transport/httpserver/console/styles.css b/internal/transport/httpserver/console/styles.css index 05f9c3f..8a5ac90 100644 --- a/internal/transport/httpserver/console/styles.css +++ b/internal/transport/httpserver/console/styles.css @@ -3,3 +3,4 @@ .schedule-row{cursor:pointer;transition:background .2s;border-radius:10px;padding-left:7px;padding-right:7px;margin-left:-7px;margin-right:-7px}.schedule-row:hover,.schedule-row:focus{outline:0;background:rgba(98,230,212,.055)}.schedule-rule-card{padding:14px;border:1px solid rgba(156,183,218,.1);border-radius:12px;background:rgba(4,12,23,.38)}.schedule-rule-card b{display:block;color:#c8e5e6;font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-word}.schedule-rule-card small{display:block;margin-top:7px;color:#7189a1;font-size:9px}.timeline-dot.fired{background:var(--cyan);box-shadow:0 0 0 4px rgba(98,230,212,.1)}.timeline-dot.coalesced{background:var(--purple);box-shadow:0 0 0 4px rgba(178,154,255,.1)}.timeline-dot.skipped{background:var(--orange);box-shadow:0 0 0 4px rgba(255,179,107,.1)} .page-view{display:none}.page-view.active{display:block}.content-grid.single-column{grid-template-columns:1fr}.page-view>.content-grid{min-height:360px}.page-view>.content-grid>.panel{min-height:330px}.page-view>.content-grid>.wide-panel{min-height:420px} .workflow-row,.subscription-row{cursor:pointer;border-radius:10px;padding-left:7px;padding-right:7px;margin-left:-7px;margin-right:-7px;transition:background .2s}.workflow-row:hover,.workflow-row:focus,.subscription-row:hover,.subscription-row:focus{outline:0;background:rgba(98,230,212,.055)}.workflow-row:focus,.subscription-row:focus{box-shadow:inset 2px 0 0 var(--cyan)}.timeline-dot.leased{background:var(--blue);box-shadow:0 0 0 4px rgba(112,166,255,.1)}.timeline-dot.acked{background:var(--cyan);box-shadow:0 0 0 4px rgba(98,230,212,.1)}.timeline-dot.expired{background:var(--orange);box-shadow:0 0 0 4px rgba(255,179,107,.1)}.timeline-dot.cancelled{background:var(--red);box-shadow:0 0 0 4px rgba(255,120,140,.1)}.workflow-drawer,.subscription-drawer{width:min(640px,100%)} +.detail-row-action{border:1px solid rgba(255,179,107,.28);border-radius:8px;padding:6px 9px;color:var(--orange);background:rgba(255,179,107,.07);font:10px inherit;cursor:pointer;white-space:nowrap}.detail-row-action:hover{border-color:rgba(255,179,107,.65);color:#ffe0bb;background:rgba(255,179,107,.13)}