diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 60b9090..96dcee5 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 提交最终结论。流程实例详情已接入当前视图、步骤状态和有界事件时间线,并支持带 `control_revision` 的暂停、恢复和取消;结果订阅详情已接入配置、积压窗口和交付历史,并支持启停及 EXPIRED 交付追加预算重试。权限不足时保留明确提示,不把页面操作视为绕过服务端事务边界的快捷入口。 +控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。流程实例详情已接入当前视图、步骤状态和有界事件时间线,并支持带 `control_revision` 的暂停、恢复和取消;对 `NEEDS_REVIEW` 实例进一步提供六类带证据的恢复入口(任务核查、步骤结论、继续、步骤重试、PENDING 接续、等待重建),页面只组装协议请求,当前修订、激活身份、权限、审计和状态门控由服务端负责。结果订阅详情已接入配置、积压窗口和交付历史,并支持启停及 EXPIRED 交付追加预算重试。权限不足时保留明确提示,不把页面操作视为绕过服务端事务边界的快捷入口。 定时计划详情已接入当前版本、规则、下一次触发和 occurrence 历史;暂停/恢复沿用 `schedules:manage`、`expected_revision`、原因和幂等键。计划发布、跨命名空间管理身份和更高风险的交付控制仍按独立权限模型推进。 diff --git a/docs/console.md b/docs/console.md index bfa78d5..c5c72dc 100644 --- a/docs/console.md +++ b/docs/console.md @@ -17,6 +17,7 @@ http://127.0.0.1:8080/console/ - 点击流程实例查看当前权威状态、固定版本、步骤状态和事件时间线;步骤读取沿用 `workflows:read`,事件时间线没有 `workflows:audit:read` 时会明确显示权限提示。 - 点击结果订阅查看冻结配置、游标、积压窗口和最近交付历史;页面不展示租约秘密,也不绕过 `events:read` / `events:manage` 权限。 - 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。 +- `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。 - 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。 - 左侧五个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows` 和 `#/subscriptions` 定位;切换视图不会把所有模块堆在一张长页面上。 - 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。 @@ -36,4 +37,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 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。 diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js index 94924b9..7df09f0 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, workflowActionMode: '', selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null }; + const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, 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: '结果订阅' }; @@ -217,7 +217,8 @@ } function showWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = false; document.body.classList.add('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 closeWorkflowRecovery() { state.workflowRecoveryMode = ''; state.workflowRecoveryContext = null; $('workflow-recovery-form').hidden = true; $('workflow-recovery-error').textContent = ''; } + function closeWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeWorkflowAction(); closeWorkflowRecovery(); } function showSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = false; document.body.classList.add('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 = ''; } @@ -236,6 +237,84 @@ catch (error) { $('workflow-action-error').textContent = error.message || '流程控制失败'; } finally { button.disabled = false; } } + const workflowRecoveryLabels = { + 'task-review': ['核查任务结果', '先核实任务执行已经结清,再记录这次执行的最终结论。'], + 'step-resolve': ['确认步骤结论', '确认当前步骤的逻辑结果;步骤结果不会自动解除流程核查。'], + 'continue': ['解除流程核查', '确认全部核查事项已处理,再允许流程继续推进。'], + 'step-retry': ['安全重试步骤', '仅在确认旧执行不会继续产生外部效果时,创建新的步骤激活。'], + 'poll-continue': ['接续 PENDING 查询', '确认权威结果仍为 PENDING,沿原查询间隔继续,不重复查询同一轮。'], + 'wait-rearm': ['重建等待代次', '确认当前等待事实后重建 SIGNAL 或 TIMER 等待代次,原截止时间不延长。'], + }; + function recoveryStepState(step) { return String(step?.state || '').toUpperCase(); } + function renderWorkflowRecovery(workflow, payload, error) { + const block = $('workflow-recovery-block'); const actions = $('workflow-recovery-actions'); + block.hidden = workflow?.state !== 'NEEDS_REVIEW'; actions.innerHTML = ''; + if (block.hidden) return; + if (error) { text('workflow-recovery-hint', '步骤读取失败'); actions.innerHTML = '
当前无法读取步骤,暂不提供恢复按钮。
'; return; } + text('workflow-recovery-hint', '需要 workflows:recover · 所有操作进入审计'); + const items = Array.isArray(payload?.steps) ? payload.steps : []; + const buttons = [{ mode: 'continue', label: '解除流程核查', hint: '全部事项已处理' }]; + items.forEach(step => { + const kind = String(step.kind || '').toUpperCase(); const status = recoveryStepState(step); const context = { step_key: step.key || step.id || '', activation_id: step.activation_id || '', expected_step_revision: step.revision || '', task_id: step.task_id || '' }; + if (status !== 'NEEDS_REVIEW') return; + if (context.task_id) buttons.push({ mode: 'task-review', label: `任务核查 · ${step.key || step.id}`, hint: '记录任务最终结论', context }); + buttons.push({ mode: 'step-resolve', label: `步骤结论 · ${step.key || step.id}`, hint: `${kind || 'STEP'} · 记录逻辑结果`, context }); + if (kind === 'TASK' || kind === 'POLL') buttons.push({ mode: 'step-retry', label: `重试步骤 · ${step.key || step.id}`, hint: '确认安全后创建新激活', context }); + if (kind === 'POLL') buttons.push({ mode: 'poll-continue', label: `接续查询 · ${step.key || step.id}`, hint: '确认 PENDING 后接续', context }); + if (kind === 'SIGNAL' || kind === 'TIMER') buttons.push({ mode: 'wait-rearm', label: `重建等待 · ${step.key || step.id}`, hint: '确认等待事实后换代', context }); + }); + actions.innerHTML = buttons.map(button => ``).join(''); + } + async function openWorkflowRecovery(mode, context = {}) { + if (!state.selectedWorkflow || state.selectedWorkflow.state !== 'NEEDS_REVIEW' || !workflowRecoveryLabels[mode]) return; + state.workflowRecoveryMode = mode; state.workflowRecoveryContext = context; + const needsStep = ['step-resolve', 'step-retry', 'poll-continue', 'wait-rearm'].includes(mode); const needsTask = mode === 'task-review'; const needsOutcome = ['task-review', 'step-resolve'].includes(mode); + $('workflow-recovery-form').hidden = false; $('workflow-recovery-error').textContent = ''; $('workflow-recovery-reason').value = ''; $('workflow-recovery-evidence').value = `evidence:console-${Date.now()}`; $('workflow-recovery-confirm').checked = false; + $('workflow-recovery-step-fields').hidden = !needsStep; $('workflow-recovery-task-fields').hidden = !needsTask; $('workflow-recovery-outcome-fields').hidden = !needsOutcome; + $('workflow-recovery-result').value = '{}'; $('workflow-recovery-step-key').value = context.step_key || ''; $('workflow-recovery-activation-id').value = context.activation_id || ''; $('workflow-recovery-step-revision').value = context.expected_step_revision || ''; + $('workflow-recovery-task-id').value = context.task_id || ''; $('workflow-recovery-task-revision').value = ''; + text('workflow-recovery-title', workflowRecoveryLabels[mode][0]); text('workflow-recovery-description', workflowRecoveryLabels[mode][1]); text('workflow-recovery-submit', `${workflowRecoveryLabels[mode][0]} →`); text('workflow-recovery-snapshot', `当前流程修订 ${state.selectedWorkflow.revision || '—'} · ${context.step_key ? `步骤 ${context.step_key}` : '流程级决定'}`); + text('workflow-recovery-confirm-label', mode === 'continue' ? '我确认全部核查事项已经处理,可以解除流程核查。' : '我确认已经核实当前事实,并承担提交此恢复决定的责任。'); + $('workflow-recovery-reason').focus(); + if (needsTask && context.task_id) { + try { const task = extractData(await request(`/v1/tasks/${encodeURIComponent(context.task_id)}`)); if (!$('workflow-recovery-task-revision').value && task.revision) $('workflow-recovery-task-revision').value = task.revision; } + catch { text('workflow-recovery-description', `${workflowRecoveryLabels[mode][1]} 当前任务修订读取失败,请手工填写任务 revision。`); } + } + } + function parseRecoveryResult(value) { + try { return JSON.parse(value); } catch { throw new Error('结果必须是合法 JSON,例如 {"status":"DONE"};没有结果时填写 null。'); } + } + function validRevision(value) { return /^[1-9]\d*$/.test(String(value || '').trim()); } + async function submitWorkflowRecovery(event) { + event.preventDefault(); if (!state.selectedWorkflow || !state.workflowRecoveryMode) return; + const mode = state.workflowRecoveryMode; const workflow = state.selectedWorkflow; const reason = $('workflow-recovery-reason').value.trim(); const evidence = $('workflow-recovery-evidence').value.trim(); + const error = message => { $('workflow-recovery-error').textContent = message; }; + if (!reason) { error('请填写核查原因。'); return; } + if (!evidence || /[\s\x00-\x1f\x7f]/.test(evidence)) { error('证据引用必填,且不能包含空格或控制字符。'); return; } + if (!validRevision(workflow.revision)) { error('当前流程 revision 无效,请重新打开流程详情。'); return; } + if (!$('workflow-recovery-confirm').checked) { error('请先确认本次核查决定。'); return; } + const body = { expected_revision: String(workflow.revision), reason, evidence_ref: evidence }; let path = `/v1/workflows/${encodeURIComponent(workflow.id)}/recovery/${mode}`; + if (mode === 'continue') body.review_resolved = true; + if (['step-resolve', 'step-retry', 'poll-continue', 'wait-rearm'].includes(mode)) { + const stepKey = $('workflow-recovery-step-key').value.trim(); const activationID = $('workflow-recovery-activation-id').value.trim(); const stepRevision = $('workflow-recovery-step-revision').value.trim(); + if (!stepKey || !activationID || !validRevision(stepRevision)) { error('请填写当前步骤 key、激活 ID 和步骤 revision。'); return; } + Object.assign(body, { step_key: stepKey, activation_id: activationID, expected_step_revision: stepRevision }); + if (mode === 'step-resolve') { const result = parseRecoveryResult($('workflow-recovery-result').value.trim()); body.execution_settled = true; body.outcome = $('workflow-recovery-outcome').value; body.result = result; } + if (mode === 'step-retry') body.safe_to_retry = true; + if (mode === 'poll-continue') body.pending_confirmed = true; + if (mode === 'wait-rearm') body.wait_verified = true; + } + if (mode === 'task-review') { + const taskID = $('workflow-recovery-task-id').value.trim(); const taskRevision = $('workflow-recovery-task-revision').value.trim(); const result = parseRecoveryResult($('workflow-recovery-result').value.trim()); + if (!taskID || !validRevision(taskRevision)) { error('请填写待核查任务 ID 和当前任务 revision。'); return; } + if (result === null) { error('任务核查的 result 不能为 null,请填写实际 JSON 结果。'); return; } + Object.assign(body, { task_id: taskID, expected_task_revision: taskRevision, execution_settled: true, outcome: $('workflow-recovery-outcome').value, result }); + } + const button = $('workflow-recovery-submit'); button.disabled = true; error(''); const workflowID = workflow.id; + try { const response = await request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(`workflow-recovery-${mode}`) }, body: JSON.stringify(body) }); showToast(response?.data?.deduplicated ? '已恢复原恢复回执' : `${workflowRecoveryLabels[mode][0]}已提交`); closeWorkflowRecovery(); await refresh(); await openWorkflowDetail(workflowID); } + catch (err) { error(err.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)}`; @@ -259,11 +338,13 @@ async function openWorkflowDetail(id) { if (!id) return; if (!state.token) { showToast('请先连接具备 workflows:read 权限的凭据'); return; } - 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 = '
正在读取事件…
'; + showWorkflowDrawer(); closeWorkflowAction(); closeWorkflowRecovery(); $('workflow-actions').hidden = true; $('workflow-recovery-block').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)); - renderWorkflowSteps(stepsResult.status === 'fulfilled' ? extractData(stepsResult.value) : {}, stepsResult.status === 'rejected'); + const workflow = extractData(workflowResult.value); const stepsPayload = stepsResult.status === 'fulfilled' ? extractData(stepsResult.value) : {}; + renderWorkflowDetail(workflow); + renderWorkflowSteps(stepsPayload, stepsResult.status === 'rejected'); + renderWorkflowRecovery(workflow, stepsPayload, stepsResult.status === 'rejected'); renderWorkflowEvents(eventsResult.status === 'fulfilled' ? extractData(eventsResult.value) : {}, eventsResult.status === 'rejected'); } function renderSubscriptionDetail(subscription) { @@ -336,6 +417,8 @@ } $('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(); }); $('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(); } }); + $('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery); + document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('workflow-recovery-form').hidden) closeWorkflowRecovery(); }); $('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 25b5149..7347d3a 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -196,6 +196,11 @@
步骤状态加载中
正在读取步骤…
事件时间线加载中
正在读取事件…
+

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

+