diff --git a/README.md b/README.md index aac44c7..40246f3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - **Pulse · 脉动**:异步任务、延迟执行、定时计划、执行授权、重试与结果订阅。 - **Weave · 织流**:研发定义并发布流程,支持顺序、并行、条件分支、等待信号、有界查询和人工恢复。 -- **Console**:账号密码登录的管理页面,查看任务、计划、流程、结果交付及归档作业。 +- **Console**:账号密码登录的管理页面,按应用与环境查看任务、计划、流程、结果交付及归档作业。 主分支:`main`。当前版本:**`v0.1.1`**。源码、SDK 和 Outbox 适配包采用固定版本发布,已发布标签不覆盖。变更见 [CHANGELOG](CHANGELOG.md)。 diff --git a/cmd/orbit/main.go b/cmd/orbit/main.go index 1dc9aee..4c2e349 100644 --- a/cmd/orbit/main.go +++ b/cmd/orbit/main.go @@ -125,8 +125,10 @@ func run() error { httpserver.RegisterWorkflows(server, instance.New(mysql.NewWorkflowInstanceStore(db)), control.New(mysql.NewWorkflowControlStore(db)), weavesignal.New(mysql.NewWorkflowSignalStore(db)), recovery.New(mysql.NewWorkflowRecoveryStore(db))) httpserver.RegisterConsoleRetention(server, consoleAuth, retentionStore, retentionStore) httpserver.RegisterConsoleArchiveJobs(server, consoleAuth, retentionStore, cfg.ArchiveStorageID) - httpserver.RegisterConsoleApplications(server, consoleAuth, mysql.NewConsoleApplicationStore(db, cfg.ConsoleApplicationAdmin)) - httpserver.WrapConsoleAuth(server, consoleAuth) + consoleApplications := mysql.NewConsoleApplicationStore(db, cfg.ConsoleApplicationAdmin) + httpserver.RegisterConsoleApplications(server, consoleAuth, consoleApplications) + httpserver.RegisterConsoleScopes(server, consoleApplications) + httpserver.WrapConsoleAuth(server, consoleAuth, consoleApplications) httpserver.WrapMetrics(server, metrics) if cfg.ArchiveRoot != "" { diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 1d5a919..586870d 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -289,3 +289,9 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专 - 000032 与基线增加旧摘要/固定截止,无外键。控制台/CLI 默认 7 天过渡;同一身份的 API、WS 控制命令和跨节点重连均核验两代有效性,权限与任务归属保持不变。 - 结束过渡、两代同时撤销、过渡期防重复换证、旧 v1 固定操作兼容、幂等审计及中文模型注释随功能交付。 - 验证结果见[长期凭据与平滑换证](reports/credential-overlap.md)。SDK 无接口修改;调用方须在截止前滚动更新配置并收尾在途执行。 + +### 控制台应用范围与列表归属(2026-09-27) + +- 已实现:页顶应用/环境与队列筛选、列表和详情应用标签、URL 保持筛选;切换隔离旧请求和表单,错误范围不降级读取默认数据。 +- 单管理员通过 Cookie 显式选择范围;业务事务重新核验会话与目标应用,SDK 不支持范围覆盖,订阅原凭据绑定不变。 +- 同步提供[范围接口](api/console-scope.md)、真实 MySQL 授权/审计/退出后拒绝测试和 HTTP 列表及订阅管理回归。最终结果见[本批报告](reports/console-scope.md)。 diff --git a/docs/api/console-applications.md b/docs/api/console-applications.md index 3550af7..09b7230 100644 --- a/docs/api/console-applications.md +++ b/docs/api/console-applications.md @@ -21,7 +21,7 @@ export ORBIT_CONSOLE_APPLICATION_ADMIN=admin 5. 「平滑换证」保留 application_id、namespace_id、credential_id、队列、任务和订阅;新 Token 立即生效,旧 Token 在配置的过渡期内继续使用(页面默认 7 天,支持 1/7/30 天),便于逐台更新配置。详情展示旧 Token 实际截止,全部服务切换后可点「结束过渡」。 6. 默认「长期有效」,不需要每月人工换证;已有凭据可点「调整有效期」直接设为长期,原 Token 保持不变。 -环境与命名空间形成隔离边界。不同应用应分别创建;同一应用的多个命名空间在列表中分别展示。页面目前支持新建应用及其初始队列,未提供应用删除、身份迁移、跨应用任务列表或现有应用新增命名空间的表单;已有 CLI 的 `--application-id` 仍可增加命名空间,队列 SDK 可增加同范围队列。接入管理页不会改变其他业务页面的账号默认范围。 +环境与命名空间形成隔离边界。不同应用应分别创建;同一应用的多个命名空间在列表中分别展示。页面支持新建应用及其初始队列;业务列表可通过应用筛选栏切换范围,分别展示各应用数据。未提供应用删除、身份迁移或现有应用新增命名空间的表单;已有 CLI 的 `--application-id` 仍可增加命名空间,队列 SDK 可增加同范围队列。 ## 2. 路由与认证 @@ -145,3 +145,5 @@ SET_EXPIRY 不生成新 Token,只更新当前有效期;valid_days=0 为长 | 数据库/网络故障 | 5xx / 网络错误 | 不假定失败,保留原操作重报 | 可重复运行的真实 HTTP 场景见 [test-console-applications.py](../../scripts/test-console-applications.py),标准容器入口 `make console-e2e` 同时验证此链路。基础 SDK 使用方式见 [SDK 接入](../sdk/README.md)。 + +业务数据的应用筛选、队列分页及会话隔离见[应用范围接口](console-scope.md)。 diff --git a/docs/api/console-scope.md b/docs/api/console-scope.md new file mode 100644 index 0000000..6a67163 --- /dev/null +++ b/docs/api/console-scope.md @@ -0,0 +1,52 @@ +# 控制台应用范围 + +此接口服务于内置控制台的单管理员,用于按应用环境读取列表、详情并执行已有管理操作;不是 SDK 的跨应用调用方式。业务服务继续使用自己应用的 Token 和队列。 + +## 查看范围元数据 + +`GET /console/scope?after=` 只接受登录 Cookie,返回 `data`: + +```json +{ + "entry": { + "application_id": "11111111111111111111111111111111", + "application": "sample-app", + "namespace_id": "22222222222222222222222222222222", + "namespace": "default", + "environment": "test", + "enabled": true + }, + "queues": [{"id": "33333333333333333333333333333333", "name": "jobs"}], + "can_switch": true +} +``` + +不指定范围头时返回账号原始应用及授权队列。队列按 ID 升序,每页最多 100 项,存在更多时返回 `next`,下次用 `after=next`;没有更多则省略。应用目录继续使用 `GET /console/applications` 的 50 项分页。名称全部来自数据库,ID 为授权身份,不按应用名称猜测关联。 + +## 显式选择应用环境 + +部署配置 `ORBIT_CONSOLE_APPLICATION_ADMIN` 指定的登录管理员可发送: + +```http +X-Orbit-Console: 1 +X-Orbit-Namespace: 22222222222222222222222222222222 +``` + +以上请求头只能搭配原登录 Cookie,不能搭配 `Authorization`。服务端不签发、不返回新业务 Token,不更改浏览器会话原始归属。部署配置为空或登录名不匹配时拒绝切换。 + +支持范围: + +| 页面 | 请求 | 数据范围 | +| --- | --- | --- | +| 任务执行 | 现有 `/v1/tasks` 列表、详情、历史及管理动作 | 所选命名空间 + 队列 | +| 定时计划 | 现有 `/v1/schedules` 查询、暂停/继续 | 所选命名空间 + 队列 | +| 流程实例 | 现有 `/v1/workflows` 查询、控制和人工恢复 | 所选命名空间,含流程固定版本所有队列 | +| 结果订阅 | 现有 `/v1/subscriptions` 查询、启停和交付重试 | 所选命名空间 + 队列,管理员可管理该范围内不同 SDK 凭据的已有订阅 | +| 保留策略 | `/console/retention`、预览、导出 | 所选命名空间 | +| 归档作业 | `/console/archive/plans`、`jobs` 及原管理动作 | 所选命名空间 | + +队列选项仅对任务、计划、订阅生效。此桥接入口不提供创建任务/计划/订阅、发布定义、启动流程、发送信号、Worker 执行或结果消费权限。SDK 原有的权限与订阅归属校验不变。 + +每个业务事务重新核验管理员账号、原会话、绑定凭据有效性以及原/目标应用启用状态,再执行原有的资源归属、版本、幂等、核查证据和审计规则。管理审计操作者为 `console:`,操作归入目标命名空间。读取其他范围的资源 ID 不会越权返回数据。不存在的范围、未授权范围不自动回退。 + +缺少 Cookie 或会话失效返回 401;不允许的范围切换、混用 SDK Bearer、跨站写操作返回 403;目标应用停用或资源不属于当前范围返回 404。非法范围 ID 返回 400。页面切换采用完整导航,旧详情和请求不会在新应用页面呈现;URL 只保留非敏感 ID。 diff --git a/docs/console.md b/docs/console.md index f04167c..95d48c2 100644 --- a/docs/console.md +++ b/docs/console.md @@ -11,7 +11,7 @@ http://127.0.0.1:8080/console/ - 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。 - 通过数据库中的控制台账号登录;服务端使用 HttpOnly 会话 Cookie 调用现有授权 API,浏览器不接触业务 Token。 - 总览页直接展示当前查询窗口的任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;登录和权限信息不会占用统计区域。 -- 展示任务、定时计划、流程实例和结果订阅的最近摘要。 +- 页顶选择应用、环境与队列;任务、计划、流程、订阅、归档列表和详情显示应用归属。筛选保存在 URL,刷新、复制链接及后退可恢复。 - 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。 - 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。 - 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。 @@ -20,10 +20,10 @@ http://127.0.0.1:8080/console/ - 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。 - `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。 - 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。 -- 左侧六个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows`、`#/subscriptions` 和 `#/retention` 定位;切换视图不会把所有模块堆在一张长页面上。 +- 左侧八个模块是独立视图:总览、任务、计划、流程、订阅、保留策略、归档作业和应用接入。 - “保留策略”只允许单管理员查看和修改命名空间级窗口;修改需要版本号、幂等键和原因并留下审计。预览和只读导出不会删除源数据,未确认交付、未知效果和未结束流程不会成为候选。 - 任务状态按 10 秒自动刷新;页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。 -- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。 +- 普通账号沿用绑定凭据范围;部署指定管理员可显式选择应用环境管理。读取失败显示错误,避免当作空列表。 ## 本地打开 @@ -48,7 +48,7 @@ secret-manager read orbit/console-admin-password | \ --output .local/production.credentials.json ``` -密码不会进入命令参数、日志或 JSON 文件,初始化失败时资源和账号一起回滚。已经完成业务初始化的环境,也可以由数据库管理员单独创建控制台账号。账号绑定一个已有的命名空间凭据,账号权限就是该凭据的权限;这不会轮换或输出业务 Token。推荐从私有凭据文件读取命名空间和凭据标识,并从标准输入交付密码: +密码不会进入命令参数、日志或 JSON 文件,初始化失败时资源和账号一起回滚。已经完成业务初始化的环境,也可以由数据库管理员单独创建控制台账号。账号绑定一个已有的命名空间凭据,未指定为应用管理员的账号沿用该凭据权限;这不会轮换或输出业务 Token。推荐从私有凭据文件读取命名空间和凭据标识,并从标准输入交付密码: ```sh ./bin/orbit-admin console-user create \ @@ -74,10 +74,18 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx' ## 安全边界 -控制台不直接访问数据库,也不绕过业务凭据权限。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台账号绑定业务凭据,不自动获得跨命名空间权限;不支持跨命名空间聚合,不代替平台的权限与审计 API。 +控制台通过服务端授权访问数据库。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。普通账号不自动获得跨命名空间权限;部署指定的单管理员可显式选择应用范围,每个业务事务重新核验原会话及目标应用。各应用分别展示,SDK 权限不变。 不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。 ## 应用接入 -侧栏「应用接入」提供应用创建、队列与凭据查看、Token 重新生成和完整 SDK 配置交付。配置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一管理登录名;本地 Compose 已配置。其他业务页面继续使用账号原有默认范围。[功能、接口、权限和恢复说明](api/console-applications.md)。 +侧栏「应用接入」提供应用创建、队列与凭据查看、Token 重新生成和完整 SDK 配置交付。配置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一管理登录名;本地 Compose 已配置。业务页面支持该管理员选择应用范围,订阅原 SDK 凭据绑定不变。[功能、接口、权限和恢复说明](api/console-applications.md)。 + +## 按应用查看数据 + +页顶「应用 / 环境」选择已创建的应用,再选择队列。任务、定时计划和结果订阅按队列读取;流程、保留策略和归档按应用环境所属命名空间读取,页面有范围提示。应用每页 50 项、队列每页 100 项,选择「加载更多…」获取下一页。没有队列的应用仍能查看流程和归档。 + +切换采用完整导航,隔离旧请求和详情;有未保存策略、未确认操作或打开的详情时需先处理完。URL 只保存非敏感 ID。无效范围显示错误,不降级读取默认应用。总览仍是最近查询窗口的统计,不是全库累计。 + +只有部署指定管理员可以切换应用;其他账号只展示自己的应用和授权队列。后台停用应用、撤销绑定凭据或退出账号后,原页面范围不能继续授予权限。[范围接口及授权规则](api/console-scope.md)。 diff --git a/docs/reports/console-scope.md b/docs/reports/console-scope.md new file mode 100644 index 0000000..341ca61 --- /dev/null +++ b/docs/reports/console-scope.md @@ -0,0 +1,9 @@ +# 控制台应用范围回归 + +## 变更 + +任务、计划、流程、订阅和归档列表显示应用名称与环境;保留策略及详情显示归属。管理员可选择应用环境和队列,范围保存在 URL,页面切换隔离旧数据。后台按 Cookie 会话授权,普通 SDK 不接受范围覆盖。无表结构变更,无新增外键。 + +## 验证记录 + +验证进行中,最终结果在本次交付前更新。 diff --git a/internal/consoleauth/scope.go b/internal/consoleauth/scope.go new file mode 100644 index 0000000..d5a08e4 --- /dev/null +++ b/internal/consoleauth/scope.go @@ -0,0 +1,21 @@ +package consoleauth + +import ( + "context" + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" +) + +// Scope 是当前查看范围的脱敏元数据。队列分页独立于应用分页。 +type Scope struct { + Entry ApplicationEntry `json:"entry"` // Entry 数据库核实的应用、环境和命名空间。 + Queues []AccessQueue `json:"queues"` // Queues 本页授权队列,最多 100 项。 + Next core.ID `json:"next,omitempty"` // Next 下一页队列排他起点。 + CanSwitch bool `json:"can_switch"` // CanSwitch 仅部署指定的管理员可以切换应用。 +} + +// Scopes 只供登录 Cookie 使用,SDK 不具备范围切换接口。 +type Scopes interface { + ResolveScope(context.Context, auth.Token, core.ID) (auth.Token, error) + GetScope(context.Context, auth.Token, core.ID) (Scope, error) +} diff --git a/internal/storage/mysql/console_scope.go b/internal/storage/mysql/console_scope.go new file mode 100644 index 0000000..49b2062 --- /dev/null +++ b/internal/storage/mysql/console_scope.go @@ -0,0 +1,135 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + + "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" + "git.yuntuo.hk/k2safe/orbit/internal/fault" + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" +) + +// consoleScopeActor 在业务读写的同一事务校验原会话、部署指定管理员及目标应用。 +// 仅持共享身份锁,之后沿用业务既有的额度→队列→资源锁序。 +func consoleScopeActor(ctx context.Context, tx *sql.Tx, t auth.Token) (core.ID, error) { + if t.Console == nil || t.Console.Username == "" || t.Console.SessionNamespaceID.Validate() != nil || t.NamespaceID.Validate() != nil { + return "", fault.Forbidden + } + var user core.ID + err := tx.QueryRowContext(ctx, `SELECT u.id FROM orbit_console_sessions s + JOIN orbit_console_users u ON u.id=s.user_id AND u.namespace_id=s.namespace_id AND u.credential_id=s.credential_id + JOIN orbit_credentials c ON c.namespace_id=s.namespace_id AND c.id=s.credential_id + JOIN orbit_namespaces n ON n.id=s.namespace_id JOIN orbit_applications a ON a.id=n.application_id + WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=? + AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6) AND u.enabled=TRUE AND u.username=? + AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)) + AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, t.Console.SessionNamespaceID, t.CredentialID, t.Digest, t.Console.Username).Scan(&user) + if errors.Is(err, sql.ErrNoRows) { + return "", fault.Forbidden + } + if err != nil { + return "", err + } + if t.Console.UserID != "" && user != t.Console.UserID { + return "", fault.Forbidden + } + var id core.ID + err = tx.QueryRowContext(ctx, `SELECT n.id FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=? AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, t.NamespaceID).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", fault.NotFound + } + return user, err +} + +func consoleScopePermissions() auth.Permissions { + return auth.Permissions{Actions: []string{"tasks:read", "tasks:cancel", "tasks:review", "tasks:retry", "tasks:audit:read", "queues:read", "queues:manage", "schedules:read", "schedules:manage", "events:read", "events:manage", "workflows:read", "workflows:control", "workflows:recover", "workflows:audit:read"}, Queues: []string{"*"}} +} + +func (s *ConsoleApplicationStore) ResolveScope(ctx context.Context, t auth.Token, target core.ID) (auth.Token, error) { + if target.Validate() != nil { + return auth.Token{}, fault.Invalid("invalid console namespace") + } + out := t + out.NamespaceID = target + out.Console = &auth.ConsoleIdentity{SessionNamespaceID: t.NamespaceID, Username: s.username} + err := WithTx(ctx, s.db, func(tx *sql.Tx) error { + user, err := consoleScopeActor(ctx, tx, out) + out.Console.UserID = user + return err + }) + return out, err +} + +func (s *ConsoleApplicationStore) GetScope(ctx context.Context, t auth.Token, after core.ID) (consoleauth.Scope, error) { + out := consoleauth.Scope{Queues: []consoleauth.AccessQueue{}} + if after != "" && after.Validate() != nil { + return out, fault.Invalid("invalid queue cursor") + } + err := WithTx(ctx, s.db, func(tx *sql.Tx) error { + p, err := authenticate(ctx, tx, t) + if err != nil { + return err + } + if t.Console != nil { + out.CanSwitch = true + } else { + // 元数据接口也必须核验真实控制台会话,SDK 不能读取此入口。 + var user core.ID + err = tx.QueryRowContext(ctx, `SELECT u.id,(u.username=?) FROM orbit_console_sessions s JOIN orbit_console_users u ON u.id=s.user_id AND u.namespace_id=s.namespace_id AND u.credential_id=s.credential_id WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=? AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6) AND u.enabled=TRUE FOR SHARE`, s.username, t.NamespaceID, t.CredentialID, t.Digest).Scan(&user, &out.CanSwitch) + if errors.Is(err, sql.ErrNoRows) { + return fault.Unauthorized + } + if err != nil { + return err + } + } + e := &out.Entry + err = tx.QueryRowContext(ctx, `SELECT a.id,a.name,n.id,n.name,n.environment,(a.enabled AND n.enabled) FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=?`, t.NamespaceID).Scan(&e.ApplicationID, &e.Application, &e.NamespaceID, &e.Namespace, &e.Environment, &e.Enabled) + if err != nil { + return err + } + query := `SELECT id,name FROM pulse_queues WHERE namespace_id=?` + args := []any{t.NamespaceID} + if after != "" { + query += ` AND id>?` + args = append(args, after) + } + all := false + for _, q := range p.Queues { + if q == "*" { + all = true + } + } + if !all { + query += " AND id IN (" + for i, q := range p.Queues { + if i > 0 { + query += "," + } + query += "?" + args = append(args, core.ID(q)) + } + query += ")" + } + rows, err := tx.QueryContext(ctx, query+` ORDER BY id LIMIT 101`, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var q consoleauth.AccessQueue + if err := rows.Scan(&q.ID, &q.Name); err != nil { + return err + } + out.Queues = append(out.Queues, q) + } + if len(out.Queues) > 100 { + out.Queues = out.Queues[:100] + out.Next = out.Queues[99].ID + } + return rows.Err() + }) + return out, err +} diff --git a/internal/storage/mysql/console_scope_integration_test.go b/internal/storage/mysql/console_scope_integration_test.go new file mode 100644 index 0000000..7f867cc --- /dev/null +++ b/internal/storage/mysql/console_scope_integration_test.go @@ -0,0 +1,137 @@ +package mysql + +import ( + "context" + "os" + "testing" + "time" + + "git.yuntuo.hk/k2safe/orbit/internal/fault" + "git.yuntuo.hk/k2safe/orbit/internal/retention" + "git.yuntuo.hk/k2safe/orbit/platform/auth" +) + +func TestIntegrationConsoleScope(t *testing.T) { + dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("requires disposable MySQL") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + db, err := Open(ctx, dsn, 8, 2) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err = Migrate(ctx, db); err != nil { + t.Fatal(err) + } + username := "scope-" + string(testID(t)) + password := "test-password-for-scopes-123" + var source, target BootstrapResult + for i, dest := range []*BootstrapResult{&source, &target} { + in := BootstrapInput{Application: string(testID(t)), Namespace: "default", Environment: "test", Queue: "scope-queue"} + if i == 0 { + in.ConsoleUsername = username + in.ConsolePassword = password + } + if err = Bootstrap(ctx, db, in, func(v BootstrapResult) error { *dest = v; return nil }); err != nil { + t.Fatal(err) + } + } + accounts := NewConsoleAuthStore(db) + login, err := accounts.Login(ctx, username, password, "scope-test", "127.0.0.1") + if err != nil { + t.Fatal(err) + } + session, _ := auth.Parse(login.Token) + sdk, _ := auth.Parse(source.Token) + scopes := NewConsoleApplicationStore(db, username) + initial, err := scopes.GetScope(ctx, session, "") + if err != nil || initial.Entry.NamespaceID != source.NamespaceID || !initial.CanSwitch { + t.Fatalf("initial scope: %+v %v", initial, err) + } + if _, err = scopes.GetScope(ctx, sdk, ""); fault.Code(err) != "UNAUTHENTICATED" { + t.Fatalf("SDK scope metadata: %v", err) + } + // 普通账号的队列白名单使用 BINARY ID 查询,不能以字符串错误比较成空列表。 + if _, err = db.ExecContext(ctx, `UPDATE orbit_credentials SET permissions=JSON_OBJECT('actions',JSON_ARRAY('queues:read'),'queues',JSON_ARRAY(?)) WHERE namespace_id=? AND id=?`, string(source.QueueID), source.NamespaceID, source.CredentialID); err != nil { + t.Fatal(err) + } + limited, err := NewConsoleApplicationStore(db, "another-admin").GetScope(ctx, session, "") + if err != nil || limited.CanSwitch || len(limited.Queues) != 1 || limited.Queues[0].ID != source.QueueID { + t.Fatalf("limited scope: %+v %v", limited, err) + } + last, err := scopes.GetScope(ctx, session, source.QueueID) + if err != nil || len(last.Queues) != 0 || last.Next != "" { + t.Fatalf("queue cursor: %+v %v", last, err) + } + if _, err = scopes.ResolveScope(ctx, sdk, target.NamespaceID); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatalf("SDK override: %v", err) + } + for _, name := range []string{"", "another-admin"} { + if _, err = NewConsoleApplicationStore(db, name).ResolveScope(ctx, session, target.NamespaceID); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatalf("unconfigured admin: %v", err) + } + } + scoped, err := scopes.ResolveScope(ctx, session, target.NamespaceID) + if err != nil { + t.Fatal(err) + } + tasks := NewTaskStore(db) + if _, err = tasks.GetQueue(ctx, scoped, target.QueueID); err != nil { + t.Fatal(err) + } + if _, err = tasks.GetQueue(ctx, scoped, source.QueueID); fault.Code(err) != "NOT_FOUND" { + t.Fatalf("foreign queue: %v", err) + } + if err = tasks.AuthorizeSubmit(ctx, scoped); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatalf("scope must not create work: %v", err) + } + got, err := scopes.GetScope(ctx, scoped, "") + if err != nil || got.Entry.ApplicationID != target.ApplicationID || len(got.Queues) != 1 || got.Queues[0].ID != target.QueueID { + t.Fatalf("target metadata: %+v %v", got, err) + } + // 完整应用/队列名称从数据库读取,包含特殊字符也不改变授权身份。 + policy := NewRetentionPolicyStore(db) + in := retention.UpdateInput{ExpectedRevision: 0, Enabled: false, IdempotencyDays: 120, TaskHistoryDays: 400, WorkflowHistoryDays: 400, EventLogDays: 180, DeliveryHistoryDays: 120, InboxHistoryDays: 120, AuditLogDays: 2555, Reason: "scope policy test"} + saved, err := policy.UpdateRetentionPolicy(ctx, scoped, "scope-policy", in) + if err != nil || saved.NamespaceID != target.NamespaceID { + t.Fatalf("policy: %+v %v", saved, err) + } + if _, err = policy.UpdateRetentionPolicy(ctx, scoped, "scope-policy", in); err != nil { + t.Fatal(err) + } + original, err := policy.GetRetentionPolicy(ctx, source.NamespaceID) + if err != nil || original.Revision != 0 { + t.Fatalf("changed original scope: %+v %v", original, err) + } + var actor string + if err = db.QueryRowContext(ctx, `SELECT actor_id FROM orbit_audit_events WHERE namespace_id=? AND action='retention.policy.update'`, target.NamespaceID).Scan(&actor); err != nil { + t.Fatal(err) + } + if actor != scoped.ActorID() { + t.Fatalf("audit actor: %s", actor) + } + // 解析后停用目标应用/退出会话,旧的内存范围不能继续授权。 + if _, err = db.ExecContext(ctx, `UPDATE orbit_applications SET enabled=FALSE WHERE id=?`, target.ApplicationID); err != nil { + t.Fatal(err) + } + if _, err = tasks.GetQueue(ctx, scoped, target.QueueID); fault.Code(err) != "NOT_FOUND" { + t.Fatalf("disabled target: %v", err) + } + if _, err = db.ExecContext(ctx, `UPDATE orbit_applications SET enabled=TRUE WHERE id=?`, target.ApplicationID); err != nil { + t.Fatal(err) + } + if err = accounts.RevokeSession(ctx, session); err != nil { + t.Fatal(err) + } + in.ExpectedRevision = 1 + if _, err = policy.UpdateRetentionPolicy(ctx, scoped, "after-logout", in); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatalf("stale scope write: %v", err) + } + var foreignKeys int + if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&foreignKeys); err != nil || foreignKeys != 0 { + t.Fatalf("foreign keys %d %v", foreignKeys, err) + } +} diff --git a/internal/storage/mysql/event_queries.go b/internal/storage/mysql/event_queries.go index 674e49c..7844aa1 100644 --- a/internal/storage/mysql/event_queries.go +++ b/internal/storage/mysql/event_queries.go @@ -81,6 +81,10 @@ func (s *EventStore) ListSubscriptions(ctx context.Context, t auth.Token, queue } q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND credential_id=? AND queue_id=?` args := []any{t.NamespaceID, t.CredentialID, queue} + if t.Console != nil { + q = `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND queue_id=?` + args = []any{t.NamespaceID, queue} + } if in.Cursor != "" { c, err := parseEventCursor(in.Cursor, t, queue, "subscriptions", "") if err != nil { diff --git a/internal/storage/mysql/event_subscriptions.go b/internal/storage/mysql/event_subscriptions.go index c706da8..3721fbc 100644 --- a/internal/storage/mysql/event_subscriptions.go +++ b/internal/storage/mysql/event_subscriptions.go @@ -28,10 +28,16 @@ const subscriptionColumns = `id,queue_id,credential_id,name,revision,enabled,fil func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Permissions, id core.ID, lock bool) (events.Subscription, error) { q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND credential_id=? AND queue_id IS NOT NULL` + args := []any{t.NamespaceID, id, t.CredentialID} + if t.Console != nil { + // 调用方已在同一事务 authenticate;管理员可管理应用内已有订阅,SDK 仍绑定原凭据。 + q = `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND queue_id IS NOT NULL` + args = []any{t.NamespaceID, id} + } if lock { q += ` FOR UPDATE` } - out, err := scanSubscription(tx.QueryRowContext(ctx, q, t.NamespaceID, id, t.CredentialID)) + out, err := scanSubscription(tx.QueryRowContext(ctx, q, args...)) if errors.Is(err, sql.ErrNoRows) { return out, fault.NotFound } diff --git a/internal/storage/mysql/retention_policy.go b/internal/storage/mysql/retention_policy.go index d36d0a6..9eea27e 100644 --- a/internal/storage/mysql/retention_policy.go +++ b/internal/storage/mysql/retention_policy.go @@ -71,8 +71,11 @@ func (s *RetentionPolicyStore) GetRetentionPolicy(ctx context.Context, namespace } // retentionActor 在策略事务内验证控制台会话。普通 SDK 凭据不能修改保留策略。 -// 管理员必须具备当前命名空间全部队列的管理范围;不添加角色体系或跨命名空间权限。 +// 普通会话要求全部队列管理范围;部署指定管理员的显式范围在同一事务重新核验。 func retentionActor(ctx context.Context, tx *sql.Tx, token auth.Token) (core.ID, error) { + if token.Console != nil { + return consoleScopeActor(ctx, tx, token) + } p, err := authenticate(ctx, tx, token) if err != nil { return "", err diff --git a/internal/storage/mysql/task_store.go b/internal/storage/mysql/task_store.go index 8203388..6d1fe8f 100644 --- a/internal/storage/mysql/task_store.go +++ b/internal/storage/mysql/task_store.go @@ -45,6 +45,10 @@ func (s *TaskStore) transaction(ctx context.Context, token auth.Token, action st }) } func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permissions, error) { + if token.Console != nil { + _, err := consoleScopeActor(ctx, tx, token) + return consoleScopePermissions(), err + } var digest core.Digest var raw, previous []byte var currentValid, previousValid bool @@ -178,7 +182,7 @@ func appendAuditWithEvidence(ctx context.Context, tx *sql.Tx, token auth.Token, if err != nil { return "", err } - _, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,evidence_ref,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,NULLIF(?,''),?,?)", token.NamespaceID, id, operation, string(token.CredentialID), action, kind, resource, reason, evidence, string(old), string(current)) + _, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,evidence_ref,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,NULLIF(?,''),?,?)", token.NamespaceID, id, operation, token.ActorID(), action, kind, resource, reason, evidence, string(old), string(current)) return id, err } func dbNow(ctx context.Context, tx *sql.Tx) (time.Time, error) { diff --git a/internal/transport/httpserver/archive_jobs.go b/internal/transport/httpserver/archive_jobs.go index d519c01..665bf85 100644 --- a/internal/transport/httpserver/archive_jobs.go +++ b/internal/transport/httpserver/archive_jobs.go @@ -29,7 +29,7 @@ func RegisterConsoleArchiveJobs(server *http.Server, consoleAuth RetentionConsol writeError(w, "", fault.Unauthorized) return auth.Token{}, false } - return t, true + return scopedConsoleToken(r, t), true } key := func(w http.ResponseWriter, r *http.Request) (string, bool) { keys := r.Header.Values("Idempotency-Key") diff --git a/internal/transport/httpserver/console.go b/internal/transport/httpserver/console.go index bed010a..350d020 100644 --- a/internal/transport/httpserver/console.go +++ b/internal/transport/httpserver/console.go @@ -13,6 +13,8 @@ import ( "time" "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" + "git.yuntuo.hk/k2safe/orbit/internal/fault" + core "git.yuntuo.hk/k2safe/orbit/model" "git.yuntuo.hk/k2safe/orbit/platform/auth" ) @@ -119,11 +121,32 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) { // ConsoleSessionMiddleware 将 HttpOnly 会话转换为内部 Bearer 请求头。 // 浏览器脚本永远看不到业务令牌;现有所有 v1 handler 仍走同一个鉴权入口。 -func ConsoleSessionMiddleware(next http.Handler, consoleAuth consoleauth.Store) http.Handler { +func ConsoleSessionMiddleware(next http.Handler, consoleAuth consoleauth.Store, scopes ...consoleauth.Scopes) http.Handler { if next == nil || consoleAuth == nil { return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if values := r.Header.Values("X-Orbit-Namespace"); len(values) > 0 { + if len(values) != 1 || len(scopes) == 0 || scopes[0] == nil || len(r.Header.Values("Authorization")) > 0 || r.Header.Get("X-Orbit-Console") != "1" || !consoleScopePath(r) { + writeError(w, "", fault.Forbidden) + return + } + if err := http.NewCrossOriginProtection().Check(r); err != nil { + writeError(w, "", fault.Forbidden) + return + } + token, ok := consoleCookieToken(r) + if !ok || consoleAuth.ValidateSession(r.Context(), token) != nil { + writeError(w, "", fault.Unauthorized) + return + } + scoped, err := scopes[0].ResolveScope(r.Context(), token, core.ID(values[0])) + if err != nil { + writeError(w, "", err) + return + } + r = r.WithContext(context.WithValue(r.Context(), consoleScopeKey{}, scoped)) + } if len(r.Header.Values("Authorization")) == 0 { if token, ok := consoleCookieToken(r); ok && consoleAuth.ValidateSession(r.Context(), token) == nil { clone := r.Clone(r.Context()) diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js index 6a4b3fc..f4361c2 100644 --- a/internal/transport/httpserver/console/app.js +++ b/internal/transport/httpserver/console/app.js @@ -1,5 +1,5 @@ (() => { - const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, 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 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 pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业', applications: '应用接入' }; @@ -26,10 +26,12 @@ 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]); + $('application-scope').hidden = next === 'applications'; + text('scope-hint', ['workflows', 'retention', 'archives'].includes(next) ? '当前应用环境 · 不受队列筛选影响' : '任务、计划和订阅按所选队列显示'); if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`; window.scrollTo({ top: 0, behavior: 'smooth' }); - if (next === 'retention' && state.authenticated) loadRetention(); - if (next === 'archives' && state.authenticated) loadArchives(); + if (next === 'retention' && state.scopeReady) loadRetention(); + if (next === 'archives' && state.scopeReady) loadArchives(); if (next === 'applications' && state.authenticated) window.orbitApplications.load(); } function redirectToLogin() { @@ -43,13 +45,18 @@ state.authenticated = true; return true; } async function request(path, options = {}) { - const headers = { Accept: 'application/json', ...(options.headers || {}) }; + const scoped = path.startsWith('/v1/') || path === '/console/scope' || path.startsWith('/console/scope?') || path.startsWith('/console/retention') || path.startsWith('/console/archive/'); + const headers = { Accept: 'application/json', ...(scoped && state.scopeNamespace ? { 'X-Orbit-Namespace': state.scopeNamespace, 'X-Orbit-Console': '1' } : {}), ...(options.headers || {}) }; + const mutation = options.method && options.method !== 'GET'; + if (mutation) state.mutations++; + try { const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers, credentials: 'same-origin' }); const raw = await response.text(); let body = {}; try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; } if (response.status === 401) { state.authenticated = false; redirectToLogin(); throw new Error('登录已过期,请重新登录'); } if (!response.ok) { const error = new Error(body?.error?.message || body?.message || `HTTP ${response.status}`); error.status = response.status; throw error; } return body; + } finally { if (mutation) state.mutations--; } } async function loadConsoleConfig() { try { @@ -65,6 +72,97 @@ // 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。 } } + const scopeUI = { applications: [], nextApplication: '', nextQueue: '', busy: false }; + function scopeBadge() { + const e = state.scope?.entry; + if (!e) return ''; + return `${escapeHTML(e.application)}${escapeHTML(e.environment)}`; + } + function appendScopeOptions(select, items, selected, more, kind) { + select.replaceChildren(); + for (const item of items) { + const option = document.createElement('option'); + option.value = kind === 'application' ? item.namespace_id : item.id; + option.textContent = kind === 'application' ? `${item.application} · ${item.environment} / ${item.namespace}${item.enabled ? '' : ' · 已停用'}` : item.name; + option.disabled = kind === 'application' && !item.enabled; + select.append(option); + } + if (!items.length) select.append(new Option('暂无队列', '')); + if (more) select.append(new Option('加载更多…', '__more__')); + select.value = selected; + } + async function loadScopeApplications() { + const page = extractData(await request(`/console/applications${scopeUI.nextApplication ? `?after=${encodeURIComponent(scopeUI.nextApplication)}` : ''}`)); + for (const item of page.items || []) if (!scopeUI.applications.some(e => e.namespace_id === item.namespace_id)) scopeUI.applications.push(item); + scopeUI.nextApplication = page.next || ''; + appendScopeOptions($('scope-application'), scopeUI.applications, state.scope.entry.namespace_id, scopeUI.nextApplication, 'application'); + } + async function loadScope() { + const params = new URLSearchParams(location.search); + state.scopeNamespace = params.get('application') || ''; + try { + const scope = extractData(await request('/console/scope')); + state.scope = scope; + // 指定管理员在默认应用内也使用显式管理范围,普通账号继续沿用原有队列权限。 + if (scope.can_switch && !state.scopeNamespace) { + state.scopeNamespace = scope.entry.namespace_id; + state.scope = extractData(await request('/console/scope')); + } + const queues = state.scope.queues; + const requested = params.get('queue'); + const preferred = requested || state.consoleConfig?.default_queue_id; + 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; + } + scopeUI.nextQueue = state.scope.next || ''; + scopeUI.applications = [state.scope.entry]; + appendScopeOptions($('scope-application'), scopeUI.applications, scope.entry.namespace_id, '', 'application'); + appendScopeOptions($('scope-queue'), queues, state.queue, scopeUI.nextQueue, 'queue'); + $('scope-application').disabled = !scope.can_switch; + $('scope-queue').disabled = !queues.length; + if (scope.can_switch) { + try { await loadScopeApplications(); } catch (error) { text('scope-error', '应用目录读取失败:' + error.message); } + } + document.querySelectorAll('.detail-scope').forEach(el => { el.innerHTML = scopeBadge(); }); + $('retention-scope').innerHTML = scopeBadge(); + text('scope-summary', `${scope.entry.application} / ${scope.entry.environment} / ${scope.entry.namespace}`); + state.scopeReady = true; renderConnectionSummary(); + } catch (error) { + state.queue = ''; state.scopeReady = false; + text('scope-error', `当前应用范围不可用:${error.message}。可点击“重置范围”返回默认应用。`); + } + } + function navigateScope(namespace, queue = '') { + if (state.mutations || retentionUI.busy || archiveUI.busy || retentionUI.pendingSave || archiveUI.pending) { showToast('有操作尚未确认,请先核对完成后再切换应用'); return false; } + if (retentionUI.dirty || archiveUI.dirty || document.querySelector('.drawer-backdrop:not([hidden])')) { showToast('请先保存配置并关闭详情,再切换查看范围'); return false; } + const url = new URL(location.href); url.searchParams.delete('application'); url.searchParams.delete('queue'); + if (namespace) url.searchParams.set('application', namespace); + if (queue) url.searchParams.set('queue', queue); + // 完整导航隔离不同范围的请求、详情及幂等状态;后退和刷新都恢复 URL 中的范围。 + location.assign(url.href); return true; + } + $('scope-application').addEventListener('change', async event => { + const value = event.target.value; event.target.value = state.scope?.entry.namespace_id || ''; + if (scopeUI.busy) return; + if (value !== '__more__') { navigateScope(value); return; } + scopeUI.busy = true; + try { await loadScopeApplications(); } catch (error) { showToast(error.message); } finally { scopeUI.busy = false; } + }); + $('scope-queue').addEventListener('change', async event => { + const value = event.target.value; event.target.value = state.queue; + if (scopeUI.busy) return; + if (value !== '__more__') { navigateScope(state.scopeNamespace, value); return; } + scopeUI.busy = true; + try { + const page = extractData(await request(`/console/scope?after=${encodeURIComponent(scopeUI.nextQueue)}`)); + for (const queue of page.queues) if (!state.scope.queues.some(q => q.id === queue.id)) state.scope.queues.push(queue); + scopeUI.nextQueue = page.next || ''; + appendScopeOptions($('scope-queue'), state.scope.queues, state.queue, scopeUI.nextQueue, 'queue'); + } catch (error) { showToast(error.message); } finally { scopeUI.busy = false; } + }); + $('scope-reset').addEventListener('click', () => navigateScope('')); const retentionUI = { busy: false, dirty: false, cursor: '', batch: 0, count: 0, done: false, download: '', pendingSave: null }; function syncRetentionControls() { document.querySelectorAll('#retention-form input, #retention-form textarea, #retention-form select, #retention-form button').forEach(el => { el.disabled = retentionUI.busy; }); @@ -221,7 +319,7 @@ text('archive-executor', archiveUI.configured ? '已配置持久卷' : '未配置持久卷'); $('archive-executor').classList.toggle('ready', archiveUI.configured); $('archive-plan-chips').innerHTML = archiveUI.plans.map(p => `${archiveLabels[p.kind]} · ${p.enabled ? '开启' : '关闭'}`).join(''); - $('archive-job-list').innerHTML = jobs.length ? jobs.map(j => `
${escapeHTML(archiveLabels[j.kind] || j.kind)}${escapeHTML(archiveStates[j.state] || j.state)}
${escapeHTML(j.id)}
${j.batches} 批已确认${j.roots.toLocaleString('zh-CN')} 条根记录${archiveSize(j.bytes)} / ${j.max_bytes / 1048576} MiB策略 v${escapeHTML(j.policy_revision)}
${j.last_sha256 ? `
查看文件校验摘要${escapeHTML(j.last_sha256)}
` : ''}${j.error_code ? `

${escapeHTML(archiveErrors[j.error_code] || j.error_code)}

` : ''}${j.state === 'RUNNING' ? `

领取次数 ${j.attempts} · 租约至 ${escapeHTML(formatTime(j.lease_until))},中断后可接管。

` : ''}${j.state === 'FAILED' ? `
` : ''}
`).join('') : '
▤还没有归档作业保存并开启周期后,系统会按保留窗口生成作业。
'; + $('archive-job-list').innerHTML = jobs.length ? jobs.map(j => `
${escapeHTML(archiveLabels[j.kind] || j.kind)}${escapeHTML(archiveStates[j.state] || j.state)}
${scopeBadge()}${escapeHTML(j.id)}
${j.batches} 批已确认${j.roots.toLocaleString('zh-CN')} 条根记录${archiveSize(j.bytes)} / ${j.max_bytes / 1048576} MiB策略 v${escapeHTML(j.policy_revision)}
${j.last_sha256 ? `
查看文件校验摘要${escapeHTML(j.last_sha256)}
` : ''}${j.error_code ? `

${escapeHTML(archiveErrors[j.error_code] || j.error_code)}

` : ''}${j.state === 'RUNNING' ? `

领取次数 ${j.attempts} · 租约至 ${escapeHTML(formatTime(j.lease_until))},中断后可接管。

` : ''}${j.state === 'FAILED' ? `
` : ''}
`).join('') : '
▤还没有归档作业保存并开启周期后,系统会按保留窗口生成作业。
'; syncArchiveControls(); } async function loadArchives() { @@ -317,8 +415,8 @@ $('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.slice(0, 8).map(item => `
${escapeHTML(shortID(item.id))}${escapeHTML(item.business_key || '无业务键')}
${escapeHTML(item.handler || '—')}
${escapeHTML(item.handler_version || '')}${escapeHTML(item.state || 'UNKNOWN')}${escapeHTML(item.revision || '—')}${escapeHTML(formatTime(item.created_at))}`).join(''); + 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); } }); @@ -326,8 +424,8 @@ } function renderSchedules(items) { text('schedule-page-hint', `${items.length} 项 · 点击计划查看详情`); - const el = $('schedule-list'); if (!items.length) { el.innerHTML = '
◷
当前没有定时计划需要 schedules:read 权限
'; return; } - el.innerHTML = items.map(item => `
◷
${escapeHTML(item.name || shortID(item.id))}${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}
${escapeHTML(item.paused ? '已暂停' : '运行中')}
`).join(''); + 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); } }); @@ -335,8 +433,8 @@ } function renderWorkflows(items) { text('metric-workflows', String(items.length)); text('metric-workflows-sub', items.length ? '最近实例' : '当前没有实例'); - const el = $('workflow-list'); if (!items.length) { el.innerHTML = '
⌘
当前没有流程实例需要 workflows:read 权限
'; return; } - el.innerHTML = items.map(item => `
⌘
${escapeHTML(item.business_key || shortID(item.id))}${escapeHTML(item.state || '—')} · ${escapeHTML(item.version || '—')}
${escapeHTML(formatTime(item.created_at))}
`).join(''); + 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); } }); @@ -346,8 +444,8 @@ 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 = '
⌁
当前没有结果订阅需要 events:read 权限
'; return; } - el.innerHTML = items.map(item => `
⌁
${escapeHTML(item.name || shortID(item.id))}待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}
${item.enabled === false ? '已暂停' : '已启用'}
`).join(''); + 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.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); } }); @@ -672,19 +770,22 @@ try { await loadHealth(); if (currentView() === 'applications') { await window.orbitApplications.load(); setConnection(true); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); return; } - if (!state.authenticated || !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); + 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([ - request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`), + state.queue ? request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : Promise.resolve({ items: [] }), request('/v1/workflows?limit=20'), - request(`/v1/subscriptions?queue_id=${encodeURIComponent(state.queue)}&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; - if (scheduleItems) renderSchedules(scheduleItems); - if (workflowItems) renderWorkflows(workflowItems); - if (subscriptionItems) renderSubscriptions(subscriptionItems); + renderSchedules(scheduleItems || []); + if (!scheduleItems) $('schedule-list').textContent = '定时计划读取失败,请刷新重试或检查权限'; + renderWorkflows(workflowItems || []); + if (!workflowItems) $('workflow-list').textContent = '流程实例读取失败,请刷新重试或检查权限'; + renderSubscriptions(subscriptionItems || []); + if (!subscriptionItems) $('subscription-list').textContent = '结果订阅读取失败,请刷新重试或检查权限'; 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'); } @@ -709,6 +810,6 @@ 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(() => Promise.all([loadRetention(), refresh(), currentView() === 'archives' ? loadArchives() : Promise.resolve()])); }).catch(() => redirectToLogin()); - state.timer = setInterval(() => { if (state.authenticated) { refresh(); if (currentView() === 'archives') loadArchives(); } }, 10000); + 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); })(); diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index 3695491..e5be834 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -48,6 +48,13 @@ +
+
⊞
应用范围正在读取…
+ + +
任务、计划和订阅按所选队列显示
+

+
@@ -109,7 +116,7 @@
PULSE / TASKS

任务执行流

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

历史数据保留策略

默认策略

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

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