diff --git a/docs/console.md b/docs/console.md index f50b8f5..63d7cbc 100644 --- a/docs/console.md +++ b/docs/console.md @@ -10,6 +10,7 @@ http://127.0.0.1:8080/console/ - 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。 - 通过用户选择的本地凭据 JSON 或手工 Token/队列范围调用现有授权 API;队列范围优先从凭据 JSON 或服务端默认配置自动填充。 +- 总览页直接展示任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;连接参数收进“连接设置”抽屉,不占用统计区域。 - 展示任务、定时计划、流程实例和结果订阅的最近摘要。 - 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。 - 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。 @@ -31,7 +32,7 @@ http://127.0.0.1:8080/console/ http://127.0.0.1:8080/console/ ``` -启动 Orbit 后,API 地址默认使用当前控制台页面所在的地址,不需要手工填写。点击“选择凭据 JSON”,选择 `orbit-admin bootstrap` 生成的私有凭据文件,例如 `.local/local.credentials.json`,队列范围和 Token 会自动读取,再点击“连接”。浏览器不能在没有用户选择的情况下读取本机文件,这是浏览器的安全边界;文件只在当前页面内解析,Token 不会上传到 Orbit Console,也不写入 Orbit 数据库。 +启动 Orbit 后,首页先展示运行统计;需要切换环境或加载凭据时,点击顶部齿轮、首页“连接设置”或统计旁的设置入口。API 地址默认使用当前控制台页面所在的地址,不需要手工填写。点击“选择凭据 JSON”,选择 `orbit-admin bootstrap` 生成的私有凭据文件,例如 `.local/local.credentials.json`,队列范围和 Token 会自动读取,再点击“连接”。浏览器不能在没有用户选择的情况下读取本机文件,这是浏览器的安全边界;文件只在当前页面内解析,Token 不会上传到 Orbit Console,也不写入 Orbit 数据库。 如果一个部署只服务一个默认队列,可以在服务端配置队列范围,页面打开后会自动使用它: diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js index 7908663..5f8d7b3 100644 --- a/internal/transport/httpserver/console/app.js +++ b/internal/transport/httpserver/console/app.js @@ -52,11 +52,19 @@ $('queue-id').value = defaultQueue; sessionStorage.setItem('orbit.console.queue', defaultQueue); text('queue-hint', '已使用服务端默认队列配置;也可选择凭据 JSON 覆盖。'); + renderConnectionSummary(); } } catch { // 连接表单仍可使用手工 API 地址和凭据,不因可选配置失败而阻断页面。 } } + function renderConnectionSummary() { + text('summary-api', state.base || location.origin); + text('summary-api-hint', state.connected ? '接口响应正常,按 10 秒周期刷新' : 'API 地址自动跟随当前页面'); + text('summary-queue', state.queue ? shortID(state.queue) : '未配置'); + text('summary-credential', state.token ? '已载入' : '未载入'); + text('summary-note', state.token && state.queue ? '已具备读取业务数据的连接条件。' : '服务状态会自动检查;需要业务数据时再加载凭据。'); + } function setConnection(connected, detail = '') { state.connected = connected; const authenticated = connected && Boolean(state.token && state.queue); @@ -68,6 +76,7 @@ document.querySelector('.status-dot').classList.toggle('ready', connected); document.querySelector('.live-chip').classList.toggle('ready', connected); $('live-label').textContent = connected ? '环境在线' : '本地环境'; + renderConnectionSummary(); if (detail) showToast(detail); } async function loadHealth() { @@ -124,6 +133,35 @@ row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } }); }); } + function renderRuntimeStats(tasks, schedules, workflows, subscriptions) { + if (Array.isArray(tasks)) { + const counts = tasks.reduce((acc, item) => { const key = stateClass(item.state); acc[key] = (acc[key] || 0) + 1; return acc; }, {}); + const total = tasks.length; + text('stat-success-rate', total ? `${Math.round((counts.succeeded || 0) / total * 100)}%` : '—'); + text('stat-success-rate-sub', total ? `${counts.succeeded || 0} 成功 / ${total} 项` : '当前没有任务'); + text('stat-task-total', `${total} 项`); + const waiting = (counts.waiting || 0) + (counts.pending || 0); + const other = (counts.failed || 0) + (counts.cancelled || 0) + (counts.needs_review || 0); + const width = value => `${total ? value / total * 100 : 0}%`; + [['succeeded', counts.succeeded || 0], ['running', counts.running || 0], ['waiting', waiting], ['other', other]].forEach(([name, value]) => { $(`stat-bar-${name}`).style.width = width(value); text(`stat-count-${name}`, String(value)); }); + } else { + text('stat-success-rate', '—'); text('stat-success-rate-sub', '暂无任务权限'); text('stat-task-total', '暂无数据'); + ['succeeded', 'running', 'waiting', 'other'].forEach(name => { $(`stat-bar-${name}`).style.width = '0%'; text(`stat-count-${name}`, '—'); }); + } + if (Array.isArray(schedules)) { + const active = schedules.filter(item => item.paused !== true).length; + text('stat-active-schedules', String(active)); text('stat-active-schedules-sub', `${schedules.length} 项计划 · ${schedules.length - active} 项暂停`); + } else { text('stat-active-schedules', '—'); text('stat-active-schedules-sub', '暂无计划权限'); } + if (Array.isArray(workflows)) { + const review = workflows.filter(item => stateClass(item.state) === 'needs_review').length; + text('stat-review-workflows', String(review)); text('stat-review-workflows-sub', `${workflows.length} 项流程实例`); + } else { text('stat-review-workflows', '—'); text('stat-review-workflows-sub', '暂无流程权限'); } + if (Array.isArray(subscriptions)) { + const pending = subscriptions.reduce((sum, item) => sum + (Number(item.pending_count) || 0), 0); + text('stat-pending-deliveries', String(pending)); text('stat-pending-deliveries-sub', `${subscriptions.length} 个订阅 · 待确认结果`); + } else { text('stat-pending-deliveries', '—'); text('stat-pending-deliveries-sub', '暂无事件权限'); } + text('stats-snapshot', state.connected ? `更新于 ${formatTime(new Date())}` : '等待连接'); + } function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } function formatJSON(value) { if (value === undefined || value === null || value === '') return ''; @@ -134,6 +172,8 @@ return `console-${prefix}-${id}`.slice(0, 128); } function terminalState(value) { return ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(value); } + function openConnectionSettings() { $('connection-settings-backdrop').hidden = false; document.body.classList.add('drawer-open'); $('api-url').focus(); } + function closeConnectionSettings() { $('connection-settings-backdrop').hidden = true; document.body.classList.remove('drawer-open'); } 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) { @@ -413,17 +453,20 @@ if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning'); try { await loadHealth(); - if (!state.token || !state.queue) { setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; } - const tasks = await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`); renderTasks(extractItems(tasks)); + if (!state.token || !state.queue) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; } + const tasks = await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`); const taskItems = extractItems(tasks); renderTasks(taskItems); const optional = await Promise.allSettled([ request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`), request('/v1/workflows?limit=20'), request(`/v1/subscriptions?queue_id=${encodeURIComponent(state.queue)}&limit=20`), ]); - if (optional[0].status === 'fulfilled') renderSchedules(extractItems(optional[0].value)); - if (optional[1].status === 'fulfilled') renderWorkflows(extractItems(optional[1].value)); - if (optional[2].status === 'fulfilled') renderSubscriptions(extractItems(optional[2].value)); - setConnection(true); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); text('metric-health-sub', '数据库与 Schema 正常'); + const scheduleItems = optional[0].status === 'fulfilled' ? extractItems(optional[0].value) : null; + const workflowItems = optional[1].status === 'fulfilled' ? extractItems(optional[1].value) : null; + const subscriptionItems = optional[2].status === 'fulfilled' ? extractItems(optional[2].value) : null; + if (scheduleItems) renderSchedules(scheduleItems); + if (workflowItems) renderWorkflows(workflowItems); + if (subscriptionItems) renderSubscriptions(subscriptionItems); + setConnection(true); renderRuntimeStats(taskItems, scheduleItems, workflowItems, subscriptionItems); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); text('metric-health-sub', '数据库与 Schema 正常'); } catch (error) { setConnection(false); showToast(`连接失败:${error.message}`); text('last-updated', '刷新失败'); } finally { state.loading = false; $('refresh-button').classList.remove('spinning'); } } @@ -433,14 +476,15 @@ sessionStorage.setItem('orbit.console.queue', state.queue); if (!state.token) showToast('服务状态可以直接查看;选择凭据 JSON 后可读取业务数据'); else if (!state.queue) showToast('队列会从凭据 JSON 自动读取,也可配置 ORBIT_CONSOLE_QUEUE_ID'); + closeConnectionSettings(); 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(); }); $('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(); } }); + $('connect-button').addEventListener('click', connect); $('connect-hero').addEventListener('click', openConnectionSettings); $('connection-settings-button').addEventListener('click', openConnectionSettings); $('connection-settings-inline').addEventListener('click', openConnectionSettings); $('close-connection-settings').addEventListener('click', closeConnectionSettings); $('connection-settings-backdrop').addEventListener('click', event => { if (event.target.id === 'connection-settings-backdrop') closeConnectionSettings(); }); $('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 (!$('connection-settings-backdrop').hidden) closeConnectionSettings(); else 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); const queue = String(credential.queue_id || state.consoleConfig?.default_queue_id || '').trim(); $('token').value = credential.token || ''; $('queue-id').value = queue; state.queue = queue; sessionStorage.setItem('orbit.console.queue', queue); text('queue-hint', queue ? '队列已从凭据 JSON 自动读取;无需手工填写。' : '凭据未附带队列,请配置 ORBIT_CONSOLE_QUEUE_ID。'); showToast(queue ? '凭据已读取,队列已自动填充' : '凭据已读取,但缺少队列配置'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); }); + $('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); const queue = String(credential.queue_id || state.consoleConfig?.default_queue_id || '').trim(); $('token').value = credential.token || ''; $('queue-id').value = queue; state.queue = queue; state.token = String(credential.token || '').trim(); sessionStorage.setItem('orbit.console.queue', queue); text('queue-hint', queue ? '队列已从凭据 JSON 自动读取;无需手工填写。' : '凭据未附带队列,请配置 ORBIT_CONSOLE_QUEUE_ID。'); renderConnectionSummary(); showToast(queue ? '凭据已读取,队列已自动填充' : '凭据已读取,但缺少队列配置'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); }); document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); })); window.addEventListener('hashchange', () => setView(currentView(), false)); setView(currentView(), false); diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index 740bf9b..6f22dc6 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -40,6 +40,7 @@
本地环境 尚未刷新 +
@@ -52,7 +53,7 @@

任务、定时、流程和交付状态,在一块屏幕上保持有序运转。

- +
@@ -115,6 +132,20 @@ +