diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md
index 1711051..3916c40 100644
--- a/docs/08-development-plan.md
+++ b/docs/08-development-plan.md
@@ -36,7 +36,9 @@
### 控制台第一版
-已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份、跨命名空间聚合及写操作在管理 API 与权限模型明确后再接入。
+已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份和跨命名空间聚合仍不在控制台内提供。
+
+控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。下一阶段继续补充分页详情、计划控制和管理身份,不把页面操作视为绕过服务端事务边界的快捷入口。
## 2. 持续推进方式
diff --git a/docs/console.md b/docs/console.md
index 52b6b86..fb8b390 100644
--- a/docs/console.md
+++ b/docs/console.md
@@ -11,6 +11,8 @@ http://127.0.0.1:8080/console/
- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。
- 通过用户选择的本地凭据 JSON 或手工 Token/队列 ID 调用现有授权 API。
- 展示任务、定时计划、流程实例和结果订阅的最近摘要。
+- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
+- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
- 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。
- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
@@ -26,6 +28,6 @@ http://127.0.0.1:8080/console/
## 安全边界
-当前版本是管理查询控制台,不提供独立管理员账号、SSO、跨命名空间聚合或直接操作数据库。所有任务/计划/流程/订阅读取仍经过现有 Bearer Token、动作和队列范围校验;敏感凭据不进入页面源码。
+控制台不直接访问数据库,也不绕过 Bearer Token。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台不创建独立管理员身份,不支持跨命名空间聚合,不代替平台的权限与审计 API。
-下一阶段增加写操作前,应先补独立的管理身份与权限模型,再接入取消、重试、暂停、恢复、交付重试和审计操作。不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。
+不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。后续再补独立管理身份、计划控制和交付重试等更高风险操作。
diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js
index 9330fae..5579f90 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 };
+ const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '' };
const $ = (id) => document.getElementById(id);
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
const sessionQueue = sessionStorage.getItem('orbit.console.queue') || '';
@@ -18,6 +18,7 @@
function shortID(id) { return id && id.length > 14 ? `${id.slice(0, 8)}…${id.slice(-4)}` : (id || '—'); }
function stateClass(value) { return String(value || '').toLowerCase(); }
function extractItems(payload) { return payload?.data?.items || payload?.items || []; }
+ function extractData(payload) { return payload?.data ?? payload ?? {}; }
async function request(path, options = {}) {
const headers = { Accept: 'application/json', ...(options.headers || {}) };
if (state.token) headers.Authorization = `Bearer ${state.token}`;
@@ -59,7 +60,11 @@
text('metric-tasks-sub', `${counts.succeeded || 0} 成功 · ${counts.running || 0} 执行中`);
const body = $('task-table');
if (!items.length) { body.innerHTML = '
| ◇当前队列没有任务提交任务后,这里会自动显示最新状态 |
'; return; }
- body.innerHTML = items.slice(0, 8).map(item => `${escapeHTML(shortID(item.id))}${escapeHTML(item.business_key || '无业务键')} | ${escapeHTML(item.handler || '—')} ${escapeHTML(item.handler_version || '')} | ${escapeHTML(item.state || 'UNKNOWN')} | ${escapeHTML(item.revision || '—')} | ${escapeHTML(formatTime(item.created_at))} |
`).join('');
+ body.innerHTML = items.slice(0, 8).map(item => `${escapeHTML(shortID(item.id))}${escapeHTML(item.business_key || '无业务键')} | ${escapeHTML(item.handler || '—')} ${escapeHTML(item.handler_version || '')} | ${escapeHTML(item.state || 'UNKNOWN')} | ${escapeHTML(item.revision || '—')} | ${escapeHTML(formatTime(item.created_at))} |
`).join('');
+ body.querySelectorAll('.task-row').forEach(row => {
+ row.addEventListener('click', () => openTaskDetail(row.dataset.taskId));
+ row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskDetail(row.dataset.taskId); } });
+ });
}
function renderSchedules(items) {
text('metric-schedules', String(items.length));
@@ -79,6 +84,77 @@
el.innerHTML = items.slice(0, 4).map(item => `⌁
${escapeHTML(item.name || shortID(item.id))}待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}
${item.enabled === false ? '已暂停' : '已启用'}
`).join('');
}
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
+ function formatJSON(value) {
+ if (value === undefined || value === null || value === '') return '';
+ try { return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2); } catch { return String(value); }
+ }
+ function operationKey(prefix) {
+ const id = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ return `console-${prefix}-${id}`.slice(0, 128);
+ }
+ function terminalState(value) { return ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(value); }
+ function showTaskDrawer() { $('task-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
+ function closeTaskDrawer() { $('task-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeActionForm(); }
+ function renderDetailList(id, items, emptyText, renderItem) {
+ const el = $(id);
+ if (!items.length) { el.innerHTML = `${escapeHTML(emptyText)}
`; return; }
+ el.innerHTML = items.map(renderItem).join('');
+ }
+ function renderTaskDetail(task) {
+ state.selectedTask = task;
+ text('detail-title', task.handler || '任务详情'); text('detail-id', task.id || '—'); text('detail-state', task.state || 'UNKNOWN');
+ $('detail-state').className = `state-pill ${stateClass(task.state)}`;
+ text('detail-updated', `最后更新 ${formatTime(task.updated_at)}`); text('detail-handler', `${task.handler || '—'} · ${task.handler_version || '—'}`);
+ text('detail-revision', task.revision || '—'); text('detail-attempt-count', task.attempt_count ?? '0'); text('detail-effect', task.effect_uncertain ? '待核查' : '已确认');
+ text('detail-created', formatTime(task.created_at)); text('detail-deadline', formatTime(task.deadline_at));
+ const resultBlock = $('detail-result-block'); resultBlock.hidden = !task.result; if (task.result) $('detail-result').textContent = formatJSON(task.result);
+ const canCancel = !terminalState(task.state) && !task.cancel_requested_at;
+ const canRetry = (task.state === 'FAILED' || task.state === 'CANCELLED') && !task.effect_uncertain;
+ $('detail-actions').hidden = !(canCancel || canRetry); $('task-cancel-button').hidden = !canCancel; $('task-retry-button').hidden = !canRetry;
+ text('detail-action-note', task.state === 'NEEDS_REVIEW' ? '此任务需要先完成带证据的人工核查,确认旧执行已经结清后再决定最终状态。' : '写操作需要对应的管理权限;服务端会再次校验版本、状态、幂等键和队列范围。');
+ }
+ function renderAttempts(items, error) {
+ text('detail-attempts-count', error ? '权限不足' : `${items.length} 次`);
+ renderDetailList('detail-attempts', items, error ? '当前凭据没有 tasks:read 历史权限。' : '还没有执行尝试。', item => `第 ${escapeHTML(item.epoch)} 次 · ${escapeHTML(item.state || 'UNKNOWN')}${escapeHTML(item.error_code || '无错误')} · ${escapeHTML(formatTime(item.created_at))}
${item.retryable ? '可重试' : '不可重试'} `);
+ }
+ function renderOperations(items, error) {
+ text('detail-operations-count', error ? '权限不足' : `${items.length} 条`);
+ renderDetailList('detail-operations', items, error ? '当前凭据没有 tasks:audit:read 权限。' : '还没有操作审计。', item => `${escapeHTML(item.action || '操作')}${escapeHTML(item.actor_id || '—')} · ${escapeHTML(formatTime(item.created_at))}
${escapeHTML(shortID(item.id))} `);
+ }
+ async function openTaskDetail(id) {
+ if (!id) return;
+ if (!state.token) { showToast('请先连接具备 tasks:read 权限的凭据'); return; }
+ showTaskDrawer(); text('detail-title', '正在读取任务…'); text('detail-id', shortID(id)); text('detail-state', '读取中'); $('detail-actions').hidden = true;
+ $('detail-attempts').innerHTML = '正在读取尝试历史…
'; $('detail-operations').innerHTML = '正在读取操作审计…
';
+ const encoded = encodeURIComponent(id); const [taskResult, attemptsResult, operationsResult] = await Promise.allSettled([request(`/v1/tasks/${encoded}`), request(`/v1/tasks/${encoded}/attempts?limit=20`), request(`/v1/tasks/${encoded}/operations?limit=20`)]);
+ if (taskResult.status !== 'fulfilled') { text('detail-title', '任务读取失败'); text('detail-action-note', taskResult.reason?.message || '无法读取任务详情'); return; }
+ const task = extractData(taskResult.value); renderTaskDetail(task);
+ renderAttempts(attemptsResult.status === 'fulfilled' ? extractItems(attemptsResult.value) : [], attemptsResult.status === 'rejected');
+ renderOperations(operationsResult.status === 'fulfilled' ? extractItems(operationsResult.value) : [], operationsResult.status === 'rejected');
+ }
+ function closeActionForm() { state.actionMode = ''; $('task-action-form').hidden = true; $('action-error').textContent = ''; }
+ function openActionForm(mode) {
+ if (!state.selectedTask) return;
+ state.actionMode = mode; $('task-action-form').hidden = false; $('action-error').textContent = ''; $('action-reason').value = ''; $('action-evidence').value = `evidence:console-${Date.now()}`; $('action-safe').checked = false;
+ const retry = mode === 'retry'; text('action-kicker', retry ? 'TASK RECOVERY' : 'TASK CONTROL'); text('action-title', retry ? '安全重试任务' : '取消任务'); text('action-description', retry ? '只有确认旧执行不会继续产生外部效果时,才能创建新的关联任务。' : '取消会记录持久化取消意图;运行中的处理器仍需上报结果或等待恢复。');
+ $('action-evidence-wrap').hidden = !retry; $('action-timeout-wrap').hidden = !retry; $('action-safe-wrap').hidden = !retry; $('action-submit').textContent = retry ? '创建重试任务 →' : '提交取消 →'; $('action-reason').focus();
+ }
+ async function submitAction(event) {
+ event.preventDefault(); if (!state.selectedTask || !state.actionMode) return;
+ const reason = $('action-reason').value.trim(); if (!reason) { $('action-error').textContent = '请填写操作原因。'; return; }
+ const task = state.selectedTask; const retry = state.actionMode === 'retry'; const body = { expected_revision: String(task.revision), reason };
+ const path = `/v1/tasks/${encodeURIComponent(task.id)}:${retry ? 'retry' : 'cancel'}`;
+ if (retry) {
+ const timeout = Number($('action-timeout').value); if (!$('action-safe').checked) { $('action-error').textContent = '请先确认安全重试声明。'; return; }
+ if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2592000000) { $('action-error').textContent = '任务总时限必须是 1 到 2592000000 毫秒。'; return; }
+ const evidence = $('action-evidence').value.trim(); if (!evidence) { $('action-error').textContent = '请填写证据引用。'; return; }
+ Object.assign(body, { evidence_ref: evidence, safe_to_retry: true, task_timeout_ms: timeout });
+ }
+ const button = $('action-submit'); button.disabled = true; $('action-error').textContent = '';
+ try { const response = await request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(retry ? 'retry' : 'cancel') }, body: JSON.stringify(body) }); showToast(retry ? '已创建关联重试任务' : '取消意图已提交'); closeActionForm(); await refresh(); await openTaskDetail(task.id); if (response?.data?.task?.id && retry) showToast(`重试任务:${shortID(response.data.task.id)}`); }
+ catch (error) { $('action-error').textContent = error.message || '操作失败'; }
+ finally { button.disabled = false; }
+ }
async function refresh() {
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
try {
@@ -102,6 +178,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')); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); } });
$('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-scroll]').forEach(button => button.addEventListener('click', () => { const target = $(button.dataset.scroll); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.scroll === button.dataset.scroll)); }));
diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html
index 73c85b3..568ceda 100644
--- a/internal/transport/httpserver/console/index.html
+++ b/internal/transport/httpserver/console/index.html
@@ -96,6 +96,41 @@
+