diff --git a/CHANGELOG.md b/CHANGELOG.md index 2502afd..ec54a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 代码交付主分支为 `main`。采用 `v主版本.次版本.修订号`,已发布标签不可变;补丁按 `v0.1.1`、`v0.1.2` 递增,业务项目固定具体版本。 +## main — 2026-09-29 + +- 管理端统一浅色工作台、分组导航、紧凑总览、中文状态和移动端布局。 +- 任务/流程增加服务端状态、精确业务键和时间筛选;任务、计划、流程、订阅增加游标分页、页大小与 ID 定位。刷新保留查询,迟到响应不覆盖新结果。 +- 应用搜索、接入配置集中展示、低频操作折叠;详情支持复制 ID、键盘焦点与返回入口,Token 查看/复制及原有审计规则保持。 +- 本批只更新控制台及说明,不修改 API、SDK 或数据库结构。SDK 继续使用 v0.1.2,Outbox 继续使用 v0.1.1。[验收记录](docs/reports/console-refresh.md)。 + ## v0.1.2 — 2026-09-27 - 通用队列/订阅永久退役、活动订阅名额释放、依赖与积压保护;历史、名称、审计和原幂等回执保留。 diff --git a/Makefile b/Makefile index 4b53a34..7059795 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ GO ?= go -.PHONY: fmt test vet build adapters-check image integration console-e2e compose-check mysql-replication mysql-compatibility sdk-check protocol-check docs-check stability-check schedule-backlog release-manifest release-local-check +.PHONY: fmt test vet build adapters-check image integration console-e2e console-check compose-check mysql-replication mysql-compatibility sdk-check protocol-check docs-check stability-check schedule-backlog release-manifest release-local-check fmt: $(GO) fmt ./... cd sdk/go && $(GO) fmt ./... @@ -44,6 +44,11 @@ image: integration: ./scripts/test-mysql.sh +console-check: + node --check internal/transport/httpserver/console/app.js + node --check internal/transport/httpserver/console/applications.js + node --test scripts/test-console-state.cjs + console-e2e: ./scripts/test-console-e2e.sh diff --git a/README.md b/README.md index d7fea94..f3be712 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - **Pulse · 脉动**:异步任务、延迟执行、定时计划、执行授权、重试与结果订阅。 - **Weave · 织流**:研发定义并发布流程,支持顺序、并行、条件分支、等待信号、有界查询和人工恢复。 -- **Console**:账号密码登录的管理页面,按应用与环境查看任务、计划、流程、结果交付及归档作业。 +- **Console**:账号密码登录的管理工作台,按应用与环境查看运行快照;支持任务/流程筛选、资源分页、ID 定位、审计与接入配置复制。[控制台使用说明](docs/console.md)。 主分支:`main`。当前版本:**`v0.1.2`**。源码、SDK 和 Outbox 适配包采用固定版本发布,已发布标签不覆盖。变更见 [CHANGELOG](CHANGELOG.md)。 @@ -273,6 +273,7 @@ docs/ API、SDK、场景、设计及验收报告 make test vet build # 7 个模块的竞态测试、静态检查与构建 make sdk-check protocol-check docs-check make integration # 临时 MySQL、真实平台、示例及故障回归 +make console-check # 前端查询状态回归(开发机需 Node.js) make console-e2e # 控制台端到端回归 ``` diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 2a3c04b..b811d33 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -320,3 +320,13 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专 保护:未完成/未知任务、执行许可、活动计划和流程、未退役订阅阻止队列退役;积压、未确认和耗尽交付阻止订阅退役。退役不删历史或审计,不修改凭据。 进度:实现完成;标准七模块竞态回归、vet/build、SDK/协议/文档门禁、真实 MySQL 完整 integration、控制台 HTTP 端到端均通过。浏览器与本地部署、远端版本交付记录见[本批报告](reports/resource-retirement.md)。 + + +## 2026-09-29:管理端简化与操作体验 + +- [x] 全站统一浅色工作台、分组导航、紧凑统计、中文状态、响应式布局和登录页。 +- [x] 任务/流程按状态、业务键、时间服务端筛选;四个资源列表真实游标翻页与 ID 定位,刷新保持范围与筛选。 +- [x] 首页明确最近窗口口径,提供待处理入口;应用页集中接入配置,收起技术细节和低频管理操作。 +- [ ] 浏览器验证、查询/翻页回归、基础构建与控制台端到端,本地 Docker 更新和对接说明同步。 + +不修改业务执行内核、权限、Token、数据库模型或既有业务资源。 diff --git a/docs/console.md b/docs/console.md index eeb67ba..e209cbd 100644 --- a/docs/console.md +++ b/docs/console.md @@ -1,6 +1,6 @@ # Orbit Console · 星轨控制台 -Orbit 内置了一个不依赖 Node.js、外部 CDN 或额外文件挂载的管理控制台第一版。静态资源编译进 Orbit 二进制,入口为: +Orbit 内置了一个不依赖 Node.js、外部 CDN 或额外文件挂载的管理控制台。静态资源编译进 Orbit 二进制,入口为: ```text http://127.0.0.1:8080/console/ @@ -8,10 +8,10 @@ http://127.0.0.1:8080/console/ ## 当前能力 -- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。 +- 读取 `/health/ready` 和 `/version` 展示服务就绪状态及版本;数据库未就绪时明确提示不可用。 - 通过数据库中的控制台账号登录;服务端使用 HttpOnly 会话 Cookie 调用现有授权 API,普通业务页面无需业务 Token;应用接入页仅在管理员明确查看时读取当前 Token。 -- 总览页直接展示当前查询窗口的任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;登录和权限信息不会占用统计区域。 -- 页顶选择应用、环境与队列;任务、计划、流程、订阅、归档列表和详情显示应用归属。筛选保存在 URL,刷新、复制链接及后退可恢复。 +- 总览展示各类最近 20 条记录的快照:任务成功占比、状态分布、启用计划、待核查流程和待确认结果,并提供异常入口与最近任务。已取消单独统计,不算失败;快照不是全库累计。 +- 页顶选择应用、环境与队列;任务、计划、流程、订阅、归档列表和详情显示应用归属。应用和队列范围保存在 URL,重新打开、复制链接及后退可恢复;列表条件和页码在当前页面会话内保持。 - 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。 - 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。 - 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。 @@ -22,15 +22,15 @@ http://127.0.0.1:8080/console/ - 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。 - 左侧八个模块是独立视图:总览、任务、计划、流程、订阅、保留策略、归档作业和应用接入。 - “保留策略”只允许单管理员查看和修改命名空间级窗口;修改需要版本号、幂等键和原因并留下审计。预览和只读导出不会删除源数据,未确认交付、未知效果和未结束流程不会成为候选。 -- 任务状态按 10 秒自动刷新;页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。 +- 当前视图按 10 秒自动刷新,可在页顶暂停;输入、打开详情或操作弹窗时不自动重载。页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。 - 普通账号沿用绑定凭据范围;部署指定管理员可显式选择应用环境管理。读取失败显示错误,避免当作空列表。 ## 本地打开 -启动 Orbit 后访问 `/console/`。未登录时,任何控制台业务页面都会跳转到 `/console/login`;登录成功后进入控制台,登录会话过期或服务端返回 401 时也会重新跳转。当前 Docker 开发环境入口: +启动 Orbit 后访问 `/console/`。未登录时,任何控制台业务页面都会跳转到 `/console/login`;登录成功后进入控制台,登录会话过期或服务端返回 401 时也会重新跳转。使用 `scripts/orbit-local.py up` 部署的持久本地联调入口(通用 Compose 默认端口仍为 8080): ```text -http://127.0.0.1:8080/console/ +http://127.0.0.1:18080/console/ ``` 首次使用可以在 `orbit-admin bootstrap` 时一起创建控制台管理员。这样业务资源、业务凭据和管理员账号在同一个初始化事务中提交: @@ -95,3 +95,15 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx' 应用接入页按命名空间记忆所选队列;多队列且没有记忆时要求明确选择,不根据名称猜测业务用途。下拉框显示暂停/退役状态,已退役队列不能导出成新接入配置;凭据无该队列权限时直接提示,不自动换目标。 结果订阅详情和应用接入页分别提供订阅、队列退役;每次提交需要原因和当前版本,网络不明确时保留原键重报。活动订阅名额包含暂停订阅,退役后释放,历史仍可查询;阻塞原因明确显示。未补存当前 Token 时,预览入口与过渡提示都要求先补存,避免误认为已有可复制的新配置。 + +## 查询与日常操作 + +1. 在页顶选择应用环境;任务、计划、订阅再选择队列。 +2. 任务与流程可组合状态、完整业务键和创建时间查询。业务键精确匹配,保留大小写、特殊字符和尾部空格;时间输入按浏览器时区换算,开始包含、结束不包含。 +3. 四个资源列表提供每页 20 / 50 / 100 条、下一页、上一页和回到最新。请求服务端游标,不在前端截取全部数据。自动刷新和手动「刷新数据」保留当前条件及页码;改变条件或页大小回到第一页。分页期间数据可能继续变化,不承诺固定快照。 +4. 在页面右上输入完整 32 位资源 ID 可直接定位;详情可复制完整 ID。读取失败保留筛选并提示原因,迟到的旧查询不会覆盖新查询。详情打开后焦点留在抽屉内,Escape 关闭后回到原入口。 +5. 应用接入页可搜索本页应用,先选择接入队列,再查看或复制完整配置。应用标识、权限、有效期调整、退役等低频信息折叠展示;查看与换证沿用原有身份、权限和审计规则。 + +全站采用浅色工作台和统一表格。窄屏导航可横向滑动,宽表格在自身容器中滚动;登录页与控制台使用相同视觉样式。 + +前端校验运行 `make console-check`(开发机需要 Node.js,运行服务无需 Node.js);真实 MySQL、登录保护、任务执行与控制台接口链路运行 `make console-e2e`。本批范围与浏览器验收见[管理端改版记录](reports/console-refresh.md)。 diff --git a/docs/reports/console-refresh.md b/docs/reports/console-refresh.md new file mode 100644 index 0000000..52dc78c --- /dev/null +++ b/docs/reports/console-refresh.md @@ -0,0 +1,38 @@ +# 管理端简化与操作体验验收 + +日期:2026-09-29。仅调整内置控制台、查询状态与使用说明;API、SDK、MySQL 模型和权限不变。 + +## 交付内容 + +- 统一浅色工作台、分组导航、紧凑总览、中文状态、表格、详情和登录页。 +- 总览按各类最近 20 条记录展示快照,明确统计范围,取消与失败分别显示;需要关注的项目可进入对应列表。 +- 任务/流程通过已有服务端 API 组合状态、精确业务键、创建时间查询;任务/计划/流程/订阅使用服务端游标翻页,支持 20/50/100 条与直接 ID 定位。 +- 刷新保留条件和当前游标;新查询失效旧响应,详情关闭或换资源后忽略旧响应。详情焦点留在抽屉,关闭回到入口,完整 ID 可复制。 +- 应用接入支持本页搜索;凭据配置为主要操作,技术标识、权限和低频管理操作折叠。管理员仍可查看和复制含 Token 的完整接入配置。 +- 仅刷新当前页;自动刷新可暂停,打开详情、操作弹窗或编辑输入时不自动打断。 + +## 自动回归 + +- `make test vet build`:根模块、SDK、示例与适配包竞态测试、静态检查和构建通过。 +- `make console-check`:JS 语法检查及 4 项查询状态回归通过。覆盖特殊字符/尾空格、游标条件不丢失、迟到响应隔离、失败保留查询、时间边界及流程命名空间范围。 +- `make console-e2e`:隔离 MySQL 容器通过登录/未登录保护、CSRF、跨应用隔离、Token 查看/导入、换证过渡、订阅/队列退役、真实 WS Worker、任务重试、定时触发、归档及退出登录回归。 +- HTML ID 唯一性检查通过。运行服务无需 Node.js 或新前端依赖。 + +## 浏览器与数据 + +本地同源预览使用当前持久 MySQL 数据;独立队列 `console-ui-297f4c61f275` 用于 25 条测试任务的分页验证。测试任务使用专用处理器且未启动 Worker,全部取消,无外部副作用。保留记录供复查。 + +已核对: + +- 20 条首屏 → 5 条第二页 → 刷新仍在第二页 → 上一页;50 条页大小返回 25 条。 +- 精确查询 `筛选 +%_ `(尾部两个空格)仅返回对应的一条记录;结束时间早于开始时间被阻止。 +- 原联调队列 13 条记录按已取消状态查到 2 条;ID 定位、详情审计、复制完整 ID 通过。 +- 定时计划当前规则/触发历史、流程步骤/事件/输出、订阅交付、保留策略只读预览及归档空态均可读取。 +- 应用搜索、明确队列选择、查看/复制完整配置通过;检查仅记录存在性,不把 Token、Cookie 或密码写入报告/截图。 +- 1440×1000 桌面与 390×844 窄屏检查:页面不横向溢出,窄屏表格在自身容器内滚动,登录页面适配。 + +## 口径与边界 + +首页不是全局累计统计;列表游标不承诺并发变更下的固定快照;详情历史仍是最近 20 条。应用搜索只搜索本页。完整浏览器重载会重置列表条件与页码,应用/队列范围从 URL 恢复。 + +本批不创建新的 SDK 标签,不变更或轮换现有业务凭据,不改变单管理员模式,不将 UI 回归当作生产容量验收。代码交付在 `main`,本地运行版本以 `/version` 中的构建修订为准。 diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js index 35778c0..fde332c 100644 --- a/internal/transport/httpserver/console/app.js +++ b/internal/transport/httpserver/console/app.js @@ -2,6 +2,10 @@ const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, scope: null, scopeReady: false, scopeNamespace: '', mutations: 0, retention: null, 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 core = window.OrbitConsole; + const label = core.label; + const lists = Object.fromEntries(['tasks', 'schedules', 'workflows', 'subscriptions'].map(kind => [kind, new core.ListPage()])); + let overviewSequence = 0, healthPromise = null; const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业', applications: '应用接入' }; function showToast(message) { @@ -23,21 +27,24 @@ } function setView(view, updateHash = true) { const next = pageNames[view] ? view : 'overview'; + if (updateHash && location.hash !== `#/${next}`) { location.hash = `/${next}`; return; } const leavingApplications = state.scopeReady && $('application-scope').hidden && next !== 'applications'; document.querySelectorAll('.page-view').forEach(page => page.classList.toggle('active', page.dataset.page === next)); - document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.view === next)); - text('page-title', pageNames[next]); + document.querySelectorAll('.nav-item').forEach(item => { item.classList.toggle('active', item.dataset.view === next); if (item.dataset.view === next) item.setAttribute('aria-current','page'); else item.removeAttribute('aria-current'); }); + text('page-title', pageNames[next]); document.title = `${pageNames[next]} · Orbit 控制台`; + document.querySelector('.scope-queue').hidden = ['workflows','retention','archives'].includes(next); $('application-scope').hidden = next === 'applications'; if (leavingApplications && state.scope?.can_switch) { scopeUI.nextApplication = ''; loadScopeApplications().catch(error => showToast(`应用目录读取失败:${error.message}`)); } text('scope-hint', ['workflows', 'retention', 'archives'].includes(next) ? '当前应用环境 · 不受队列筛选影响' : '任务、计划和订阅按所选队列显示'); - if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`; - window.scrollTo({ top: 0, behavior: 'smooth' }); + window.scrollTo({ top: 0, behavior: 'instant' }); + if (matchMedia('(max-width:640px)').matches) document.querySelector('.nav-item.active')?.scrollIntoView({inline:'nearest',block:'nearest'}); if (next === 'retention' && state.scopeReady) loadRetention(); if (next === 'archives' && state.scopeReady) loadArchives(); if (next === 'applications' && state.authenticated) window.orbitApplications.load(); + if (state.authenticated && state.scopeReady && ['overview', ...Object.keys(lists)].includes(next)) refresh(); } function redirectToLogin() { const returnTo = `${location.pathname}${location.search}${location.hash}`; @@ -119,7 +126,7 @@ state.queue = queues.some(q => q.id === preferred) ? preferred : (queues[0]?.id || ''); if (requested && !queues.some(q => q.id === requested)) { const queue = extractData(await request(`/v1/queues/${encodeURIComponent(requested)}`)); - queues.push({ id: queue.id, name: queue.name }); state.queue = requested; + queues.push({ id: queue.id, name: queue.name, paused: queue.paused, retired_at: queue.retired_at }); state.queue = requested; } scopeUI.nextQueue = state.scope.next || ''; scopeUI.applications = [state.scope.entry]; @@ -140,7 +147,7 @@ $('scope-application').replaceChildren(new Option('范围不可用', '')); $('scope-queue').replaceChildren(new Option('未加载', '')); $('scope-application').disabled = true; $('scope-queue').disabled = true; - $('task-table').innerHTML = '◇当前范围未加载请检查应用范围,或点击“重置范围”'; + $('task-table').innerHTML = '◇当前范围未加载请检查应用范围,或点击“重置范围”'; text('scope-error', `当前应用范围不可用:${error.message}。可点击“重置范围”返回默认应用。`); } } @@ -401,65 +408,103 @@ $('side-status').textContent = authenticated ? '环境已连接' : (connected ? '服务在线' : '等待连接'); $('side-endpoint').textContent = connected ? state.base : 'API 未连接'; $('side-progress').style.width = connected ? '100%' : '0%'; - document.querySelector('.status-dot').classList.toggle('ready', connected); + document.querySelectorAll('.status-dot').forEach(dot => 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() { + if (healthPromise) return healthPromise; + healthPromise = (async () => { + try { + const [ready, version] = await Promise.all([request('/health/ready'), request('/version')]); + if (ready?.status !== 'ready') throw new Error('服务尚未就绪'); + text('metric-health', '运行正常'); + text('metric-health-sub', `${version?.version || 'Orbit'} · 数据库已就绪`); + setConnection(true); return version; + } catch (error) { text('metric-health', '暂不可用'); text('metric-health-sub', '请检查服务与数据库连接'); setConnection(false); throw error; } + finally { healthPromise = null; } + })(); + return healthPromise; + } + function badge(value) { return `${escapeHTML(label(value))}`; } + const listBodies = { tasks:'task-table', schedules:'schedule-list', workflows:'workflow-list', subscriptions:'subscription-list' }; + const listColumns = { tasks:7, schedules:6, workflows:6, subscriptions:6 }; + const detailOpeners = { tasks:openTaskDetail, schedules:openScheduleDetail, workflows:openWorkflowDetail, subscriptions:openSubscriptionDetail }; + function resourceButton(kind, item, name) { return ``; } + function detailButton(kind, item) { return resourceButton(kind, item, '详情 →'); } + function renderList(kind, items) { + const body = $(listBodies[kind]); + if (!items.length) { body.innerHTML = `○没有符合条件的记录检查应用与队列,或调整筛选条件。`; return; } + body.innerHTML = items.map(item => { + let cells; + if (kind === 'tasks') cells = `
${resourceButton(kind,item,shortID(item.id))}${escapeHTML(item.business_key || '未设置业务键')}
${scopeBadge()}${escapeHTML(item.handler || '—')}${escapeHTML(item.handler_version || '')}${badge(item.state)}${escapeHTML(String(item.attempt_count ?? 0))} 次${escapeHTML(formatTime(item.created_at))}`; + if (kind === 'schedules') cells = `${resourceButton(kind,item,item.name)}${escapeHTML(shortID(item.id))}${scopeBadge()}${badge(item.paused ? 'PAUSED' : 'ENABLED')}${escapeHTML(String(item.current_version || '—'))}${escapeHTML(item.paused ? '已暂停' : formatTime(item.next_fire_at))}`; + if (kind === 'workflows') cells = `${resourceButton(kind,item,item.business_key || shortID(item.id))}${escapeHTML(shortID(item.id))}${scopeBadge()}${badge(item.state)}${item.paused ? ' '+badge('PAUSED') : ''}${escapeHTML(item.version || '—')}${escapeHTML(formatTime(item.created_at))}`; + if (kind === 'subscriptions') cells = `${resourceButton(kind,item,item.name)}${escapeHTML(shortID(item.id))}${scopeBadge()}${badge(item.retired_at ? 'RETIRED' : item.enabled === false ? 'PAUSED' : 'ENABLED')}${escapeHTML(String(item.pending_count ?? 0))} / ${escapeHTML(String(item.max_pending ?? '—'))}${escapeHTML(formatTime(item.created_at))}`; + return `${cells}${detailButton(kind,item)}`; + }).join(''); + } + function syncPager(kind) { + const page = lists[kind]; + $(`${kind}-next`).disabled = page.loading || !page.next; + $(`${kind}-prev`).disabled = page.loading || !page.history.length; + $(`${kind}-first`).disabled = page.loading || !page.history.length; + text(`${kind}-page`, `第 ${page.history.length+1} 页`); + $(listBodies[kind]).closest('table').setAttribute('aria-busy',String(page.loading)); + } + async function loadList(kind, background = false) { + const page = lists[kind], ticket = page.begin(); syncPager(kind); + $(`${kind}-error`).hidden = true; + if (!background) $(listBodies[kind]).innerHTML = `正在读取…`; try { - const [live, version] = await Promise.all([request('/health/live'), request('/version')]); - text('metric-health', live?.status === 'alive' ? '在线' : '异常'); - text('metric-health-sub', `Go ${version?.go_version || '—'} · API v${version?.http_protocol_version || '—'}`); - text('side-status', '环境已连接'); - return version; + const data = extractData(await request(core.listURL(kind,state.queue,page.filters,page.cursor,page.limit))); + if (!page.accept(ticket,data)) return; + renderList(kind,data.items || []); + text(`${kind}-summary`, `本页 ${data.items?.length || 0} 条${Object.keys(page.filters).length ? ' · 已应用筛选' : ''} · ${formatTime(new Date())}`); } catch (error) { - text('metric-health', '离线'); text('metric-health-sub', error.message); setConnection(false); throw error; - } + if (!page.fail(ticket)) return; + const note = error.status === 403 ? '当前账号没有读取权限,请联系管理员。' : `${error.message}。原筛选与页码已保留,请刷新重试。`; + text(`${kind}-error`,note); $(`${kind}-error`).hidden = false; + $(listBodies[kind]).innerHTML = `本页读取失败`; + text(`${kind}-summary`,'读取失败'); + } finally { if (ticket === page.sequence) syncPager(kind); } } - function renderTasks(items) { - text('task-count', `${items.length} 项`); text('metric-tasks', String(items.length)); - const counts = items.reduce((acc, item) => { const key = stateClass(item.state); acc[key] = (acc[key] || 0) + 1; return acc; }, {}); - const total = Math.max(items.length, 1); const width = (name) => `${(counts[name] || 0) / total * 100}%`; - $('task-bar-succeeded').style.width = width('succeeded'); $('task-bar-running').style.width = width('running'); $('task-bar-waiting').style.width = `${((counts.waiting || 0) + (counts.pending || 0)) / total * 100}%`; $('task-bar-other').style.width = `${((counts.failed || 0) + (counts.cancelled || 0) + (counts.needs_review || 0)) / total * 100}%`; - text('metric-tasks-sub', `${counts.succeeded || 0} 成功 · ${counts.running || 0} 执行中`); - const body = $('task-table'); - if (!items.length) { body.innerHTML = '◇当前队列没有任务提交任务后,这里会自动显示最新状态'; return; } - body.innerHTML = items.map(item => `
${escapeHTML(shortID(item.id))}${escapeHTML(item.business_key || '无业务键')}
${scopeBadge()}${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 renderOverviewLists(tasks, workflows, subscriptions) { + text('metric-tasks', tasks ? String(tasks.length) : '—'); + text('metric-tasks-sub','当前队列 · 最近 20 条'); + text('metric-workflows',workflows ? String(workflows.length) : '—'); + text('metric-workflows-sub','当前应用 · 最近 20 条'); + text('metric-subscriptions',subscriptions ? String(subscriptions.length) : '—'); + text('metric-subscriptions-sub','当前队列 · 最近 20 条'); + const total = tasks?.length || 0; + ['succeeded','running','waiting','other'].forEach(name => { + const values = name === 'running' ? ['RUNNING','LEASED'] : name === 'other' ? ['FAILED','NEEDS_REVIEW','CANCELLED'] : [name.toUpperCase()]; + $(`task-bar-${name}`).style.width = `${total ? tasks.filter(t=>values.includes(t.state)).length/total*100 : 0}%`; }); + $('subscription-meter-fill').style.width = `${subscriptions?.length ? subscriptions.filter(s=>s.enabled&&!s.retired_at).length/subscriptions.length*100 : 0}%`; + $('overview-task-list').innerHTML = tasks?.length ? tasks.slice(0,6).map(t=>`${resourceButton('tasks',t,shortID(t.id))}${escapeHTML(t.business_key || '未设置业务键')}${escapeHTML(t.handler)}${badge(t.state)}${escapeHTML(formatTime(t.created_at))}${detailButton('tasks',t)}`).join('') : `${tasks ? '当前队列还没有任务' : '任务暂时无法读取,请检查权限或稍后刷新'}`; + const attention = []; + const count = (items, value) => items?.filter(i=>i.state===value).length || 0; + const taskReview = count(tasks,'NEEDS_REVIEW'), failed = count(tasks,'FAILED'), flowReview = count(workflows,'NEEDS_REVIEW'); + const pending = subscriptions?.reduce((n,item)=>n+(Number(item.pending_count)||0),0) || 0; + const add = (number,kind,title,note,state='') => { if(number) attention.push(``); }; + add(taskReview,'tasks','任务等待核查','执行结果不明确,需要先核对证据','NEEDS_REVIEW'); + add(failed,'tasks','任务执行失败','查看失败原因和执行历史','FAILED'); + add(flowReview,'workflows','流程等待核查','查看流程步骤和恢复记录','NEEDS_REVIEW'); + add(pending,'subscriptions','结果等待确认','检查消费者状态和交付历史'); + $('attention-list').innerHTML = attention.join('') || `
${tasks && workflows && subscriptions ? '当前快照暂无异常' : '部分数据暂不可用'}${tasks && workflows && subscriptions ? '最近记录中没有待核查任务、失败任务或未确认结果。' : '请根据页面提示检查连接和读取权限。'}
`; } - function renderSchedules(items) { - text('schedule-page-hint', `${items.length} 项 · 点击计划查看详情`); - const el = $('schedule-list'); if (!items.length) { el.innerHTML = '
◷
当前没有定时计划当前应用与队列还没有创建计划
'; return; } - el.innerHTML = items.map(item => `
◷
${escapeHTML(item.name || shortID(item.id))}${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}
${scopeBadge()}
${escapeHTML(item.paused ? '已暂停' : '运行中')}
`).join(''); - el.querySelectorAll('.schedule-row').forEach(row => { - row.addEventListener('click', () => openScheduleDetail(row.dataset.scheduleId)); - row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openScheduleDetail(row.dataset.scheduleId); } }); - }); - } - function renderWorkflows(items) { - text('metric-workflows', String(items.length)); text('metric-workflows-sub', items.length ? '最近实例' : '当前没有实例'); - const el = $('workflow-list'); if (!items.length) { el.innerHTML = '
⌘
当前没有流程实例当前应用环境还没有启动流程
'; return; } - el.innerHTML = items.map(item => `
⌘
${escapeHTML(item.business_key || shortID(item.id))}${escapeHTML(item.state || '—')} · ${escapeHTML(item.version || '—')}
${scopeBadge()}
${escapeHTML(formatTime(item.created_at))}
`).join(''); - el.querySelectorAll('.workflow-row').forEach(row => { - row.addEventListener('click', () => openWorkflowDetail(row.dataset.workflowId)); - row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openWorkflowDetail(row.dataset.workflowId); } }); - }); - } - function renderSubscriptions(items) { - text('metric-subscriptions', String(items.length)); - const active = items.filter(item => item.enabled !== false).length; $('subscription-meter-fill').style.width = `${items.length ? active / items.length * 100 : 0}%`; - text('metric-subscriptions-sub', items.length ? `${active} 个已启用 · 交付自动恢复` : '当前没有结果订阅'); - const el = $('subscription-list'); if (!items.length) { el.innerHTML = '
⌁
当前没有结果订阅当前应用与队列还没有创建订阅
'; return; } - el.innerHTML = items.map(item => `
⌁
${escapeHTML(item.name || shortID(item.id))}待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}
${scopeBadge()}
${item.retired_at ? '已退役' : item.enabled === false ? '已暂停' : '已启用'}
`).join(''); - el.querySelectorAll('.subscription-row').forEach(row => { - row.addEventListener('click', () => openSubscriptionDetail(row.dataset.subscriptionId)); - row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } }); - }); + async function loadOverview() { + const ticket = ++overviewSequence; + const kinds = ['tasks','schedules','workflows','subscriptions']; + const results = await Promise.allSettled(kinds.map(kind => !state.queue && kind!=='workflows' ? Promise.resolve({data:{items:[]}}) : request(core.listURL(kind,state.queue)))); + if (ticket !== overviewSequence) return; + const items = results.map(result=>result.status==='fulfilled' ? extractItems(result.value) : null); + renderOverviewLists(items[0],items[2],items[3]); renderRuntimeStats(...items); + const failed = kinds.filter((_,i)=>results[i].status==='rejected'); + if (currentView()==='overview') { $('refresh-error').hidden = !failed.length; text('refresh-error',failed.length ? `${failed.map(k=>pageNames[k]).join('、')}读取失败,其他统计仅基于已成功读取的数据。请检查权限或刷新重试。` : ''); } } function renderRuntimeStats(tasks, schedules, workflows, subscriptions) { if (Array.isArray(tasks)) { @@ -469,25 +514,25 @@ 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 other = (counts.failed || 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)); }); + [['succeeded', counts.succeeded || 0], ['running', (counts.running || 0) + (counts.leased || 0)], ['waiting', waiting], ['other', other], ['cancelled',counts.cancelled || 0]].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}`, '—'); }); + text('stat-success-rate', '—'); text('stat-success-rate-sub', '任务读取失败'); text('stat-task-total', '暂无数据'); + ['succeeded', 'running', 'waiting', 'other', 'cancelled'].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', '暂无计划权限'); } + 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', '暂无流程权限'); } + 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', '暂无事件权限'); } + } 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])); } @@ -500,8 +545,32 @@ 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(); } + let drawerSequence = 0, drawerOpener = null; + function showDrawer(kind) { + const backdrop = $(`${kind}-drawer-backdrop`); + if (backdrop.hidden) drawerOpener = document.activeElement; + backdrop.hidden = false; document.body.classList.add('drawer-open'); + document.querySelector('.app-shell').inert = true; + backdrop.querySelectorAll('.detail-grid b').forEach(el => { el.textContent = '—'; }); + $(`close-${kind}-drawer`).focus(); + return ++drawerSequence; + } + function hideDrawer(kind) { + const backdrop = $(`${kind}-drawer-backdrop`); if (backdrop.hidden) return; + drawerSequence++; backdrop.hidden = true; document.body.classList.remove('drawer-open'); + document.querySelector('.app-shell').inert = false; + if (drawerOpener?.isConnected) drawerOpener.focus(); + } + document.addEventListener('keydown', event => { + const drawer = document.querySelector('.drawer-backdrop:not([hidden])'); + if (event.key !== 'Tab' || !drawer) return; + const focusable = [...drawer.querySelectorAll('button,input,select,textarea,a[href],summary')].filter(el=>!el.disabled && el.getClientRects().length); + const first = focusable[0], last = focusable.at(-1); + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + }); + function showTaskDrawer() { return showDrawer('task'); } + function closeTaskDrawer() { hideDrawer('task'); closeActionForm(); } function renderDetailList(id, items, emptyText, renderItem) { const el = $(id); if (!items.length) { el.innerHTML = `
${escapeHTML(emptyText)}
`; return; } @@ -509,12 +578,12 @@ } function renderTaskDetail(task) { state.selectedTask = task; - text('detail-title', task.handler || '任务详情'); text('detail-id', task.id || '—'); text('detail-state', task.state || 'UNKNOWN'); + text('detail-title', task.handler || '任务详情'); text('detail-id', task.id || '—'); text('detail-state', label(task.state)); $('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-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 resultBlock = $('detail-result-block'); resultBlock.hidden = task.result === undefined; if (task.result !== undefined) $('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; @@ -522,19 +591,20 @@ } 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 ? '可重试' : '不可重试'}
`); + renderDetailList('detail-attempts', items, error ? '当前凭据没有 tasks:read 历史权限。' : '还没有执行尝试。', item => `
第 ${escapeHTML(item.epoch)} 次 · ${escapeHTML(label(item.state))}${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))}
`); + renderDetailList('detail-operations', items, error ? '当前凭据没有 tasks:audit:read 权限。' : '还没有操作审计。', item => `
${escapeHTML(label(item.action || '操作'))}${escapeHTML(item.actor_id || '—')} · ${escapeHTML(formatTime(item.created_at))}
${escapeHTML(shortID(item.id))}
`); } async function openTaskDetail(id) { if (!id) return; if (!state.authenticated) { redirectToLogin(); return; } - showTaskDrawer(); text('detail-title', '正在读取任务…'); text('detail-id', shortID(id)); text('detail-state', '读取中'); $('detail-actions').hidden = true; + const ticket = showTaskDrawer(); state.selectedTask = null; closeActionForm(); $('detail-result-block').hidden = true; text('detail-title', '正在读取任务…'); text('detail-id', 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; } + if (ticket !== drawerSequence) return; + if (taskResult.status !== 'fulfilled') { text('detail-title', '任务读取失败'); text('detail-action-note', taskResult.reason?.message || '无法读取任务详情'); text('detail-state','不可用'); renderAttempts([],false); renderOperations([],false); 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'); @@ -562,27 +632,28 @@ catch (error) { $('action-error').textContent = error.message || '操作失败'; } finally { button.disabled = false; } } - function showScheduleDrawer() { $('schedule-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); } - function closeScheduleDrawer() { $('schedule-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeScheduleAction(); } + function showScheduleDrawer() { return showDrawer('schedule'); } + function closeScheduleDrawer() { hideDrawer('schedule'); closeScheduleAction(); } function closeScheduleAction() { $('schedule-action-form').hidden = true; $('schedule-action-error').textContent = ''; } function scheduleRuleLabel(rule) { if (!rule) return '—'; return rule.kind === 'CRON' ? `Cron · ${rule.cron || '—'}` : `固定间隔 · ${rule.interval_ms || '—'} ms`; } function renderScheduleOccurrences(items, error) { text('schedule-occurrences-count', error ? '权限不足' : `${items.length} 条`); - renderDetailList('schedule-occurrences', items, error ? '当前凭据没有 schedules:read 触发历史权限。' : '还没有触发记录。', item => `
${escapeHTML(item.decision || 'UNKNOWN')} · ${escapeHTML(formatTime(item.scheduled_at))}${escapeHTML(item.reason || '—')} · 版本 ${escapeHTML(item.schedule_version || '—')}
${item.task_id ? escapeHTML(shortID(item.task_id)) : '无任务'}
`); + renderDetailList('schedule-occurrences', items, error ? '当前凭据没有 schedules:read 触发历史权限。' : '还没有触发记录。', item => `
${escapeHTML(label(item.decision || 'UNKNOWN'))} · ${escapeHTML(formatTime(item.scheduled_at))}${escapeHTML(label(item.reason || '—'))} · 版本 ${escapeHTML(item.schedule_version || '—')}
${item.task_id ? escapeHTML(shortID(item.task_id)) : '无任务'}
`); } function renderScheduleDetail(schedule) { state.selectedSchedule = schedule; text('schedule-detail-title', schedule.name || '定时计划'); text('schedule-detail-id', schedule.id || '—'); text('schedule-detail-state', schedule.paused ? '已暂停' : '运行中'); $('schedule-detail-state').className = `state-pill ${schedule.paused ? 'waiting' : 'succeeded'}`; - text('schedule-detail-updated', `最后更新 ${formatTime(schedule.updated_at)}`); text('schedule-detail-name', schedule.name || '—'); text('schedule-detail-queue', shortID(schedule.queue_id)); text('schedule-detail-revision', schedule.revision || '—'); text('schedule-detail-version', schedule.current_version || '—'); text('schedule-detail-next', formatTime(schedule.next_fire_at)); - const definition = schedule.current?.definition || {}; const rule = definition.rule || {}; text('schedule-detail-timezone', rule.timezone || '—'); text('schedule-detail-rule-kind', rule.kind || '—'); $('schedule-detail-rule').innerHTML = `${escapeHTML(scheduleRuleLabel(rule))}漏跑 ${escapeHTML(definition.policy?.misfire || '—')} · 重叠 ${escapeHTML(definition.policy?.overlap || '—')} · 补偿上限 ${escapeHTML(definition.policy?.catchup_limit || '—')}`; + text('schedule-detail-updated', `最后更新 ${formatTime(schedule.updated_at)}`); text('schedule-detail-name', schedule.name || '—'); text('schedule-detail-queue', shortID(schedule.queue_id)); text('schedule-detail-revision', schedule.revision || '—'); text('schedule-detail-version', schedule.current_version || '—'); text('schedule-detail-next', schedule.paused ? `已暂停(原触发点 ${formatTime(schedule.next_fire_at)})` : formatTime(schedule.next_fire_at)); + const definition = schedule.current?.definition || {}; const rule = definition.rule || {}; text('schedule-detail-timezone', rule.timezone || '—'); text('schedule-detail-rule-kind', label(rule.kind)); $('schedule-detail-rule').innerHTML = `${escapeHTML(scheduleRuleLabel(rule))}漏跑 ${escapeHTML(label(definition.policy?.misfire || '—'))} · 重叠 ${escapeHTML(label(definition.policy?.overlap || '—'))} · 补偿上限 ${escapeHTML(definition.policy?.catchup_limit || '—')}`; $('schedule-actions').hidden = false; text('schedule-control-button', schedule.paused ? '恢复计划' : '暂停计划'); text('schedule-action-note', schedule.paused ? '恢复会按冻结的漏跑策略继续处理暂停期间的触发点。' : '暂停只停止未来触发,已经创建的任务继续执行。'); } async function openScheduleDetail(id) { if (!id) return; if (!state.authenticated) { redirectToLogin(); return; } - showScheduleDrawer(); text('schedule-detail-title', '正在读取计划…'); text('schedule-detail-id', shortID(id)); $('schedule-actions').hidden = true; $('schedule-occurrences').innerHTML = '
正在读取触发记录…
'; + const ticket = showScheduleDrawer(); state.selectedSchedule = null; closeScheduleAction(); text('schedule-detail-title', '正在读取计划…'); text('schedule-detail-id', id); $('schedule-actions').hidden = true; $('schedule-occurrences').innerHTML = '
正在读取触发记录…
'; const encoded = encodeURIComponent(id); const [scheduleResult, occurrencesResult] = await Promise.allSettled([request(`/v1/schedules/${encoded}`), request(`/v1/schedules/${encoded}/occurrences?limit=20`)]); - if (scheduleResult.status !== 'fulfilled') { text('schedule-detail-title', '计划读取失败'); text('schedule-action-note', scheduleResult.reason?.message || '无法读取计划详情'); return; } + if (ticket !== drawerSequence) return; + if (scheduleResult.status !== 'fulfilled') { text('schedule-detail-title', '计划读取失败'); text('schedule-action-note', scheduleResult.reason?.message || '无法读取计划详情'); text('schedule-occurrences','无法读取触发记录'); return; } renderScheduleDetail(extractData(scheduleResult.value)); renderScheduleOccurrences(occurrencesResult.status === 'fulfilled' ? extractItems(occurrencesResult.value) : [], occurrencesResult.status === 'rejected'); } function openScheduleAction() { @@ -597,14 +668,14 @@ catch (error) { $('schedule-action-error').textContent = error.message || '计划控制失败'; } finally { button.disabled = false; } } - function showWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); } + function showWorkflowDrawer() { return showDrawer('workflow'); } function closeWorkflowAction() { state.workflowActionMode = ''; $('workflow-action-form').hidden = true; $('workflow-action-error').textContent = ''; } 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 closeWorkflowDrawer() { hideDrawer('workflow'); closeWorkflowAction(); closeWorkflowRecovery(); } + function showSubscriptionDrawer() { return showDrawer('subscription'); } 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 closeSubscriptionDrawer() { hideDrawer('subscription'); 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 = ''; @@ -699,7 +770,7 @@ } 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-title', workflow.business_key || '流程实例'); text('workflow-detail-id', workflow.id || '—'); text('workflow-detail-state', label(workflow.state)); $('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 权限;服务端会再次校验控制修订、状态、幂等键和操作原因。'); @@ -709,19 +780,20 @@ text('workflow-steps-count', error ? '权限不足' : `${items.length} 个`); renderDetailList('workflow-steps', items, error ? '当前凭据没有 workflows:read 步骤权限。' : '该实例还没有可展示的步骤。', item => { const link = item.task_id ? `任务 ${shortID(item.task_id)}` : (item.activation_id ? `激活 ${shortID(item.activation_id)}` : '未激活'); - return `
${escapeHTML(item.key || item.id || '未命名步骤')} · ${escapeHTML(item.kind || 'STEP')}${escapeHTML(item.state || 'UNKNOWN')} · ${escapeHTML(link)} · 修订 ${escapeHTML(item.revision || '—')}
${item.activation_sequence ? `第 ${escapeHTML(item.activation_sequence)} 代` : '—'}
`; + return `
${escapeHTML(item.key || item.id || '未命名步骤')} · ${escapeHTML(item.kind || 'STEP')}${escapeHTML(label(item.state))} · ${escapeHTML(link)} · 修订 ${escapeHTML(item.revision || '—')}
${item.activation_sequence ? `第 ${escapeHTML(item.activation_sequence)} 代` : '—'}
`; }); } function renderWorkflowEvents(payload, error) { const items = Array.isArray(payload?.items) ? payload.items : []; text('workflow-events-count', error ? '权限不足' : `${items.length} 条`); - renderDetailList('workflow-events', items, error ? '当前凭据没有 workflows:audit:read 事件审计权限。' : '还没有保存的状态事件。', item => `
${escapeHTML(item.type || '状态事件')} · ${escapeHTML(item.state || '—')}${escapeHTML(item.state_scope || item.aggregate_kind || 'WORKFLOW')} ${item.step_key ? `· ${escapeHTML(item.step_key)}` : ''} · ${escapeHTML(formatTime(item.occurred_at))}
修订 ${escapeHTML(item.aggregate_revision || '—')}
`); + renderDetailList('workflow-events', items, error ? '当前凭据没有 workflows:audit:read 事件审计权限。' : '还没有保存的状态事件。', item => `
${escapeHTML(label(item.type || '状态事件'))} · ${escapeHTML(label(item.state || '—'))}${escapeHTML(label(item.state_scope || item.aggregate_kind || 'WORKFLOW'))} ${item.step_key ? `· ${escapeHTML(item.step_key)}` : ''} · ${escapeHTML(formatTime(item.occurred_at))}
修订 ${escapeHTML(item.aggregate_revision || '—')}
`); } async function openWorkflowDetail(id) { if (!id) return; if (!state.authenticated) { redirectToLogin(); return; } - 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 ticket = showWorkflowDrawer(); state.selectedWorkflow = null; closeWorkflowAction(); closeWorkflowRecovery(); $('workflow-actions').hidden = true; $('workflow-recovery-block').hidden = true; text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', 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 (ticket !== drawerSequence) return; if (workflowResult.status !== 'fulfilled') { text('workflow-detail-title', '流程读取失败'); text('workflow-detail-note', workflowResult.reason?.message || '无法读取流程详情'); renderWorkflowSteps({}, true); renderWorkflowEvents({}, true); return; } const workflow = extractData(workflowResult.value); const stepsPayload = stepsResult.status === 'fulfilled' ? extractData(stepsResult.value) : {}; renderWorkflowDetail(workflow); @@ -732,13 +804,13 @@ function renderSubscriptionDetail(subscription) { state.selectedSubscription = subscription; text('subscription-detail-title', subscription.name || '结果订阅'); text('subscription-detail-id', subscription.id || '—'); text('subscription-detail-state', subscription.retired_at ? '已退役' : 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(', ') : '全部结果类型'); + 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.map(label).join('、') : '全部结果类型'); $('subscription-actions').hidden = !!subscription.retired_at; text('subscription-control-button', subscription.enabled === false ? '启用订阅' : '暂停订阅'); text('subscription-detail-note', subscription.retired_at ? `退役于 ${formatTime(subscription.retired_at)},截止水位 ${subscription.retired_sequence};未来结果不再订阅,历史交付仍可查询。` : '订阅控制需要 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))}
${String(item.state || '') === 'EXPIRED' ? `` : `${escapeHTML(shortID(item.event_id))}`}
`); + renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `
${escapeHTML(label(item.state))} · 序号 ${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)); })); } let subscriptionRetirement = null; @@ -772,36 +844,48 @@ async function openSubscriptionDetail(id) { if (!id) return; if (!state.authenticated) { redirectToLogin(); return; } - showSubscriptionDrawer(); closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', shortID(id)); $('subscription-deliveries').innerHTML = '
正在读取交付历史…
'; + const ticket = showSubscriptionDrawer(); state.selectedSubscription = null; closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', 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 (ticket !== drawerSequence) return; 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'); } - async function refresh() { - if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning'); + async function refresh(background = false) { + const view = currentView(); $('refresh-button').classList.add('spinning'); try { await loadHealth(); - if (currentView() === 'applications') { await window.orbitApplications.load(); setConnection(true); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); return; } - if (!state.authenticated || !state.scopeReady) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; } - const tasks = state.queue ? await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : { items: [] }; const taskItems = extractItems(tasks); renderTasks(taskItems); - const optional = await Promise.allSettled([ - state.queue ? request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : Promise.resolve({ items: [] }), - request('/v1/workflows?limit=20'), - state.queue ? request(`/v1/subscriptions?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : Promise.resolve({ items: [] }), - ]); - 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; - renderSchedules(scheduleItems || []); - if (!scheduleItems) $('schedule-list').textContent = '定时计划读取失败,请刷新重试或检查权限'; - renderWorkflows(workflowItems || []); - if (!workflowItems) { $('workflow-list').textContent = '流程实例读取失败,请刷新重试或检查权限'; text('metric-workflows', '—'); text('metric-workflows-sub', '读取失败'); } - renderSubscriptions(subscriptionItems || []); - if (!subscriptionItems) { $('subscription-list').textContent = '结果订阅读取失败,请刷新重试或检查权限'; text('metric-subscriptions', '—'); text('metric-subscriptions-sub', '读取失败'); } - 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'); } + if (view !== currentView()) return; + $('refresh-error').hidden = true; + if (!state.authenticated || !state.scopeReady) return; + if (lists[view]) await loadList(view, background === true); + else if (view === 'overview') await loadOverview(); + else if (view === 'applications') await window.orbitApplications.load(); + else if (view === 'retention') await loadRetention(); + else if (view === 'archives') await loadArchives(); + text('last-updated', `更新于 ${formatTime(new Date())}`); + } catch(error) { text('refresh-error',`服务暂不可用:${error.message}。请刷新重试。`); $('refresh-error').hidden=false; text('last-updated','刷新失败'); } + finally { $('refresh-button').classList.remove('spinning'); } } + for (const [kind,page] of Object.entries(lists)) { + for (const direction of ['first','prev','next']) $(`${kind}-${direction}`).addEventListener('click',()=>{ if(page.move(direction)) loadList(kind); }); + $(`${kind}-limit`).addEventListener('change',event=>{ page.limit=Number(event.target.value); page.reset(); loadList(kind); }); + const form = $(`${kind}-filters`); + if(form) { + form.addEventListener('submit',event=>{ event.preventDefault(); try { page.reset(core.readFilters(Object.fromEntries(new FormData(form)))); loadList(kind); } catch(error) { text(`${kind}-error`,error.message); $(`${kind}-error`).hidden=false; } }); + form.addEventListener('reset',()=>{ page.reset({}); loadList(kind); }); + } + } + document.querySelectorAll('[data-lookup]').forEach(form=>form.addEventListener('submit',event=>{event.preventDefault(); const kind=form.dataset.lookup; detailOpeners[kind]($(`${kind}-lookup`).value.trim());})); + document.addEventListener('click',event=>{ + const resource=event.target.closest('[data-resource-id]'); + if(resource) detailOpeners[resource.dataset.resourceKind]?.(resource.dataset.resourceId); + const attention=event.target.closest('[data-attention]'); + if(attention) { const kind=attention.dataset.attention, value=attention.dataset.filterState; lists[kind].reset(value ? {state:value} : {}); const form=$(`${kind}-filters`); if(form) { for(const input of form.querySelectorAll('input,select')) input.value=''; if(value) form.elements.state.value=value; } setView(kind); } + const copy=event.target.closest('[data-copy-id]'); + if(copy) { const value=$(copy.dataset.copyId)?.textContent; if(value && value!=='—') navigator.clipboard.writeText(value).then(()=>showToast('资源 ID 已复制')).catch(()=>showToast('复制失败,请选择 ID 手动复制')); } + }); + document.querySelector('.skip-link').addEventListener('click', event => { event.preventDefault(); $('main-content').focus(); }); + $('auto-refresh').addEventListener('change',()=>{ text('refresh-mode-label',$('auto-refresh').checked ? '每 10 秒自动刷新' : '自动刷新已暂停'); if($('auto-refresh').checked) refresh(); }); async function logout() { await fetch('/console/auth/logout', { method: 'POST', credentials: 'same-origin' }); redirectToLogin(); } $('archive-plan-form').addEventListener('submit', saveArchivePlan); $('archive-plan-form').addEventListener('input', () => { archiveUI.dirty = true; }); @@ -815,13 +899,13 @@ $('retention-kind').addEventListener('change', resetRetentionExport); $('retention-restart-button').addEventListener('click', resetRetentionExport); $('retention-form').addEventListener('input', event => { if (event.target.matches('input, textarea')) { retentionUI.dirty = true; syncRetentionControls(); } }); - $('logout-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh); $('retention-form').addEventListener('submit', submitRetention); $('retention-preview-button').addEventListener('click', previewRetention); $('retention-export-button').addEventListener('click', exportRetention); + $('logout-button').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh); $('retention-form').addEventListener('submit', submitRetention); $('retention-preview-button').addEventListener('click', previewRetention); $('retention-export-button').addEventListener('click', exportRetention); $('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-retire-button').addEventListener('click', () => openSubscriptionAction('RETIRE')); $('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(); }); 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); - ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(loadScope).then(() => Promise.all([state.scopeReady ? loadRetention() : Promise.resolve(), refresh(), state.scopeReady && currentView() === 'archives' ? loadArchives() : Promise.resolve()])); }).catch(() => redirectToLogin()); - state.timer = setInterval(() => { if (state.authenticated) { refresh(); if (state.scopeReady && currentView() === 'archives') loadArchives(); } }, 10000); + ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(loadScope).then(() => refresh()); }).catch(() => redirectToLogin()); + state.timer = setInterval(() => { if (state.authenticated && $('auto-refresh').checked && !document.hidden && !document.querySelector('.drawer-backdrop:not([hidden]), dialog[open]') && !lists[currentView()]?.loading && !document.activeElement?.matches('input,select,textarea') && !(currentView()==='applications' && document.querySelector('#access-detail details[open]'))) refresh(true); }, 10000); })(); diff --git a/internal/transport/httpserver/console/applications.js b/internal/transport/httpserver/console/applications.js index 121399a..7e66191 100644 --- a/internal/transport/httpserver/console/applications.js +++ b/internal/transport/httpserver/console/applications.js @@ -3,7 +3,7 @@ const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE', helpCredential: '' }; const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效'; - let tokenRequest = 0, tokenTarget = null; + let tokenRequest = 0, tokenTarget = null, selectionSequence = 0; const resourceMessages = { 'resource is retired; history remains available':'资源已退役,历史仍可查询。', 'queue has unfinished tasks, including tasks needing review':'队列还有未完成或待核查任务,请先结清。', @@ -27,6 +27,8 @@ return value.data; } const queueMemory = new Map(); + function filterApplications() { const term=$('access-search').value.trim().toLocaleLowerCase(); const items=[...document.querySelectorAll('#access-list [data-access-id]')]; items.forEach(item=>{item.hidden=!!term&&!item.textContent.toLocaleLowerCase().includes(term);}); $('access-search-empty').hidden=!term||items.some(item=>!item.hidden); } + $('access-search').addEventListener('input',filterApplications); function rememberedQueue(namespace) { try { return localStorage.getItem(`orbit.access.queue.${namespace}`) || queueMemory.get(namespace) || ''; } catch { return queueMemory.get(namespace) || ''; } } function rememberQueue() { const value = $('access-queue').value; queueMemory.set(ui.selected, value); try { localStorage.setItem(`orbit.access.queue.${ui.selected}`, value); } catch {} renderConfig(); } function config(token = '••••••••', queue = $('access-queue').value, namespace = ui.selected) { @@ -42,16 +44,21 @@ try { const page = await api(ui.after ? `?after=${encodeURIComponent(ui.after)}` : ''); ui.next = page.next || ''; $('access-next').disabled = !ui.next; $('access-list').innerHTML = page.items.length ? page.items.map(entry => ``).join('') : '

还没有接入应用,点击右上角新建。

'; + filterApplications(); if (!ui.selected && page.items.length) await select(page.items[0].namespace_id); else if (ui.selected && !$('access-dialog').open && !$('access-token-dialog').open) await select(ui.selected); } catch (error) { $('access-error').textContent = message(error); } finally { ui.loading = false; } } async function select(namespace) { - if (namespace !== ui.selected) { retirement = null; $('access-retire-form').hidden = true; } - const requested = namespace; ui.selected = namespace; + if (namespace !== ui.selected) { + retirement = null; $('access-retire-form').hidden = true; + $('access-detail').hidden = true; $('access-empty').hidden = false; + $('access-empty').textContent = '正在读取应用配置…'; + } + const requested = namespace, ticket = ++selectionSequence; ui.selected = namespace; try { - const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested) return; ui.detail = detail; + const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested || ticket !== selectionSequence) return; ui.detail = detail; $('access-empty').hidden = true; $('access-detail').hidden = false; $('access-title').textContent = detail.entry.application; $('access-status').textContent = detail.entry.enabled ? '已启用' : '已停用'; $('access-app-id').textContent = detail.entry.application_id; $('access-ns-id').textContent = namespace; $('access-scope').textContent = `${detail.entry.environment} / ${detail.entry.namespace}`; @@ -79,13 +86,18 @@
接入配置 · 当前 Token${c.revoked_at ? '凭据已撤销' : c.token_stored ? '•••••••••••••••• · 已加密保管' : '历史凭据 · 导入当前 Token 后可随时查看'}
${rotationHelp} -
${c.previous_valid_until && !c.revoked_at ? `` : ''}
-
${groups.map(a => `${escape(a)}`).join('')}
查看具体权限

${c.permissions.actions.map(escape).join(' · ')}
授权队列:${escape(c.permissions.queues.join(', '))}

+
凭据管理与权限
${c.previous_valid_until && !c.revoked_at ? `` : ''}
+
${groups.map(a => `${escape(a)}`).join('')}
查看具体权限

${c.permissions.actions.map(escape).join(' · ')}
授权队列:${escape(c.permissions.queues.join(', '))}

`; }).join('') || '

该环境暂无凭据。

'; if ($('access-help-dialog').open) renderCredentialHelp(); document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig(); - } catch (error) { $('access-error').textContent = message(error); } + } catch (error) { + if (ticket !== selectionSequence) return; + $('access-error').textContent = message(error); + $('access-detail').hidden = true; $('access-empty').hidden = false; + $('access-empty').textContent = '应用配置读取失败,请刷新重试。'; + } } function renderConfig() { const q = ui.detail?.queues.find(q => q.id === $('access-queue').value); $('access-queue-note').textContent = !q ? '请明确选择接入队列,避免复制到错误的目标。' : `当前目标:${q.name} · ${q.paused ? '已暂停,任务暂不执行' : '可用'}。选择只影响复制配置,不修改服务端路由。`; $('access-retire-queue').disabled = !q || !!q.retired_at; try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } } function closeToken() { diff --git a/internal/transport/httpserver/console/console-core.js b/internal/transport/httpserver/console/console-core.js new file mode 100644 index 0000000..adfbde8 --- /dev/null +++ b/internal/transport/httpserver/console/console-core.js @@ -0,0 +1,98 @@ +/* 查询状态独立于视图;不缓存凭据,也不对结果进行全量客户端筛选。 */ +((root) => { + const labels = { + WAITING: '等待中', + LEASED: '已分配', + RUNNING: '执行中', + NEEDS_REVIEW: '待核查', + SUCCEEDED: '已成功', + FAILED: '已失败', + CANCELLED: '已取消', + CANCELLING: '取消中', + PENDING: '待处理', + ACKED: '已确认', + EXPIRED: '次数耗尽', + UNKNOWN: '结果不明', + ENABLED: '已启用', + PAUSED: '已暂停', + RETIRED: '已退役', + ACTIVE: '已激活', + CONSUMED: '已消费', + REJECTED: '已拒绝', + TIMED_OUT: '已超时', + FIRED: '已触发', + COALESCED: '合并触发', + SKIPPED: '已跳过', + MISFIRE_COALESCED: '漏跑合并', + SCHEDULED: '按计划触发', + CRON: 'Cron 表达式', + FIXED_RATE: '固定间隔', + COALESCE: '合并补偿', + SKIP: '跳过', + CATCH_UP: '逐次补偿', + SERIAL: '串行', + ALLOW: '允许并行', + TASK: '任务', + TIMER: '定时等待', + SIGNAL: '外部信号', + POLL: '有界查询', + PARALLEL: '并行', + SEQUENCE: '顺序', + SWITCH: '条件分支', + WORKFLOW: '流程', + STEP: '步骤', + 'task.submit': '提交任务', + 'task.cancel': '取消任务', + 'task.retry': '人工重试', + 'workflow.started': '流程启动', + 'workflow.advanced': '流程推进', + 'workflow.step.changed': '步骤状态变更', + 'workflow.signal.changed': '信号状态变更', + 'workflow.signal.accepted': '收到信号', + 'task.succeeded': '任务成功', + 'task.failed': '任务失败', + 'task.cancelled': '任务取消' + }; + function listURL(kind, queue, filters = {}, cursor = '', limit = 20) { + if (!['tasks', 'schedules', 'workflows', 'subscriptions'].includes(kind)) throw new Error('不支持的资源类型'); + if (kind !== 'workflows' && !queue) throw new Error('请先选择队列'); + const params = new URLSearchParams({ limit: String(limit) }); + if (kind !== 'workflows') params.set('queue_id', queue); + if (['tasks', 'workflows'].includes(kind)) { + for (const key of ['state', 'business_key', 'created_from', 'created_before']) { + // 精确业务键不能 trim,否则尾部空格对应的任务无法被查询。 + if (filters[key] !== undefined && filters[key] !== '') params.set(key, filters[key]); + } + } + if (cursor) params.set('cursor', cursor); + return `/v1/${kind}?${params}`; + } + function readFilters(values) { + const out = {}; + for (const key of ['state', 'business_key']) if (values[key] !== undefined && values[key] !== '') out[key] = values[key]; + for (const key of ['created_from', 'created_before']) { + if (!values[key]) continue; + const date = new Date(values[key]); + if (!Number.isFinite(date.getTime())) throw new Error('请填写有效的创建时间'); + out[key] = date.toISOString(); + } + if (out.created_from && out.created_before && out.created_from >= out.created_before) throw new Error('结束时间必须晚于开始时间'); + return out; + } + class ListPage { + constructor() { this.filters = {}; this.limit = 20; this.cursor = ''; this.history = []; this.next = ''; this.sequence = 0; this.loading = false; } + reset(filters = this.filters) { this.sequence++; this.filters = { ...filters }; this.cursor = ''; this.history = []; this.next = ''; this.loading = false; } + begin() { this.loading = true; return ++this.sequence; } + accept(sequence, page) { if (sequence !== this.sequence) return false; this.next = page.next_cursor || ''; this.loading = false; return true; } + fail(sequence) { if (sequence !== this.sequence) return false; this.loading = false; this.next = ''; return true; } + move(direction) { + if (this.loading) return false; + if (direction === 'next' && this.next) { this.history.push(this.cursor); this.cursor = this.next; } + else if (direction === 'prev' && this.history.length) this.cursor = this.history.pop(); + else if (direction === 'first') { this.cursor = ''; this.history = []; } + else return false; + this.next = ''; return true; + } + } + root.OrbitConsole = Object.freeze({ labels, label: value => labels[value] || value || '未知', listURL, readFilters, ListPage }); +})(globalThis); diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index b42c180..0b26635 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -3,29 +3,28 @@ - + Orbit Console · 星轨控制台 -
-
+
-
+
Orbit/总览
-
+
本地环境 - 尚未刷新 + 尚未刷新
+
⊞
应用范围正在读取…
@@ -56,96 +56,57 @@

-
-
-
ORBIT CONTROL PLANE
-

让每一次执行
都清晰可见。

-

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

-
- - -
-
- -
- +
WORKSPACE / OVERVIEW

运行概览

先看运行状态,再处理需要关注的工作。

-
服务状态✦
—等待就绪检查
-
任务总览◇
—当前会话加载
-
流程实例⌘
—等待工作流权限
-
结果订阅⌁
—等待事件权限
+
服务状态
—正在检查服务就绪状态
+ + +
- -
-
-
RUNTIME / SNAPSHOT

运行统计

等待数据
-
-
当前窗口成功率—等待任务数据
-
活跃计划—等待计划数据
-
待核查流程—等待流程数据
-
待交付结果—等待订阅数据
-
-
-
任务状态分布—
-
-
成功
0
-
执行中
0
-
等待
0
-
异常
0
-
+
以下为各类最近 20 条记录的快照,不代表全部数据。任务、计划、订阅按队列统计,流程按应用统计。等待数据
+
+

任务状态分布

当前队列的最近任务

—
+
+
已成功
—
+
执行中
—
+
等待执行
—
+
失败 / 待核查
—
+
已取消
—
+
+
窗口成功占比—等待数据
+
启用中的计划—等待数据
+
待核查流程—等待数据
+
待确认结果—等待数据
-
-
CONTROL PLANE

登录状态

未连接
-
↗
当前页面API 地址自动跟随当前页面
-
队列范围自动配置
登录会话未登录
-

服务状态会自动检查;需要业务数据时再加载凭据。

- -
+

需要关注

来自当前快照的异常与积压

!
正在检查…
+

最近任务

点击任务查看结果与执行历史

任务 / 业务键处理器状态创建时间操作
+
服务与会话信息 正在检查
服务地址—
队列—
登录方式—

-
-
-
-
PULSE / TASKS

任务执行流

0 项
-
任务应用 / 环境处理器状态修订创建时间
◇还没有任务数据登录后按账号权限自动加载
-
-
+

任务执行

查询任务状态、查看执行历史,按证据处理失败和重试。

+
时间范围
按浏览器本地时区查询,设置后点击「查询」。
正在读取…当前应用 / 队列 · 按创建时间倒序
任务 / 业务键应用 / 环境处理器状态执行次数创建时间操作
正在读取…
第 1 页
-
-
-
PULSE / SCHEDULES

定时计划

点击计划查看详情
◷
等待计划数据需要 schedules:read 权限
-
+

定时计划

查看计划状态与触发历史,暂停或恢复后续调度。

+
正在读取…当前应用 / 队列 · 按创建时间倒序
计划名称应用 / 环境状态当前版本下次触发操作
正在读取…
第 1 页
-
-
-
WEAVE / WORKFLOWS

流程实例

点击实例查看详情
⌘
等待流程数据需要 workflows:read 权限
-
+

流程实例

跟踪每一步进展,查看等待原因与恢复记录。

+
时间范围
按浏览器本地时区查询,设置后点击「查询」。
正在读取…当前应用环境 · 按创建时间倒序
流程 / 业务键应用 / 环境状态固定版本创建时间操作
正在读取…
第 1 页
-
-
-
EVENTS / DELIVERY

结果订阅

自动刷新
⌁
等待订阅数据需要 events:read 权限
-
+

结果订阅

查看待确认结果、交付历史和订阅状态。

+
正在读取…当前应用 / 队列 · 按创建时间倒序
订阅名称应用 / 环境状态待确认 / 上限创建时间操作
正在读取…
第 1 页
-
-
DATA / ARCHIVE JOBS

让历史数据,有序归档。

配置周期,自动分批保存历史;每次执行都有进度与校验记录。

+

归档作业

设置归档周期,查看执行进度和失败原因。

周期计划—已启用 / 7 个类别
本页待处理—失败作业保留进度,等待处理
本页归档记录—数据库已确认的根记录数
-
SCHEDULE

周期配置

正在读取
+

周期配置

正在读取
开启周期归档首次启用立即运行,之后按周期执行。
@@ -160,7 +121,7 @@
-
EXECUTION HISTORY

归档作业

0 项
+

归档作业

0 项

源数据库记录始终保留。文件写入服务端持久卷,页面展示已确认的批次和 SHA-256 摘要。

正在读取归档作业…
@@ -175,9 +136,10 @@
+

保留策略

配置历史保留窗口,预览和导出归档候选。

-
DATA / RETENTION

历史数据保留策略

默认策略
+

历史数据保留策略

默认策略

这里配置各类历史记录的最短保留时间。保存策略只记录配置,不会立即删除数据;预览和导出会核对终态与未确认结果。当前提供只读快照,不自动清理源数据。

允许导出只读归档默认关闭。开启并保存后,可分批导出已满足保留窗口的历史;未确认结果仍受保护。
@@ -208,40 +170,39 @@
-
CONNECT / APPLICATIONS

让应用,轻松接入。

创建应用、管理凭据,把任务交给正确的队列。

-
01 创建应用与队列→02 复制 SDK 配置→03 注册 Worker 开始执行
+

应用接入

选择应用和队列,复制配置即可开始接入。

-
YOUR APPLICATIONS

接入应用

-

正在读取应用…

+

接入应用

+

正在读取应用…

选择应用,查看队列和接入配置。
- +