Compare commits
13 Commits
6138c103d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| baa81c4923 | |||
| c0b01debba | |||
| f8c99f3e21 | |||
| d47d0d361d | |||
| 7d0de4f57a | |||
| f56f233704 | |||
| 9a15406f76 | |||
| 23db3d8fe0 | |||
| 755cd2d3c3 | |||
| df76dcb9c0 | |||
| 96d5a3032a | |||
| 92a40f4a37 | |||
| f7409afd24 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -2,6 +2,20 @@
|
||||
|
||||
代码交付主分支为 `main`。采用 `v主版本.次版本.修订号`,已发布标签不可变;补丁按 `v0.1.1`、`v0.1.2` 递增,业务项目固定具体版本。
|
||||
|
||||
## main — 2026-09-29
|
||||
|
||||
- 登录增加 MySQL 持久失败计数、临时锁定、账号/IP 限流、可信代理解析与审计查询,阈值及时间可配置;迁移 000035,无外键。
|
||||
- 补齐根页面和实际滚动区的边界回弹限制,修复侧栏与内容随触控板弹动。
|
||||
|
||||
- 修复长页面滚动体验:固定导航与页头、内容独立滚动,应用详情后台刷新避免重建相同快照及打断交互。
|
||||
|
||||
- 筛选范围改为可搜索的浅色菜单;四个资源列表默认每页 10 条,登录页大屏内容居中,统一筛选区与表格间距。
|
||||
|
||||
- 管理端统一浅色工作台、分组导航、紧凑总览、中文状态和移动端布局。
|
||||
- 任务/流程增加服务端状态、精确业务键和时间筛选;任务、计划、流程、订阅增加游标分页、页大小与 ID 定位。刷新保留查询,迟到响应不覆盖新结果。
|
||||
- 应用搜索、接入配置集中展示、低频操作折叠;详情支持复制 ID、键盘焦点与返回入口,Token 查看/复制及原有审计规则保持。
|
||||
- 此前浅色工作台改版只更新控制台;本次登录保护增加管理接口与迁移 000035,业务 SDK 协议不变。SDK 继续使用 v0.1.2,Outbox 继续使用 v0.1.1。[验收记录](docs/reports/console-refresh.md)。
|
||||
|
||||
## v0.1.2 — 2026-09-27
|
||||
|
||||
- 通用队列/订阅永久退役、活动订阅名额释放、依赖与积压保护;历史、名称、审计和原幂等回执保留。
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -1,17 +1,19 @@
|
||||
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
FROM golang:1.27.1-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS build
|
||||
FROM registry.cn-shenzhen.aliyuncs.com/yuntuo_tools/golang:1.27.1-alpine AS build
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=dev
|
||||
ARG REVISION=unknown
|
||||
ARG BUILD_TIME=unknown
|
||||
|
||||
WORKDIR /src
|
||||
ENV GOWORK=off
|
||||
ENV GO111MODULE=on \
|
||||
CGO_ENABLED=0 \
|
||||
GOPROXY=https://goproxy.cn,direct \
|
||||
GOWORK=off
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
8
Makefile
8
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,12 @@ image:
|
||||
integration:
|
||||
./scripts/test-mysql.sh
|
||||
|
||||
console-check:
|
||||
node --check internal/transport/httpserver/console/console-select.js
|
||||
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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- **Pulse · 脉动**:异步任务、延迟执行、定时计划、执行授权、重试与结果订阅。
|
||||
- **Weave · 织流**:研发定义并发布流程,支持顺序、并行、条件分支、等待信号、有界查询和人工恢复。
|
||||
- **Console**:账号密码登录的管理页面,按应用与环境查看任务、计划、流程、结果交付及归档作业。
|
||||
- **Console**:账号密码登录的管理工作台,按应用与环境查看运行快照;支持任务/流程筛选、资源分页、ID 定位、审计与接入配置复制;具备连续错误临时锁定、账号/IP 限流及登录审计。[控制台使用说明](docs/console.md) · [登录保护配置](docs/console-login-security.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 # 控制台端到端回归
|
||||
```
|
||||
|
||||
|
||||
@@ -93,7 +93,29 @@ func run() error {
|
||||
txStats := mysql.ReadTransactionStats()
|
||||
metrics.RecordTransactionStats(observability.TransactionStats{Retries: txStats.Retries, Exhausted: txStats.Exhausted})
|
||||
}
|
||||
consoleAuth := mysql.NewConsoleAuthStore(db)
|
||||
consoleAuth := mysql.NewConsoleAuthStore(db, cfg.ConsoleLogin)
|
||||
// 登录安全记录独立保留,每分钟有界清理,关闭时等待清理器退出。
|
||||
loginCleanupCtx, stopLoginCleanup := context.WithCancel(root)
|
||||
loginCleanupDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(loginCleanupDone)
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-loginCleanupCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
call, cancel := context.WithTimeout(loginCleanupCtx, 5*time.Second)
|
||||
err := consoleAuth.CleanupLoginSecurity(call)
|
||||
cancel()
|
||||
if err != nil && loginCleanupCtx.Err() == nil {
|
||||
slog.Error("console login security cleanup failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer func() { stopLoginCleanup(); <-loginCleanupDone }()
|
||||
retentionStore := mysql.NewRetentionPolicyStore(db, cfg.RetentionDefaults)
|
||||
server := httpserver.NewWithConsoleAuth(cfg.HTTPAddr, readiness.Check, consoleAuth, service.New(mysql.NewTaskStore(db)))
|
||||
httpserver.RegisterApplicationResources(server, resources.New(mysql.NewTaskStore(db)))
|
||||
|
||||
@@ -25,6 +25,14 @@ x-orbit-environment: &orbit-environment
|
||||
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
||||
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
||||
ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-}
|
||||
ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT: ${ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT:-5}
|
||||
ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW: ${ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW:-15m}
|
||||
ORBIT_CONSOLE_LOGIN_LOCK_DURATION: ${ORBIT_CONSOLE_LOGIN_LOCK_DURATION:-15m}
|
||||
ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT: ${ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT:-10}
|
||||
ORBIT_CONSOLE_LOGIN_IP_LIMIT: ${ORBIT_CONSOLE_LOGIN_IP_LIMIT:-30}
|
||||
ORBIT_CONSOLE_LOGIN_RATE_WINDOW: ${ORBIT_CONSOLE_LOGIN_RATE_WINDOW:-1m}
|
||||
ORBIT_CONSOLE_LOGIN_AUDIT_DAYS: ${ORBIT_CONSOLE_LOGIN_AUDIT_DAYS:-90}
|
||||
ORBIT_CONSOLE_TRUSTED_PROXIES: ${ORBIT_CONSOLE_TRUSTED_PROXIES:-}
|
||||
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
||||
|
||||
services:
|
||||
|
||||
@@ -93,6 +93,14 @@ services:
|
||||
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
|
||||
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
||||
ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-}
|
||||
ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT: ${ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT:-5}
|
||||
ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW: ${ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW:-15m}
|
||||
ORBIT_CONSOLE_LOGIN_LOCK_DURATION: ${ORBIT_CONSOLE_LOGIN_LOCK_DURATION:-15m}
|
||||
ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT: ${ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT:-10}
|
||||
ORBIT_CONSOLE_LOGIN_IP_LIMIT: ${ORBIT_CONSOLE_LOGIN_IP_LIMIT:-30}
|
||||
ORBIT_CONSOLE_LOGIN_RATE_WINDOW: ${ORBIT_CONSOLE_LOGIN_RATE_WINDOW:-1m}
|
||||
ORBIT_CONSOLE_LOGIN_AUDIT_DAYS: ${ORBIT_CONSOLE_LOGIN_AUDIT_DAYS:-90}
|
||||
ORBIT_CONSOLE_TRUSTED_PROXIES: ${ORBIT_CONSOLE_TRUSTED_PROXIES:-}
|
||||
ORBIT_CREDENTIAL_KEY_FILE: ${ORBIT_CREDENTIAL_KEY_FILE:-}
|
||||
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
||||
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
||||
|
||||
@@ -81,6 +81,7 @@ server {
|
||||
}
|
||||
|
||||
# 控制台登录额外限制来源 IP 的尝试速率;业务任务速率仍由 Orbit 的持久化配额和授权决定。
|
||||
# Orbit 另有数据库持久化账号/IP 保护;配置 ORBIT_CONSOLE_TRUSTED_PROXIES 为此代理的实际地址。
|
||||
location = /console/auth/login {
|
||||
limit_req zone=orbit_console_login burst=5 nodelay;
|
||||
proxy_pass http://orbit_backend;
|
||||
|
||||
@@ -320,3 +320,41 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
|
||||
保护:未完成/未知任务、执行许可、活动计划和流程、未退役订阅阻止队列退役;积压、未确认和耗尽交付阻止订阅退役。退役不删历史或审计,不修改凭据。
|
||||
|
||||
进度:实现完成;标准七模块竞态回归、vet/build、SDK/协议/文档门禁、真实 MySQL 完整 integration、控制台 HTTP 端到端均通过。浏览器与本地部署、远端版本交付记录见[本批报告](reports/resource-retirement.md)。
|
||||
|
||||
|
||||
## 2026-09-29:管理端简化与操作体验
|
||||
|
||||
- [x] 全站统一浅色工作台、分组导航、紧凑统计、中文状态、响应式布局和登录页。
|
||||
- [x] 任务/流程按状态、业务键、时间服务端筛选;四个资源列表真实游标翻页与 ID 定位,刷新保持范围与筛选。
|
||||
- [x] 首页明确最近窗口口径,提供待处理入口;应用页集中接入配置,收起技术细节和低频管理操作。
|
||||
- [x] 浏览器验证、查询/翻页回归、基础构建与控制台端到端,本地 Docker 更新和对接说明同步。验收见[管理端改版记录](reports/console-refresh.md)。
|
||||
|
||||
不修改业务执行内核、权限、Token、数据库模型或既有业务资源。
|
||||
|
||||
## 2026-09-29:筛选、分页与大屏布局细化
|
||||
|
||||
- [x] 应用/队列搜索选择、统一状态和每页条数菜单、筛选控件对齐。
|
||||
- [x] 四个资源列表默认每页 10 条,保留服务端游标与原条件。
|
||||
- [x] 登录页左侧内容居中、主工作区最大宽度与移动布局调整。
|
||||
- [x] 浏览器交互及分页回归、基础构建、本地 Docker 部署与文档同步;见[验收记录](reports/console-refresh.md)。
|
||||
|
||||
## 2026-09-29:滚动稳定性与登录保护核对
|
||||
|
||||
- [x] 控制台独立内容滚动,固定导航与页头,保留移动端导航和表格横向滚动。
|
||||
- [x] 应用详情相同快照不重建,后台刷新在滚动/编辑/展开详情时让位,检查请求返回时的交互状态。
|
||||
- [x] 核对当时登录实现的失败次数封控和登录限流缺口;后续已在下方专项补齐。
|
||||
- [x] 浏览器滚动、刷新和切页回归,基础检查及持久 Docker 部署;验收见[管理端改版记录](reports/console-refresh.md)。
|
||||
|
||||
## 2026-09-30:滚动边界补修与登录保护
|
||||
|
||||
- [x] 关闭根页面、侧栏及各内容滚动区的边界回弹;验证连续滚动与短屏导航。
|
||||
- [x] MySQL 持久化失败窗口、临时锁定及账号/IP 限流,配置校验、可信代理识别。
|
||||
- [x] 登录成功/失败/拒绝审计,单管理员查询、保留期及有界清理,登录页等待提示。
|
||||
- [x] 真实 MySQL 并发/跨实例/恢复/审计失败回归,基础检查、浏览器验证、Docker 部署与文档同步。
|
||||
|
||||
本地部署修订 `f56f233704700d43c6733b7f0fa14d67e1feddf7`,Schema 35,无外键。基础检查、真实 MySQL、双节点登录与控制台端到端通过;广泛集成在 Docker 暂时无响应处中断,原参数补跑剩余链路全部通过。证据与验证边界见[验收记录](reports/console-refresh.md),默认策略和部署配置见[登录保护](console-login-security.md)。
|
||||
|
||||
## 2026-09-30:浏览器页签图标
|
||||
|
||||
- [x] 复用现有 Orbit 品牌图形,增加 SVG 和 16/32/48 像素 ICO 图标,登录页与管理端共用。
|
||||
- [x] `make test vet build docs-check` 通过;本地部署修订 `d47d0d361d7edbcadf5728013cebcf1a2b908cc7`。未登录可请求两种图标,HTTP 200、媒体类型与文件内容正确;登录页与实际控制台均引用图标,readiness 为 ready。
|
||||
|
||||
65
docs/console-login-security.md
Normal file
65
docs/console-login-security.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 控制台登录保护
|
||||
|
||||
从 main 的数据库迁移 `000035` 开始启用。仅保护管理端账号密码登录,不改变业务 SDK Token、Worker 或结果订阅认证。保持单管理员,不引入角色管理系统。
|
||||
|
||||
## 默认行为
|
||||
|
||||
- 15 分钟失败窗口内连续失败 5 次,临时锁定 15 分钟。成功登录清空失败计数;失败窗口或锁定自然到期后重新计数。锁定期间尝试正确密码也不能登录,继续请求不会延长锁定。
|
||||
- 同一账号每分钟最多受理 10 次,同一来源 IP 每分钟最多 30 次。固定窗口从首次尝试开始;成功登录不清空限流计数。任意一项触发就拒绝。
|
||||
- 账号不存在、停用、密码错误或绑定凭据失效,正常拒绝均为 401。未知账号也参与相同限流/锁定,使用相同成本的 bcrypt 校验。既有账号按规范用户 ID 计数,避免等价 Unicode 登录名绕过限制。
|
||||
- 触发锁定或限流返回 429,响应包含 `Retry-After` 秒数。登录页显示原因和倒计时;刷新页面不能绕过服务端限制。已登录会话不受一次新登录锁定影响,不中断业务服务。
|
||||
- 数据库或审计提交失败返回 503,不签发 Cookie。跨站登录请求为 403;非法/过大的 JSON 为 400。
|
||||
|
||||
所有保护状态存放 MySQL,多节点共用,重启不归零。昂贵的密码校验在事务外执行:先用短事务占用请求预算,校验结束后再次锁定账号保护行;失败计数、锁定、成功会话和最终审计在同一事务提交。并发结果不能越过已经生效的账号锁定。校验进程异常退出的审计保留 PENDING,超过 10 分钟由清理器标 ERROR;不将未知结果当作密码错误。
|
||||
|
||||
## 部署配置
|
||||
|
||||
所有 Orbit 节点必须使用一致配置;不允许用 0 关闭保护。修改部署配置并重启服务后生效,不重置已有计数或已记录的锁定截止。降低阈值后按新阈值检查。
|
||||
|
||||
| 环境变量 | 默认值 | 约束 / 含义 |
|
||||
| --- | --- | --- |
|
||||
| `ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT` | `5` | 1–100 次,触发临时锁定 |
|
||||
| `ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW` | `15m` | 1s–24h,连续失败窗口 |
|
||||
| `ORBIT_CONSOLE_LOGIN_LOCK_DURATION` | `15m` | 1s–24h,新触发锁定时长 |
|
||||
| `ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT` | `10` | 1–1000 次 / 账号 / 限流窗口 |
|
||||
| `ORBIT_CONSOLE_LOGIN_IP_LIMIT` | `30` | 1–10000 次 / IP / 限流窗口 |
|
||||
| `ORBIT_CONSOLE_LOGIN_RATE_WINDOW` | `1m` | 1s–24h,固定限流窗口 |
|
||||
| `ORBIT_CONSOLE_LOGIN_AUDIT_DAYS` | `90` | 1–3650 天,登录审计独立保留期 |
|
||||
| `ORBIT_CONSOLE_TRUSTED_PROXIES` | 空 | 明确信任的代理 CIDR,逗号分隔 |
|
||||
|
||||
普通 Compose 和 HA Compose 均透传这些变量。本地测试环境在 `.local/compose-test/compose.env` 修改所需配置,再执行 `python3 scripts/orbit-local.py up`。此文件含其他私密配置,不提交仓库;不能仅在启动脚本外 export,脚本会隔离外部项目环境变量。
|
||||
|
||||
默认只使用 TCP 对端 IP,忽略客户端自带的 X-Forwarded-For、X-Real-IP。部署域名反向代理时,将实际代理的地址或精确网段填入 `ORBIT_CONSOLE_TRUSTED_PROXIES`,例如同机代理 `127.0.0.1/32,::1/128`。只在直连对端可信时解析 X-Forwarded-For,从右向左剥离可信代理;遇到第一个非可信地址停止。格式非法或跳数过多时退回 TCP 对端。代理应覆盖或按可信链追加转发头,不能把所有互联网地址都配置为受信代理。未配置时所有经代理的请求共用代理 IP 配额,账号维度仍独立生效。
|
||||
|
||||
## 查看审计
|
||||
|
||||
管理端右上角“登录审计”,仅部署指定的 `ORBIT_CONSOLE_APPLICATION_ADMIN` 可查看,默认每页 10 条。显示时间、登录名、来源 IP、结果和可重试时间;记录用户名不代表该账号一定存在。SDK Bearer、未登录或普通控制台账号无权读取全局记录。
|
||||
|
||||
接口:`GET /console/auth/audit?before=<上页next>`,认证为有效 HttpOnly 管理员会话 Cookie。响应:
|
||||
|
||||
```json
|
||||
{"data":{"items":[{"id":"42","username":"operator","remote_ip":"192.0.2.1","user_agent":"browser","outcome":"LOCKED","created_at":"2026-09-29T12:00:00Z","finished_at":"2026-09-29T12:00:00Z","retry_at":"2026-09-29T12:15:00Z"}],"next":"42"}}
|
||||
```
|
||||
|
||||
`next` 仅在还有下一页时提供,是倒序分页的排他上界;无全库计数、不承诺跨页快照。结果类型:SUCCESS、INVALID、LOCKED、ACCOUNT_LIMIT、IP_LIMIT、PENDING、ERROR。审计从不保存密码、Cookie、Token 或这些秘密的摘要,浏览器列表不会自动拉取接入 Token。
|
||||
|
||||
登录拒绝示例:
|
||||
|
||||
```http
|
||||
HTTP/1.1 429 Too Many Requests
|
||||
Retry-After: 900
|
||||
Cache-Control: no-store
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
限流代码为 `LOGIN_RATE_LIMITED`。不要让客户端自动循环重试账号密码登录。
|
||||
|
||||
## 保留与升级
|
||||
|
||||
迁移只新增 `orbit_console_login_guards` 与 `orbit_console_login_audit`,无外键,不更改已有用户、密码、会话、凭据及业务记录。先备份,停止旧节点,再前向迁移和统一部署;旧版没有保护,不能和新版混跑。
|
||||
|
||||
每分钟清理一次,每种语句最多处理 500 行:48 小时未更新的保护状态、超过配置天数的登录审计,以及超时 PENDING 的状态归类。有效保护窗口最长 24 小时,48 小时非活动清理不会提前解除有效锁定。清理有索引、有截止时间,失败保留记录并写错误日志;这不会缩小数据库已分配磁盘,也不替代备份。业务“保留策略”管理的是命名空间历史,登录审计的全局保留时间由上述独立配置管理。
|
||||
|
||||
锁定自然到期后使用正确密码登录即可;不自动改密码、撤销业务 Token 或踢出已有会话。若持续遭受攻击,管理员应按审计中的来源限制访问并核对反向代理配置,不能通过关闭计数绕过保护。
|
||||
锁定自然到期后使用正确密码登录即可;不自动改密码、撤销业务 Token 或踢出已有会话。若持续遭受攻击,管理员应按审计中的来源限制访问并核对反向代理配置,不能通过关闭计数绕过保护。
|
||||
@@ -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` 权限时可暂停或恢复计划。
|
||||
@@ -20,17 +20,18 @@ http://127.0.0.1:8080/console/
|
||||
- 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。
|
||||
- `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。
|
||||
- 在具备 `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` 时一起创建控制台管理员。这样业务资源、业务凭据和管理员账号在同一个初始化事务中提交:
|
||||
@@ -74,6 +75,8 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx'
|
||||
|
||||
## 安全边界
|
||||
|
||||
登录已具备持久化失败计数、临时锁定、账号/IP 限流和单管理员登录审计。默认 15 分钟内连续失败 5 次锁定 15 分钟,账号/IP 每分钟分别最多尝试 10/30 次;阈值、窗口、审计保留期和可信代理均可配置。页面显示 429 原因及倒计时,服务重启不清空保护状态。配置与接口见[登录保护](console-login-security.md)。
|
||||
|
||||
控制台通过服务端授权访问数据库。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。普通账号不自动获得跨命名空间权限;部署指定的单管理员可显式选择应用范围,每个业务事务重新核验原会话及目标应用。各应用分别展示,SDK 权限不变。
|
||||
|
||||
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。
|
||||
@@ -84,7 +87,7 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx'
|
||||
|
||||
## 按应用查看数据
|
||||
|
||||
页顶「应用 / 环境」选择已创建的应用,再选择队列。任务、定时计划和结果订阅按队列读取;流程、保留策略和归档按应用环境所属命名空间读取,页面有范围提示。应用每页 50 项、队列每页 100 项,选择「加载更多…」获取下一页。没有队列的应用仍能查看流程和归档。
|
||||
页顶「应用 / 环境」选择已创建的应用,再选择队列。两处下拉菜单支持搜索已加载的选项,应用名称和环境分行展示;支持方向键、Enter 选择及 Escape 关闭。任务、定时计划和结果订阅按队列读取;流程、保留策略和归档按应用环境所属命名空间读取,页面有范围提示。应用每页 50 项、队列每页 100 项,选择「加载更多…」获取下一页。没有队列的应用仍能查看流程和归档。
|
||||
|
||||
切换采用完整导航,隔离旧请求和详情;有未保存策略、未确认操作或打开的详情时需先处理完。URL 只保存非敏感 ID。无效范围显示错误,不降级读取默认应用。总览仍是最近查询窗口的统计,不是全库累计。
|
||||
|
||||
@@ -95,3 +98,15 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx'
|
||||
应用接入页按命名空间记忆所选队列;多队列且没有记忆时要求明确选择,不根据名称猜测业务用途。下拉框显示暂停/退役状态,已退役队列不能导出成新接入配置;凭据无该队列权限时直接提示,不自动换目标。
|
||||
|
||||
结果订阅详情和应用接入页分别提供订阅、队列退役;每次提交需要原因和当前版本,网络不明确时保留原键重报。活动订阅名额包含暂停订阅,退役后释放,历史仍可查询;阻塞原因明确显示。未补存当前 Token 时,预览入口与过渡提示都要求先补存,避免误认为已有可复制的新配置。
|
||||
|
||||
## 查询与日常操作
|
||||
|
||||
1. 在页顶选择应用环境;任务、计划、订阅再选择队列。
|
||||
2. 任务与流程可组合状态、完整业务键和创建时间查询。业务键精确匹配,保留大小写、特殊字符和尾部空格;时间输入按浏览器时区换算,开始包含、结束不包含。
|
||||
3. 四个资源列表默认每页 10 条,可选择 10 / 20 / 50 / 100 条、下一页、上一页和回到最新。请求服务端游标,不在前端截取全部数据。自动刷新和手动「刷新数据」保留当前条件及页码;改变条件或页大小回到第一页。分页期间数据可能继续变化,不承诺固定快照。
|
||||
4. 在页面右上输入完整 32 位资源 ID 可直接定位;详情可复制完整 ID。读取失败保留筛选并提示原因,迟到的旧查询不会覆盖新查询。详情打开后焦点留在抽屉内,Escape 关闭后回到原入口。
|
||||
5. 应用接入页可搜索本页应用,先选择接入队列,再查看或复制完整配置。应用标识、权限、有效期调整、退役等低频信息折叠展示;查看与换证沿用原有身份、权限和审计规则。
|
||||
|
||||
全站采用浅色工作台和统一表格。窄屏导航可横向滑动,宽表格在自身容器中滚动;登录页与控制台使用相同视觉样式。
|
||||
|
||||
前端校验运行 `make console-check`(开发机需要 Node.js,运行服务无需 Node.js);真实 MySQL、登录保护、任务执行与控制台接口链路运行 `make console-e2e`。本批范围与浏览器验收见[管理端改版记录](reports/console-refresh.md)。
|
||||
|
||||
88
docs/reports/console-refresh.md
Normal file
88
docs/reports/console-refresh.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 管理端简化与操作体验验收
|
||||
|
||||
日期: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` 中的构建修订为准。
|
||||
|
||||
## 本地部署复核
|
||||
|
||||
`scripts/orbit-local.py up` 已完成持久 Compose 环境更新。`http://127.0.0.1:18080/version` 返回构建修订 `92a40f4a37ece6c30396fcdf95fe2e73076dc5b5`,`/health/ready` 返回 `ready`。原管理员登录成功,从未登录任务链接进入时,登录后返回原任务页;正式本地入口的任务读取、应用搜索和总览通过,浏览器无 JavaScript 错误。
|
||||
|
||||
SDK、数据库 Schema 34、原应用与凭据保持。部署未重新 bootstrap;前端资源编译进原 Orbit 镜像,无额外前端服务。
|
||||
|
||||
## 第二轮:筛选与布局细化
|
||||
|
||||
- 应用/队列菜单支持已加载选项搜索、环境副标题、选中标记和无匹配提示;状态、页大小沿用同一菜单。原 select 继续承担表单值和 change 事件,无新依赖。
|
||||
- 默认页大小从 20 调整为 10,另提供 20/50/100;首页最近 20 条快照与详情历史口径保持。
|
||||
- 创建时间按需整行展开;长处理器名称省略显示并可悬停查看全文;主工作区限制大屏宽度,登录品牌内容在左半屏居中。
|
||||
- 浏览器实测 25 条记录按 10/10/5 三页读取;切换 20 条回到第一页,查询保留特殊字符与尾部空格。
|
||||
- 验证鼠标搜索并切换应用/队列、方向键/Home/End/Enter/Escape/Tab、无匹配提示、重置恢复原始状态、错误时间范围提示、未保存策略阻止范围切换且菜单恢复原值。
|
||||
- 八个页面可正常打开;1440×1000、2048×1195 和 390×844 检查大屏及移动布局;手机菜单保持在视口内。
|
||||
- 本轮基础 test/vet/build、控制台端到端和 console-check/docs-check 通过。部署复核另附下方。
|
||||
|
||||
第二轮已部署至持久 Compose 环境:`/version` 修订为 `df76dcb9c0b69b3c947d112de619737145c44015`,就绪检查 HTTP 200。实际 18080 入口应用菜单、10 条列表、退出和原管理员登录通过;原应用身份与数据库保留。本次不涉及 SDK 发版。
|
||||
|
||||
## 第三轮:滚动稳定性
|
||||
|
||||
原布局让整个 document 滚动,页头会被带离视口;应用详情每次轮询都重建 DOM。改为固定视口的工作台、独立内容滚动、固定页头和桌面应用目录;后台请求在返回时再次检查滚动/编辑状态,相同应用快照保留原有 DOM。切换视图重置内容滚动区域,菜单仍按当前控件坐标定位。
|
||||
|
||||
浏览器验证:1440×900 与 390×844 下,document 不滚动,页头和导航坐标固定;鼠标点击页头刷新前后内容 scrollTop 均为 293、scrollHeight 均为 1193;展开状态不因相同快照刷新被折叠;手机切页归零、详情打开/关闭和菜单定位通过,无横向页面溢出或 JS 错误。基础 test/vet/build、console-check/docs-check 通过。
|
||||
|
||||
第三轮当时的登录保护核对:`ConsoleAuthStore.Login` 与 HTTP 登录路由没有失败计数、临时锁定和登录限流;当时仅记录缺口。下方 2026-09-30 专项已补齐这些能力。
|
||||
|
||||
第三轮持久部署已核验:`/version` 修订 `23db3d8fe0de352be9e756f932b12559ea746ff3`,`/health/ready` 为 200 ready。实际 18080 应用页内容 scrollTop=293 时,document scrollY=0、导航和页头 top=0。服务原账号、SDK、Token 和数据库保持不变。
|
||||
|
||||
## 2026-09-30:边界回弹与登录保护
|
||||
|
||||
上一轮键盘翻页和静态位置检查没有覆盖触控板滚到边界的回弹。原 `overscroll-behavior: contain` 只阻止滚动传递,仍允许元素回弹([MDN 属性说明](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overscroll-behavior#values))。本轮在控制台根节点、侧栏、内容、应用列表及弹层的实际滚动容器设置 `none`,并固定根工作区;保留内容正常滚动和短屏菜单可达性。
|
||||
|
||||
浏览器预览验证:
|
||||
|
||||
- 1440×900,用滚动操作连续冲顶部、底部并反向:根 scrollY=0,侧栏 y=0、品牌 y=28、页头 y=0;内容 scrollTop 在 0–293 内移动。
|
||||
- 1100×600,菜单在 0–198 内滚动,内容在 0–593 内独立滚动,另一侧不被拖动,页头 y=0。
|
||||
- 390×844,顶部导航固定,内容滚动至 1112;根 scrollY=0、文档宽度 390,无根横向溢出。工具滚动不等同于所有设备的物理触控板惯性手势,以上为实际验证范围。
|
||||
|
||||
登录保护实现、配置和 API 见[控制台登录保护](../console-login-security.md)。已有真实 MySQL 回归覆盖失败/成功计数重置、两个存储实例与重建实例共用锁定、锁定不续期与自然到期、并发 6 次失败精确计数、账号/IP 配额、未知账号、失败窗口、审计提交失败回滚会话、管理员权限/分页、日志保留和零外键。独立 Docker 双服务实测还覆盖轮流失败、重启服务仍拒绝、已登录会话仍有效、伪造代理头无效、自然到期后登录成功。
|
||||
|
||||
端到端首轮旧管理查询曾超时;复跑该链路通过,未放宽断言。双节点测试首次重启后因 Docker 重新分配随机发布端口无法就绪,测试工具改为重读实际端口后通过,未修改锁定逻辑或删除状态。最终 `make console-e2e` 全链路通过,含任务执行、重试、定时计划、归档及退出登录。
|
||||
|
||||
本地持久 Compose 部署版本 `f56f233704700d43c6733b7f0fa14d67e1feddf7`,数据库 Schema 35、0 外键,readiness 为 ready。已有账号、密码、会话、凭据与业务记录未重新初始化。实际 18080 浏览器验证:独立的未知测试登录名在第 5 次错误后显示 15 分钟倒计时;真实管理员正常登录,回到原应用页,审计显示失败、锁定与成功。正式页面滚动至 293 时,根 y=0、侧栏 y=0、品牌 y=28、页头 y=0,无浏览器 JS 错误。
|
||||
|
||||
截图存放在本地忽略目录 `.local/console-ui-check/`:`login-locked.png`、`login-audit.png`、`scroll-boundary-fixed.png`,不含密码/Token 原文。
|
||||
|
||||
基础 `make test vet build console-check docs-check` 通过;真实 MySQL 存储全套通过,包含新保护专项 6 个子场景;独立 `make console-e2e` 通过。广泛 `make integration` 执行至 SDK 调度容量样本时,Docker 引擎读操作及旧服务健康请求均超时,样本未完成,因此这一次命令不记为通过。引擎自行恢复,部署完成,未重启 Docker 或其他项目。中断位置起使用相同脚本命令及参数在新的一次性数据库补跑。
|
||||
|
||||
尾段补跑完成:调度样本 6/6 触发并成功,流程样本 6/6 成功;其余 SDK、可运行示例、事务 Outbox/结果 Inbox、嵌入 HTTP 服务生命周期及真实进程恢复回归全部通过。覆盖租约恢复、ACK 丢响应、慢消费者、跨节点重连、优先级/并发控制、重试与截止恢复、定时计划恢复和流程暂停恢复。日志保留于 `.local/console-ui-check/security-integration-tail.log`;首段与该次补跑共同覆盖原集成脚本范围,不描述为单次全量命令通过。
|
||||
@@ -10,17 +10,19 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/retention"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ConsoleApplicationAdmin string // ConsoleApplicationAdmin 可跨应用管理接入的唯一控制台登录名;空时禁用。
|
||||
CredentialKeyFile string // CredentialKeyFile 管理员 Token 保管密钥文件路径;所有服务节点共用同一密钥环。
|
||||
ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。
|
||||
ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。
|
||||
ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。
|
||||
ArchiveTickInterval time.Duration // ArchiveTickInterval 后台扫描间隔,默认10秒。
|
||||
RetentionDefaults retention.Policy // RetentionDefaults 未保存数据库策略时使用的部署默认值。
|
||||
ConsoleLogin consoleauth.LoginPolicy // ConsoleLogin 登录保护策略,所有服务节点使用一致配置。
|
||||
ConsoleApplicationAdmin string // ConsoleApplicationAdmin 可跨应用管理接入的唯一控制台登录名;空时禁用。
|
||||
CredentialKeyFile string // CredentialKeyFile 管理员 Token 保管密钥文件路径;所有服务节点共用同一密钥环。
|
||||
ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。
|
||||
ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。
|
||||
ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。
|
||||
ArchiveTickInterval time.Duration // ArchiveTickInterval 后台扫描间隔,默认10秒。
|
||||
RetentionDefaults retention.Policy // RetentionDefaults 未保存数据库策略时使用的部署默认值。
|
||||
HTTPAddr string
|
||||
MySQLDSN string
|
||||
MaxOpen int
|
||||
@@ -39,7 +41,12 @@ type Config struct {
|
||||
|
||||
func Load() (Config, error) { return FromEnv(os.Getenv) }
|
||||
func FromEnv(get func(string) string) (Config, error) {
|
||||
loginPolicy, err := loginPolicyFromEnv(get)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c := Config{
|
||||
ConsoleLogin: loginPolicy,
|
||||
ConsoleApplicationAdmin: get("ORBIT_CONSOLE_APPLICATION_ADMIN"),
|
||||
CredentialKeyFile: get("ORBIT_CREDENTIAL_KEY_FILE"),
|
||||
ArchiveRoot: get("ORBIT_ARCHIVE_ROOT"), ArchiveStorageID: core.ID(get("ORBIT_ARCHIVE_STORAGE_ID")), ArchiveMaxBytes: 10 << 30, ArchiveTickInterval: 10 * time.Second,
|
||||
@@ -154,7 +161,6 @@ func FromEnv(get func(string) string) (Config, error) {
|
||||
}
|
||||
c.ArchiveTickInterval = d
|
||||
}
|
||||
var err error
|
||||
c.RetentionDefaults, err = retention.FromEnv(get)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
|
||||
57
internal/config/console_login.go
Normal file
57
internal/config/console_login.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func loginPolicyFromEnv(get func(string) string) (consoleauth.LoginPolicy, error) {
|
||||
p := consoleauth.DefaultLoginPolicy()
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
dst *int
|
||||
}{
|
||||
{"ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT", &p.FailureLimit},
|
||||
{"ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT", &p.AccountLimit},
|
||||
{"ORBIT_CONSOLE_LOGIN_IP_LIMIT", &p.IPLimit},
|
||||
{"ORBIT_CONSOLE_LOGIN_AUDIT_DAYS", &p.AuditDays},
|
||||
} {
|
||||
if raw := get(f.name); raw != "" {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("invalid %s", f.name)
|
||||
}
|
||||
*f.dst = n
|
||||
}
|
||||
}
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
dst *time.Duration
|
||||
}{
|
||||
{"ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW", &p.FailureWindow},
|
||||
{"ORBIT_CONSOLE_LOGIN_LOCK_DURATION", &p.LockDuration},
|
||||
{"ORBIT_CONSOLE_LOGIN_RATE_WINDOW", &p.RateWindow},
|
||||
} {
|
||||
if raw := get(f.name); raw != "" {
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("invalid %s", f.name)
|
||||
}
|
||||
*f.dst = d
|
||||
}
|
||||
}
|
||||
if raw := get("ORBIT_CONSOLE_TRUSTED_PROXIES"); raw != "" {
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(item))
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("invalid ORBIT_CONSOLE_TRUSTED_PROXIES CIDR")
|
||||
}
|
||||
p.TrustedProxies = append(p.TrustedProxies, prefix.Masked())
|
||||
}
|
||||
}
|
||||
return p, p.Validate()
|
||||
}
|
||||
30
internal/config/console_login_test.go
Normal file
30
internal/config/console_login_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConsoleLoginConfiguration(t *testing.T) {
|
||||
p, err := loginPolicyFromEnv(func(string) string { return "" })
|
||||
if err != nil || p.FailureLimit != 5 || p.LockDuration != 15*time.Minute || p.AuditDays != 90 {
|
||||
t.Fatalf("defaults: %+v %v", p, err)
|
||||
}
|
||||
env := map[string]string{"ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT": "3", "ORBIT_CONSOLE_LOGIN_LOCK_DURATION": "20m", "ORBIT_CONSOLE_TRUSTED_PROXIES": "127.0.0.1/32, ::1/128"}
|
||||
p, err = loginPolicyFromEnv(func(k string) string { return env[k] })
|
||||
if err != nil || p.FailureLimit != 3 || len(p.TrustedProxies) != 2 {
|
||||
t.Fatalf("configured: %+v %v", p, err)
|
||||
}
|
||||
for k, v := range map[string]string{"ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT": "0", "ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT": "-1", "ORBIT_CONSOLE_LOGIN_IP_LIMIT": "10001", "ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW": "0s", "ORBIT_CONSOLE_LOGIN_LOCK_DURATION": "25h", "ORBIT_CONSOLE_LOGIN_RATE_WINDOW": "bad", "ORBIT_CONSOLE_LOGIN_AUDIT_DAYS": "0", "ORBIT_CONSOLE_TRUSTED_PROXIES": "all"} {
|
||||
t.Run(k, func(t *testing.T) {
|
||||
if _, err := loginPolicyFromEnv(func(name string) string {
|
||||
if name == k {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}); err == nil {
|
||||
t.Fatal("invalid policy accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
73
internal/consoleauth/login_policy.go
Normal file
73
internal/consoleauth/login_policy.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package consoleauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoginPolicy 是所有节点统一配置的登录保护策略;计数及锁定保存在数据库。
|
||||
type LoginPolicy struct {
|
||||
FailureLimit int // FailureLimit 失败窗口内允许的连续失败次数,达到后锁定。
|
||||
FailureWindow time.Duration // FailureWindow 连续失败的统计窗口。
|
||||
LockDuration time.Duration // LockDuration 临时锁定时长,锁定期间请求不续期。
|
||||
AccountLimit int // AccountLimit 每个限流窗口最多接受的账号登录尝试数。
|
||||
IPLimit int // IPLimit 每个限流窗口最多接受的来源 IP 登录尝试数。
|
||||
RateWindow time.Duration // RateWindow 固定限流窗口;成功登录不清空流量计数。
|
||||
AuditDays int // AuditDays 登录审计保留天数,与业务数据策略独立。
|
||||
TrustedProxies []netip.Prefix // TrustedProxies 明确信任的反向代理网段;空时忽略所有转发头。
|
||||
}
|
||||
|
||||
func DefaultLoginPolicy() LoginPolicy {
|
||||
return LoginPolicy{FailureLimit: 5, FailureWindow: 15 * time.Minute, LockDuration: 15 * time.Minute, AccountLimit: 10, IPLimit: 30, RateWindow: time.Minute, AuditDays: 90}
|
||||
}
|
||||
func (p LoginPolicy) Validate() error {
|
||||
if p.FailureLimit < 1 || p.FailureLimit > 100 || p.AccountLimit < 1 || p.AccountLimit > 1000 || p.IPLimit < 1 || p.IPLimit > 10000 {
|
||||
return errors.New("console login limits out of range")
|
||||
}
|
||||
for _, d := range []time.Duration{p.FailureWindow, p.LockDuration, p.RateWindow} {
|
||||
if d < time.Second || d > 24*time.Hour {
|
||||
return errors.New("console login durations must be 1s–24h")
|
||||
}
|
||||
}
|
||||
if p.AuditDays < 1 || p.AuditDays > 3650 {
|
||||
return errors.New("console login audit retention must be 1–3650 days")
|
||||
}
|
||||
for _, n := range p.TrustedProxies {
|
||||
if !n.IsValid() {
|
||||
return errors.New("invalid console trusted proxy CIDR")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginBlocked 对外不区分账号是否存在,只给出拒绝类型和最短等待时间。
|
||||
type LoginBlocked struct {
|
||||
Locked bool // Locked 表示临时锁定,否则为请求限流。
|
||||
RetryAfter time.Duration // RetryAfter 按数据库时钟计算的剩余等待时间。
|
||||
}
|
||||
|
||||
func (*LoginBlocked) Error() string { return "console login temporarily blocked" }
|
||||
|
||||
// LoginAuditEntry 是脱敏的登录审计;从不记录密码、Cookie、Token 或它们的摘要。
|
||||
type LoginAuditEntry struct {
|
||||
ID uint64 `json:"id,string"` // ID 审计游标,按插入序号倒序分页。
|
||||
Username string `json:"username"` // Username 提交的有界登录名,不证明账号存在。
|
||||
RemoteIP string `json:"remote_ip"` // RemoteIP 经可信代理规则解析的来源地址。
|
||||
UserAgent string `json:"user_agent"` // UserAgent 截断为最多 512 字节的浏览器信息。
|
||||
Outcome string `json:"outcome"` // Outcome PENDING、SUCCESS、INVALID、LOCKED、ACCOUNT_LIMIT、IP_LIMIT 或 ERROR。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 尝试时间,UTC。
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"` // FinishedAt 完成时间;空表示尚未确认结果。
|
||||
RetryAt *time.Time `json:"retry_at,omitempty"` // RetryAt 拒绝时建议最早重试时间,UTC。
|
||||
}
|
||||
|
||||
// LoginAuditPage 固定每页 10 条,Next 是下一页排他上界;不承诺跨页快照。
|
||||
type LoginAuditPage struct {
|
||||
Items []LoginAuditEntry `json:"items"` // Items 当前页脱敏记录。
|
||||
Next string `json:"next,omitempty"` // Next 下一页游标,无更多时为空。
|
||||
}
|
||||
type LoginAuditor interface {
|
||||
ListLoginAudit(context.Context, auth.Token, string) (LoginAuditPage, error)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
||||
f.exec(`ALTER TABLE pulse_worker_sessions DROP COLUMN concurrency_keys`)
|
||||
// 新增的控制台和保留策略迁移也要还原到历史 v9,避免升级测试把
|
||||
// 已经存在的 v29/v30 表误当成迁移重复执行。
|
||||
f.exec(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
|
||||
f.exec(`DROP TABLE orbit_console_login_audit,orbit_console_login_guards,orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
|
||||
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_overlap, DROP COLUMN previous_secret_digest, DROP COLUMN previous_valid_until`)
|
||||
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_ciphertext, DROP COLUMN secret_key_id, DROP COLUMN secret_ciphertext`)
|
||||
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
|
||||
|
||||
@@ -943,3 +943,33 @@ ALTER TABLE orbit_subscriptions
|
||||
ADD retired_sequence BIGINT UNSIGNED NULL COMMENT '退役事务锁定的结果流提交水位;NULL表示未退役,之后的事件不再订阅',
|
||||
ADD KEY ix_subscription_active(namespace_id,retired_at),
|
||||
ADD CONSTRAINT ck_subscription_retired CHECK ((retired_at IS NULL AND retired_sequence IS NULL) OR (retired_at IS NOT NULL AND retired_sequence IS NOT NULL AND enabled=FALSE AND pending_count=0));
|
||||
|
||||
-- 登录保护独立于业务凭据和命名空间;没有外键,不改变已有密码、会话或 Token。
|
||||
CREATE TABLE orbit_console_login_guards (
|
||||
kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或 ACCOUNT,固定先 IP 后账号加锁',
|
||||
subject CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或规范账号身份的 SHA-256 十六进制键,不包含密码或 Token',
|
||||
window_start DATETIME(6) NOT NULL COMMENT '固定请求限流窗口起点,UTC',
|
||||
requests INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前窗口内已受理的尝试数,成功不归零',
|
||||
failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败窗口内的失败数,成功后归零',
|
||||
first_failed_at DATETIME(6) NULL COMMENT '失败窗口起点;空表示无连续失败',
|
||||
locked_until DATETIME(6) NULL COMMENT '临时锁定截止;锁内请求不延长此时间',
|
||||
expires_at DATETIME(6) NOT NULL COMMENT '非活动状态清理截止,晚于所有保护窗口',
|
||||
PRIMARY KEY(kind,subject),
|
||||
KEY ix_console_guard_expiry(expires_at),
|
||||
CONSTRAINT ck_console_guard_kind CHECK(kind IN ('IP','ACCOUNT'))
|
||||
) ENGINE=InnoDB COMMENT='跨节点持久登录限流及临时锁定状态';
|
||||
|
||||
CREATE TABLE orbit_console_login_audit (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '审计序号,仅用于有界倒序分页',
|
||||
username VARCHAR(128) NOT NULL COMMENT '请求中的登录名,不证明账号存在',
|
||||
remote_ip VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '实际连接或受信代理解析出的规范 IP',
|
||||
user_agent VARCHAR(512) NOT NULL COMMENT '有界浏览器标识,不保存请求正文或认证头',
|
||||
outcome VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'PENDING/SUCCESS/INVALID/LOCKED/ACCOUNT_LIMIT/IP_LIMIT/ERROR',
|
||||
created_at DATETIME(6) NOT NULL COMMENT '开始时间,UTC',
|
||||
finished_at DATETIME(6) NULL COMMENT '结果确认时间,空为未完成;异常中断由清理器标 ERROR',
|
||||
retry_at DATETIME(6) NULL COMMENT '被锁定或限流时的最早重试时间;否则为空',
|
||||
PRIMARY KEY(id),
|
||||
KEY ix_console_login_audit_time(created_at),
|
||||
KEY ix_console_login_audit_pending(outcome,created_at),
|
||||
CONSTRAINT ck_console_login_outcome CHECK(outcome IN ('PENDING','SUCCESS','INVALID','LOCKED','ACCOUNT_LIMIT','IP_LIMIT','ERROR'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='全局登录审计,不含密码、Cookie 或 Token,仅管理员可查询';
|
||||
|
||||
@@ -25,10 +25,20 @@ const (
|
||||
// ConsoleAuthStore 管理控制台的人类账号和 HttpOnly 会话。
|
||||
// 账号绑定一个已有的 Orbit 业务凭据,登录后沿用该凭据的权限范围;
|
||||
// 因此控制台登录不会改变 SDK 令牌,也不会绕过原有的 API 授权。
|
||||
type ConsoleAuthStore struct{ db *sql.DB }
|
||||
type ConsoleAuthStore struct {
|
||||
db *sql.DB
|
||||
policy consoleauth.LoginPolicy
|
||||
}
|
||||
|
||||
// NewConsoleAuthStore 创建控制台认证存储。
|
||||
func NewConsoleAuthStore(db *sql.DB) *ConsoleAuthStore { return &ConsoleAuthStore{db: db} }
|
||||
func NewConsoleAuthStore(db *sql.DB, policies ...consoleauth.LoginPolicy) *ConsoleAuthStore {
|
||||
policy := consoleauth.DefaultLoginPolicy()
|
||||
if len(policies) > 0 {
|
||||
policy = policies[0]
|
||||
}
|
||||
return &ConsoleAuthStore{db: db, policy: policy}
|
||||
}
|
||||
func (s *ConsoleAuthStore) LoginPolicy() consoleauth.LoginPolicy { return s.policy }
|
||||
|
||||
var _ consoleauth.Store = (*ConsoleAuthStore)(nil)
|
||||
|
||||
@@ -108,59 +118,6 @@ func (s *ConsoleAuthStore) CreateConsoleUser(ctx context.Context, in ConsoleUser
|
||||
return ConsoleUser{ID: string(id), Username: in.Username, DisplayName: name}, nil
|
||||
}
|
||||
|
||||
// Login 校验账号密码并建立一个独立会话。失败时统一返回未认证,避免泄露账号存在性。
|
||||
func (s *ConsoleAuthStore) Login(ctx context.Context, username, password, userAgent, remoteAddr string) (ConsoleLogin, error) {
|
||||
if s == nil || s.db == nil || username == "" || password == "" || len(password) > 1024 {
|
||||
return ConsoleLogin{}, fault.Unauthorized
|
||||
}
|
||||
var user ConsoleUser
|
||||
var userID core.ID
|
||||
var namespace, credential core.ID
|
||||
var hash string
|
||||
var enabled bool
|
||||
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.display_name,u.password_hash,u.enabled,u.namespace_id,u.credential_id
|
||||
FROM orbit_console_users u
|
||||
JOIN orbit_credentials c ON c.namespace_id=u.namespace_id AND c.id=u.credential_id
|
||||
JOIN orbit_namespaces n ON n.id=u.namespace_id
|
||||
JOIN orbit_applications a ON a.id=n.application_id
|
||||
WHERE 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`, username).
|
||||
Scan(&userID, &user.Username, &user.DisplayName, &hash, &enabled, &namespace, &credential)
|
||||
if err != nil || !enabled || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||
return ConsoleLogin{}, fault.Unauthorized
|
||||
}
|
||||
user.ID = string(userID)
|
||||
token, secretDigest, err := auth.Issue(namespace, credential)
|
||||
if err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
var now time.Time
|
||||
if err := s.db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now); err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
now = now.UTC()
|
||||
expires := now.Add(ConsoleSessionLifetime)
|
||||
sessionID, err := core.NewID()
|
||||
if err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
if len(userAgent) > 512 {
|
||||
userAgent = userAgent[:512]
|
||||
}
|
||||
if len(remoteAddr) > 64 {
|
||||
remoteAddr = remoteAddr[:64]
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO orbit_console_sessions
|
||||
(id,user_id,namespace_id,credential_id,token_digest,expires_at,last_seen_at,user_agent,remote_addr)
|
||||
VALUES (?,?,?,?,?,?,UTC_TIMESTAMP(6),?,?)`,
|
||||
sessionID, userID, namespace, credential, secretDigest, expires, userAgent, remoteAddr)
|
||||
if err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
return ConsoleLogin{User: user, Token: token, ExpiresAt: expires}, nil
|
||||
}
|
||||
|
||||
// ValidateSession 检查会话是否仍然有效,并更新最近使用时间。
|
||||
// API 授权还会再次读取该会话对应凭据的权限,不信任浏览器提交的身份字段。
|
||||
func (s *ConsoleAuthStore) ValidateSession(ctx context.Context, token auth.Token) error {
|
||||
|
||||
337
internal/storage/mysql/console_login.go
Normal file
337
internal/storage/mysql/console_login.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"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"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// 不存在/停用的账号仍走相同成本校验,限流先于昂贵的密码计算。
|
||||
var consoleDummyHash = func() []byte {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("orbit-dummy-login-password"), consolePasswordCost)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hash
|
||||
}()
|
||||
|
||||
// loginGuard 对应持久限流行;所有字段的时间均由 MySQL UTC 时钟决定。
|
||||
type loginGuard struct {
|
||||
Kind string // Kind 为 IP 或 ACCOUNT,跨维度固定先 IP 后账号加锁。
|
||||
Subject string // Subject 为规范身份的 SHA-256,不含秘密。
|
||||
WindowStart time.Time // WindowStart 请求窗口起点。
|
||||
Requests int // Requests 已受理请求数,拒绝时不增加以免溢出。
|
||||
Failures int // Failures 连续失败计数。
|
||||
FirstFailed sql.NullTime // FirstFailed 失败窗口起点,空表示无失败。
|
||||
LockedUntil sql.NullTime // LockedUntil 锁定截止,空表示未锁定。
|
||||
}
|
||||
|
||||
func loginSubject(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
func loginText(s string, max int) string {
|
||||
s = strings.ToValidUTF8(s, "")
|
||||
if len(s) > max {
|
||||
s = s[:max]
|
||||
for !utf8.ValidString(s) {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
func loginNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {
|
||||
var now time.Time
|
||||
err := tx.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now)
|
||||
return now.UTC(), err
|
||||
}
|
||||
func loadLoginGuard(ctx context.Context, tx *sql.Tx, kind, key string, now time.Time) (loginGuard, error) {
|
||||
g := loginGuard{Kind: kind, Subject: key}
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_login_guards(kind,subject,window_start,expires_at) VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE subject=VALUES(subject)`, kind, key, now, now.Add(48*time.Hour))
|
||||
if err != nil {
|
||||
return g, err
|
||||
}
|
||||
err = tx.QueryRowContext(ctx, `SELECT window_start,requests,failures,first_failed_at,locked_until FROM orbit_console_login_guards WHERE kind=? AND subject=? FOR UPDATE`, kind, key).Scan(&g.WindowStart, &g.Requests, &g.Failures, &g.FirstFailed, &g.LockedUntil)
|
||||
return g, err
|
||||
}
|
||||
func saveLoginGuard(ctx context.Context, tx *sql.Tx, g loginGuard, now time.Time) error {
|
||||
_, err := tx.ExecContext(ctx, `UPDATE orbit_console_login_guards SET window_start=?,requests=?,failures=?,first_failed_at=?,locked_until=?,expires_at=? WHERE kind=? AND subject=?`, g.WindowStart, g.Requests, g.Failures, g.FirstFailed, g.LockedUntil, now.Add(48*time.Hour), g.Kind, g.Subject)
|
||||
return err
|
||||
}
|
||||
func (g *loginGuard) resetExpired(now time.Time, p consoleauth.LoginPolicy) {
|
||||
if !now.Before(g.WindowStart.Add(p.RateWindow)) {
|
||||
g.WindowStart = now
|
||||
g.Requests = 0
|
||||
}
|
||||
if g.LockedUntil.Valid && !now.Before(g.LockedUntil.Time) {
|
||||
g.LockedUntil = sql.NullTime{}
|
||||
g.FirstFailed = sql.NullTime{}
|
||||
g.Failures = 0
|
||||
}
|
||||
if !g.LockedUntil.Valid && g.FirstFailed.Valid && !now.Before(g.FirstFailed.Time.Add(p.FailureWindow)) {
|
||||
g.FirstFailed = sql.NullTime{}
|
||||
g.Failures = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Login 先在短事务中占用账号/IP 请求预算,再在事务外计算 bcrypt。
|
||||
// 最终事务重新锁定账号保护行,失败、锁定、成功会话及审计原子提交;并发校验不能穿过已生效的锁定。
|
||||
func (s *ConsoleAuthStore) Login(ctx context.Context, username, password, userAgent, remoteAddr string) (ConsoleLogin, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return ConsoleLogin{}, errors.New("console authentication unavailable")
|
||||
}
|
||||
if err := s.policy.Validate(); err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
if username == "" || len(username) > 128 || !utf8.ValidString(username) || password == "" || len(password) > 1024 {
|
||||
return ConsoleLogin{}, fault.Unauthorized
|
||||
}
|
||||
ip, err := netip.ParseAddr(remoteAddr)
|
||||
if err != nil {
|
||||
return ConsoleLogin{}, fault.Invalid("invalid login source address")
|
||||
}
|
||||
remoteAddr = ip.Unmap().String()
|
||||
userAgent = loginText(userAgent, 512)
|
||||
var user ConsoleUser
|
||||
var userID, namespace, credential core.ID
|
||||
var hash string
|
||||
var enabled bool
|
||||
err = s.db.QueryRowContext(ctx, `SELECT id,username,display_name,password_hash,enabled,namespace_id,credential_id FROM orbit_console_users WHERE username=?`, username).Scan(&userID, &user.Username, &user.DisplayName, &hash, &enabled, &namespace, &credential)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
exists := err == nil
|
||||
// 使用已存在账号的 ID,避免 Unicode 等价拼写绕过 MySQL 登录名匹配对应的限额。
|
||||
accountKey := loginSubject("unknown:" + username)
|
||||
if exists {
|
||||
accountKey = loginSubject("user:" + string(userID))
|
||||
}
|
||||
var attemptID int64
|
||||
var blocked *consoleauth.LoginBlocked
|
||||
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
blocked = nil
|
||||
now, err := loginNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ipGuard, err := loadLoginGuard(ctx, tx, "IP", loginSubject(remoteAddr), now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ipGuard.resetExpired(now, s.policy)
|
||||
outcome := "PENDING"
|
||||
var retryAt *time.Time
|
||||
reject := func(reason string, until time.Time, locked bool) {
|
||||
outcome = reason
|
||||
retryAt = &until
|
||||
blocked = &consoleauth.LoginBlocked{Locked: locked, RetryAfter: until.Sub(now)}
|
||||
}
|
||||
if ipGuard.Requests >= s.policy.IPLimit {
|
||||
reject("IP_LIMIT", ipGuard.WindowStart.Add(s.policy.RateWindow), false)
|
||||
} else {
|
||||
ipGuard.Requests++
|
||||
account, err := loadLoginGuard(ctx, tx, "ACCOUNT", accountKey, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account.resetExpired(now, s.policy)
|
||||
if account.LockedUntil.Valid {
|
||||
reject("LOCKED", account.LockedUntil.Time, true)
|
||||
} else if account.Requests >= s.policy.AccountLimit {
|
||||
reject("ACCOUNT_LIMIT", account.WindowStart.Add(s.policy.RateWindow), false)
|
||||
} else {
|
||||
account.Requests++
|
||||
}
|
||||
if err = saveLoginGuard(ctx, tx, account, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = saveLoginGuard(ctx, tx, ipGuard, now); err != nil {
|
||||
return err
|
||||
}
|
||||
var finished *time.Time
|
||||
if blocked != nil {
|
||||
finished = &now
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_login_audit(username,remote_ip,user_agent,outcome,created_at,finished_at,retry_at) VALUES(?,?,?,?,?,?,?)`, username, remoteAddr, userAgent, outcome, now, finished, retryAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attemptID, err = result.LastInsertId()
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
if blocked != nil {
|
||||
return ConsoleLogin{}, blocked
|
||||
}
|
||||
checkHash := consoleDummyHash
|
||||
if exists && enabled {
|
||||
checkHash = []byte(hash)
|
||||
}
|
||||
valid := bcrypt.CompareHashAndPassword(checkHash, []byte(password)) == nil && exists && enabled
|
||||
var out ConsoleLogin
|
||||
var rejected error
|
||||
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
out = ConsoleLogin{}
|
||||
rejected = nil
|
||||
now, err := loginNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guard, err := loadLoginGuard(ctx, tx, "ACCOUNT", accountKey, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guard.resetExpired(now, s.policy)
|
||||
outcome := "INVALID"
|
||||
var retryAt *time.Time
|
||||
if guard.LockedUntil.Valid {
|
||||
outcome = "LOCKED"
|
||||
until := guard.LockedUntil.Time
|
||||
retryAt = &until
|
||||
rejected = &consoleauth.LoginBlocked{Locked: true, RetryAfter: until.Sub(now)}
|
||||
} else {
|
||||
stillValid := valid
|
||||
if stillValid {
|
||||
var currentHash string
|
||||
// 密码/账号/关联权限在最终事务中复查;停用、换密和失效凭据不能签发新会话。
|
||||
err = tx.QueryRowContext(ctx, `SELECT u.password_hash FROM orbit_console_users u JOIN orbit_credentials c ON c.namespace_id=u.namespace_id AND c.id=u.credential_id JOIN orbit_namespaces n ON n.id=u.namespace_id JOIN orbit_applications a ON a.id=n.application_id WHERE u.id=? AND u.namespace_id=? AND u.credential_id=? AND u.enabled=TRUE 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`, userID, namespace, credential).Scan(¤tHash)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
stillValid = err == nil && currentHash == hash
|
||||
}
|
||||
if stillValid {
|
||||
token, digest, err := auth.Issue(namespace, credential)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sessionID, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expires := now.Add(ConsoleSessionLifetime)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_console_sessions(id,user_id,namespace_id,credential_id,token_digest,expires_at,last_seen_at,user_agent,remote_addr) VALUES(?,?,?,?,?,?,?,?,?)`, sessionID, userID, namespace, credential, digest, expires, now, userAgent, remoteAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.ID = string(userID)
|
||||
out = ConsoleLogin{User: user, Token: token, ExpiresAt: expires}
|
||||
outcome = "SUCCESS"
|
||||
guard.Failures = 0
|
||||
guard.FirstFailed = sql.NullTime{}
|
||||
guard.LockedUntil = sql.NullTime{}
|
||||
} else {
|
||||
rejected = fault.Unauthorized
|
||||
if !guard.FirstFailed.Valid {
|
||||
guard.FirstFailed = sql.NullTime{Time: now, Valid: true}
|
||||
}
|
||||
guard.Failures++
|
||||
if guard.Failures >= s.policy.FailureLimit {
|
||||
until := now.Add(s.policy.LockDuration)
|
||||
guard.LockedUntil = sql.NullTime{Time: until, Valid: true}
|
||||
outcome = "LOCKED"
|
||||
retryAt = &until
|
||||
rejected = &consoleauth.LoginBlocked{Locked: true, RetryAfter: s.policy.LockDuration}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = saveLoginGuard(ctx, tx, guard, now); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE orbit_console_login_audit SET outcome=?,finished_at=?,retry_at=? WHERE id=? AND outcome='PENDING'`, outcome, now, retryAt, attemptID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
return errors.New("login audit result not confirmed")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
return out, rejected
|
||||
}
|
||||
|
||||
// CleanupLoginSecurity 每批最多清理 500 行,过期时间有索引,不触碰业务审计或有效锁定。
|
||||
func (s *ConsoleAuthStore) CleanupLoginSecurity(ctx context.Context) error {
|
||||
if err := s.policy.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, q := range []struct {
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{`UPDATE orbit_console_login_audit SET outcome='ERROR',finished_at=UTC_TIMESTAMP(6) WHERE outcome='PENDING' AND created_at<UTC_TIMESTAMP(6)-INTERVAL 10 MINUTE ORDER BY created_at LIMIT 500`, nil},
|
||||
{`DELETE FROM orbit_console_login_guards WHERE expires_at<UTC_TIMESTAMP(6) ORDER BY expires_at LIMIT 500`, nil},
|
||||
{`DELETE FROM orbit_console_login_audit WHERE created_at<UTC_TIMESTAMP(6)-INTERVAL ? DAY ORDER BY created_at LIMIT 500`, []any{s.policy.AuditDays}},
|
||||
} {
|
||||
if _, err := s.db.ExecContext(ctx, q.sql, q.args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListLoginAudit 仅沿用部署指定的单管理员权限;普通会话及 SDK Token 不能读取全局登录记录。
|
||||
func (s *ConsoleApplicationStore) ListLoginAudit(ctx context.Context, token auth.Token, before string) (consoleauth.LoginAuditPage, error) {
|
||||
out := consoleauth.LoginAuditPage{Items: []consoleauth.LoginAuditEntry{}}
|
||||
var cursor uint64
|
||||
if before != "" {
|
||||
var err error
|
||||
cursor, err = strconv.ParseUint(before, 10, 64)
|
||||
if err != nil || cursor == 0 {
|
||||
return out, fault.Invalid("invalid login audit cursor")
|
||||
}
|
||||
}
|
||||
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||
return err
|
||||
}
|
||||
q := `SELECT id,username,remote_ip,user_agent,outcome,created_at,finished_at,retry_at FROM orbit_console_login_audit`
|
||||
args := []any{}
|
||||
if cursor != 0 {
|
||||
q += ` WHERE id<?`
|
||||
args = append(args, cursor)
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, q+` ORDER BY id DESC LIMIT 11`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
out.Items = []consoleauth.LoginAuditEntry{}
|
||||
for rows.Next() {
|
||||
var e consoleauth.LoginAuditEntry
|
||||
if err = rows.Scan(&e.ID, &e.Username, &e.RemoteIP, &e.UserAgent, &e.Outcome, &e.CreatedAt, &e.FinishedAt, &e.RetryAt); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, e)
|
||||
}
|
||||
if len(out.Items) > 10 {
|
||||
out.Items = out.Items[:10]
|
||||
out.Next = strconv.FormatUint(out.Items[9].ID, 10)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
262
internal/storage/mysql/console_login_integration_test.go
Normal file
262
internal/storage/mysql/console_login_integration_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIntegrationConsoleLoginProtection(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 16, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err = Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
username := "login-" + string(testID(t))
|
||||
password := "login-protection-test-12345"
|
||||
var boot BootstrapResult
|
||||
if err = Bootstrap(ctx, db, BootstrapInput{Application: username, Namespace: "default", Environment: "test", Queue: "work", ConsoleUsername: username, ConsolePassword: password}, func(v BootstrapResult) error { boot = v; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := consoleauth.DefaultLoginPolicy()
|
||||
p.FailureLimit = 3
|
||||
p.IPLimit = 1000
|
||||
p.AccountLimit = 100
|
||||
p.LockDuration = time.Second
|
||||
first, second := NewConsoleAuthStore(db, p), NewConsoleAuthStore(db, p)
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.ExecContext(ctx, q, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var userID string
|
||||
if err = db.QueryRowContext(ctx, `SELECT LOWER(HEX(id)) FROM orbit_console_users WHERE username=?`, username).Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := loginSubject("user:" + userID)
|
||||
clear := func() { exec(`DELETE FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key) }
|
||||
login := func(s *ConsoleAuthStore, pass, ip string) error {
|
||||
t.Helper()
|
||||
_, err := s.Login(ctx, username, pass, "login-regression", ip)
|
||||
return err
|
||||
}
|
||||
expectBlocked := func(err error, locked bool) {
|
||||
t.Helper()
|
||||
var b *consoleauth.LoginBlocked
|
||||
if !errors.As(err, &b) || b.Locked != locked || b.RetryAfter <= 0 {
|
||||
t.Fatalf("expected block locked=%v got %v", locked, err)
|
||||
}
|
||||
}
|
||||
t.Run("failure_success_reset_cross_node_and_expiry", func(t *testing.T) {
|
||||
if err = login(first, "wrong", "192.0.2.10"); !errors.Is(err, fault.Unauthorized) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = login(second, password, "192.0.2.11"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err = login(first, "wrong", "192.0.2.10"); !errors.Is(err, fault.Unauthorized) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
expectBlocked(login(second, "wrong", "192.0.2.11"), true)
|
||||
var until, after time.Time
|
||||
if err = db.QueryRowContext(ctx, `SELECT locked_until FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&until); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectBlocked(login(NewConsoleAuthStore(db, p), password, "192.0.2.12"), true)
|
||||
if err = db.QueryRowContext(ctx, `SELECT locked_until FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&after); err != nil || !until.Equal(after) {
|
||||
t.Fatal("lock was extended", err)
|
||||
}
|
||||
// 使用数据库自身时钟等待真实截止,重建存储实例不能提前解除锁定。
|
||||
for {
|
||||
var elapsed bool
|
||||
if err = db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)>=?", until).Scan(&elapsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if elapsed {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if err = login(second, password, "192.0.2.12"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("concurrent_failures_serialize_and_lock", func(t *testing.T) {
|
||||
clear()
|
||||
policy := p
|
||||
policy.LockDuration = time.Minute
|
||||
a, b := NewConsoleAuthStore(db, policy), NewConsoleAuthStore(db, policy)
|
||||
results := make(chan error, 6)
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < 6; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
s := a
|
||||
if i%2 == 1 {
|
||||
s = b
|
||||
}
|
||||
results <- login(s, "wrong", "192.0.2.20")
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
invalid, blocked := 0, 0
|
||||
for err := range results {
|
||||
var v *consoleauth.LoginBlocked
|
||||
if errors.As(err, &v) && v.Locked {
|
||||
blocked++
|
||||
} else if errors.Is(err, fault.Unauthorized) {
|
||||
invalid++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if invalid != 2 || blocked != 4 {
|
||||
t.Fatalf("invalid=%d blocked=%d", invalid, blocked)
|
||||
}
|
||||
expectBlocked(login(a, password, "192.0.2.21"), true)
|
||||
var count int
|
||||
if err = db.QueryRowContext(ctx, `SELECT failures FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&count); err != nil || count != 3 {
|
||||
t.Fatalf("failure count=%d %v", count, err)
|
||||
}
|
||||
})
|
||||
t.Run("account_and_ip_limits", func(t *testing.T) {
|
||||
clear()
|
||||
policy := p
|
||||
policy.AccountLimit = 2
|
||||
s := NewConsoleAuthStore(db, policy)
|
||||
for _, ip := range []string{"192.0.2.30", "192.0.2.31"} {
|
||||
if err = login(s, password, ip); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
expectBlocked(login(NewConsoleAuthStore(db, policy), password, "192.0.2.32"), false)
|
||||
clear()
|
||||
policy.IPLimit = 2
|
||||
s = NewConsoleAuthStore(db, policy)
|
||||
for i := 0; i < 2; i++ {
|
||||
_, err = s.Login(ctx, "unknown-"+string(testID(t)), "wrong", "test", "192.0.2.40")
|
||||
if !errors.Is(err, fault.Unauthorized) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
expectBlocked(login(s, password, "192.0.2.40"), false)
|
||||
exec(`UPDATE orbit_console_login_guards SET window_start=UTC_TIMESTAMP(6)-INTERVAL 2 MINUTE WHERE kind='IP' AND subject=?`, loginSubject("192.0.2.40"))
|
||||
if err = login(s, password, "192.0.2.40"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("unknown_account_and_failure_window", func(t *testing.T) {
|
||||
unknown := "unknown-" + string(testID(t))
|
||||
policy := p
|
||||
policy.FailureLimit = 2
|
||||
s := NewConsoleAuthStore(db, policy)
|
||||
_, err = s.Login(ctx, unknown, "wrong", "test", "192.0.2.50")
|
||||
if !errors.Is(err, fault.Unauthorized) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.Login(ctx, unknown, "wrong", "test", "192.0.2.50")
|
||||
expectBlocked(err, true)
|
||||
clear()
|
||||
if err = login(first, "wrong", "192.0.2.51"); !errors.Is(err, fault.Unauthorized) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec(`UPDATE orbit_console_login_guards SET failures=2,first_failed_at=UTC_TIMESTAMP(6)-INTERVAL 1 HOUR WHERE kind='ACCOUNT' AND subject=?`, key)
|
||||
if err = login(second, "wrong", "192.0.2.51"); !errors.Is(err, fault.Unauthorized) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("audit_failure_cannot_issue_session", func(t *testing.T) {
|
||||
clear()
|
||||
var before, after int
|
||||
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_sessions WHERE user_id=UNHEX(?)`, userID).Scan(&before)
|
||||
trigger := "login_audit_" + string(testID(t))
|
||||
exec(`CREATE TRIGGER ` + trigger + ` BEFORE UPDATE ON orbit_console_login_audit FOR EACH ROW BEGIN IF NEW.username='` + username + `' AND NEW.outcome='SUCCESS' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test audit unavailable'; END IF; END`)
|
||||
defer exec("DROP TRIGGER " + trigger)
|
||||
if err = login(first, password, "192.0.2.60"); err == nil {
|
||||
t.Fatal("audit failure issued session")
|
||||
}
|
||||
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_sessions WHERE user_id=UNHEX(?)`, userID).Scan(&after)
|
||||
if before != after {
|
||||
t.Fatal("session survived failed audit")
|
||||
}
|
||||
})
|
||||
t.Run("audit_admin_pagination_secrets_and_cleanup", func(t *testing.T) {
|
||||
clear()
|
||||
result, err := first.Login(ctx, username, password, "test", "192.0.2.70")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, _ := auth.Parse(result.Token)
|
||||
sdk, _ := auth.Parse(boot.Token)
|
||||
store := NewConsoleApplicationStore(db, username)
|
||||
if _, err = store.ListLoginAudit(ctx, sdk, ""); !errors.Is(err, fault.Forbidden) {
|
||||
t.Fatal("SDK read login audit", err)
|
||||
}
|
||||
if _, err = NewConsoleApplicationStore(db, "another-admin").ListLoginAudit(ctx, session, ""); !errors.Is(err, fault.Forbidden) {
|
||||
t.Fatal("non-admin read audit", err)
|
||||
}
|
||||
page, err := store.ListLoginAudit(ctx, session, "")
|
||||
if err != nil || len(page.Items) != 10 || page.Next == "" {
|
||||
t.Fatalf("page: %+v %v", page, err)
|
||||
}
|
||||
next, err := store.ListLoginAudit(ctx, session, page.Next)
|
||||
if err != nil || len(next.Items) == 0 || next.Items[0].ID >= page.Items[9].ID {
|
||||
t.Fatal("audit pagination", err)
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT(username,remote_ip,user_agent,outcome) FROM orbit_console_login_audit WHERE username=?`, username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err = rows.Scan(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(value, password) || strings.Contains(value, result.Token) {
|
||||
t.Fatal("audit contains secret")
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
exec(`UPDATE orbit_console_login_audit SET created_at=UTC_TIMESTAMP(6)-INTERVAL 91 DAY WHERE username=? AND outcome='INVALID'`, username)
|
||||
exec(`UPDATE orbit_console_login_audit SET created_at=UTC_TIMESTAMP(6)-INTERVAL 11 MINUTE WHERE username=? AND outcome='PENDING'`, username)
|
||||
exec(`UPDATE orbit_console_login_guards SET expires_at=UTC_TIMESTAMP(6)-INTERVAL 1 DAY WHERE kind='IP' AND subject=?`, loginSubject("192.0.2.70"))
|
||||
if err = first.CleanupLoginSecurity(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_login_audit WHERE username=? AND (outcome='PENDING' OR created_at<UTC_TIMESTAMP(6)-INTERVAL 90 DAY)`, username).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatal("audit cleanup", n, err)
|
||||
}
|
||||
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatal("foreign keys", n, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -26,7 +26,7 @@ var migrationFiles embed.FS
|
||||
//go:embed baseline.sql
|
||||
var freshInstallBaseline string
|
||||
|
||||
const freshInstallBaselineVersion uint64 = 34
|
||||
const freshInstallBaselineVersion uint64 = 35
|
||||
|
||||
type Migration struct {
|
||||
Version uint64
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 登录保护独立于业务凭据和命名空间;没有外键,不改变已有密码、会话或 Token。
|
||||
CREATE TABLE orbit_console_login_guards (
|
||||
kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或 ACCOUNT,固定先 IP 后账号加锁',
|
||||
subject CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或规范账号身份的 SHA-256 十六进制键,不包含密码或 Token',
|
||||
window_start DATETIME(6) NOT NULL COMMENT '固定请求限流窗口起点,UTC',
|
||||
requests INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前窗口内已受理的尝试数,成功不归零',
|
||||
failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败窗口内的失败数,成功后归零',
|
||||
first_failed_at DATETIME(6) NULL COMMENT '失败窗口起点;空表示无连续失败',
|
||||
locked_until DATETIME(6) NULL COMMENT '临时锁定截止;锁内请求不延长此时间',
|
||||
expires_at DATETIME(6) NOT NULL COMMENT '非活动状态清理截止,晚于所有保护窗口',
|
||||
PRIMARY KEY(kind,subject),
|
||||
KEY ix_console_guard_expiry(expires_at),
|
||||
CONSTRAINT ck_console_guard_kind CHECK(kind IN ('IP','ACCOUNT'))
|
||||
) ENGINE=InnoDB COMMENT='跨节点持久登录限流及临时锁定状态';
|
||||
|
||||
CREATE TABLE orbit_console_login_audit (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '审计序号,仅用于有界倒序分页',
|
||||
username VARCHAR(128) NOT NULL COMMENT '请求中的登录名,不证明账号存在',
|
||||
remote_ip VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '实际连接或受信代理解析出的规范 IP',
|
||||
user_agent VARCHAR(512) NOT NULL COMMENT '有界浏览器标识,不保存请求正文或认证头',
|
||||
outcome VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'PENDING/SUCCESS/INVALID/LOCKED/ACCOUNT_LIMIT/IP_LIMIT/ERROR',
|
||||
created_at DATETIME(6) NOT NULL COMMENT '开始时间,UTC',
|
||||
finished_at DATETIME(6) NULL COMMENT '结果确认时间,空为未完成;异常中断由清理器标 ERROR',
|
||||
retry_at DATETIME(6) NULL COMMENT '被锁定或限流时的最早重试时间;否则为空',
|
||||
PRIMARY KEY(id),
|
||||
KEY ix_console_login_audit_time(created_at),
|
||||
KEY ix_console_login_audit_pending(outcome,created_at),
|
||||
CONSTRAINT ck_console_login_outcome CHECK(outcome IN ('PENDING','SUCCESS','INVALID','LOCKED','ACCOUNT_LIMIT','IP_LIMIT','ERROR'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='全局登录审计,不含密码、Cookie 或 Token,仅管理员可查询';
|
||||
@@ -147,7 +147,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
|
||||
indexes := func() []string {
|
||||
t.Helper()
|
||||
// 排除后续新增/替换索引;原有项在此逐项比较,v24/v25新增项由对应Query/Timeline升级测试核对。
|
||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','orbit_console_users','orbit_console_sessions','orbit_retention_policies','orbit_archive_jobs','orbit_archive_plans','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_subscription_active','ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_console_login_audit','orbit_console_login_guards','orbit_application_resources','orbit_event_streams','orbit_result_events','orbit_console_users','orbit_console_sessions','orbit_retention_policies','orbit_archive_jobs','orbit_archive_plans','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_subscription_active','ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -64,6 +67,12 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
|
||||
writeConsoleJSON(w, http.StatusOK, map[string]any{"authenticated": true})
|
||||
})
|
||||
mux.HandleFunc("POST /console/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := http.NewCrossOriginProtection().Check(r); err != nil {
|
||||
writeConsoleJSON(w, http.StatusForbidden, map[string]any{"error": map[string]string{"code": "PERMISSION_DENIED", "message": "不允许跨站登录请求"}})
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
|
||||
|
||||
if consoleAuth == nil {
|
||||
writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "console authentication is not configured"}})
|
||||
return
|
||||
@@ -78,10 +87,23 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
login, err := consoleAuth.Login(ctx, in.Username, in.Password, r.UserAgent(), remoteAddress(r))
|
||||
login, err := consoleAuth.Login(ctx, in.Username, in.Password, r.UserAgent(), loginRemoteAddress(r, consoleAuth))
|
||||
if err != nil {
|
||||
// 账号不存在、密码错误、账号停用和绑定凭据失效统一返回,避免账号枚举。
|
||||
writeConsoleJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{"code": "UNAUTHENTICATED", "message": "用户名或密码错误"}})
|
||||
var blocked *consoleauth.LoginBlocked
|
||||
if errors.As(err, &blocked) {
|
||||
seconds := max(1, int(math.Ceil(blocked.RetryAfter.Seconds())))
|
||||
code, message := "LOGIN_RATE_LIMITED", "登录尝试过于频繁,请稍后重试"
|
||||
if blocked.Locked {
|
||||
code, message = "LOGIN_LOCKED", "连续登录失败,已临时锁定,请稍后重试"
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(seconds))
|
||||
writeConsoleJSON(w, http.StatusTooManyRequests, map[string]any{"error": map[string]any{"code": code, "message": message, "retry_after_seconds": seconds}})
|
||||
} else if errors.Is(err, fault.Unauthorized) {
|
||||
writeConsoleJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{"code": "UNAUTHENTICATED", "message": "用户名或密码错误"}})
|
||||
} else {
|
||||
// 存储或审计不可用时不发 Cookie,不将基础设施故障伪装成密码错误。
|
||||
writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "登录服务暂不可用,请稍后重试"}})
|
||||
}
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: consoleSessionCookie, Value: login.Token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Expires: login.ExpiresAt, MaxAge: int(time.Until(login.ExpiresAt).Seconds())})
|
||||
|
||||
@@ -2,12 +2,36 @@
|
||||
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) {
|
||||
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
||||
clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 2800);
|
||||
}
|
||||
let loginAuditCursor = '', loginAuditPage = 1, loginAuditLoading = false;
|
||||
async function loadLoginAudit(first = false) {
|
||||
if (loginAuditLoading) return;
|
||||
loginAuditLoading = true;
|
||||
$('login-audit-first').disabled = true; $('login-audit-next').disabled = true;
|
||||
text('login-audit-status', '正在读取…');
|
||||
try {
|
||||
const cursor = first ? '' : loginAuditCursor;
|
||||
const page = extractData(await request(`/console/auth/audit${cursor ? `?before=${encodeURIComponent(cursor)}` : ''}`));
|
||||
const labels = { SUCCESS: '登录成功', INVALID: '账号或密码错误', LOCKED: '临时锁定', ACCOUNT_LIMIT: '账号限流', IP_LIMIT: 'IP 限流', PENDING: '校验中', ERROR: '未完成 / 服务异常' };
|
||||
$('login-audit-rows').innerHTML = (page.items || []).map(row => `<tr><td>${escapeHTML(formatTime(row.created_at))}</td><td>${escapeHTML(row.username)}</td><td>${escapeHTML(row.remote_ip)}</td><td>${escapeHTML(labels[row.outcome] || row.outcome)}</td><td>${escapeHTML(formatTime(row.retry_at))}</td></tr>`).join('');
|
||||
loginAuditPage = first ? 1 : loginAuditPage + 1; loginAuditCursor = page.next || '';
|
||||
text('login-audit-page', `第 ${loginAuditPage} 页`); text('login-audit-status', page.items?.length ? '' : '暂无登录记录');
|
||||
} catch (error) { text('login-audit-status', `读取失败:${error.message}`); }
|
||||
finally { loginAuditLoading = false; $('login-audit-first').disabled = false; $('login-audit-next').disabled = !loginAuditCursor; }
|
||||
}
|
||||
$('login-audit-open').addEventListener('click', () => { $('login-audit-dialog').showModal(); loadLoginAudit(true); });
|
||||
$('login-audit-close').addEventListener('click', () => $('login-audit-dialog').close());
|
||||
$('login-audit-first').addEventListener('click', () => loadLoginAudit(true));
|
||||
$('login-audit-next').addEventListener('click', () => loadLoginAudit());
|
||||
function formatTime(value) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value);
|
||||
@@ -23,21 +47,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' });
|
||||
$('main-content').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}`;
|
||||
@@ -89,6 +116,10 @@
|
||||
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}${item.retired_at ? ' · 已退役' : item.paused ? ' · 已暂停' : ''}`;
|
||||
if (kind === 'application') {
|
||||
option.dataset.title = item.application;
|
||||
option.dataset.subtitle = `${item.environment} / ${item.namespace}${item.enabled ? '' : ' · 已停用'}`;
|
||||
}
|
||||
option.disabled = kind === 'application' && !item.enabled;
|
||||
select.append(option);
|
||||
}
|
||||
@@ -119,13 +150,14 @@
|
||||
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];
|
||||
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;
|
||||
$('login-audit-open').hidden = !scope.can_switch;
|
||||
$('scope-queue').disabled = !queues.length;
|
||||
if (scope.can_switch) {
|
||||
try { await loadScopeApplications(); } catch (error) { text('scope-error', '应用目录读取失败:' + error.message); }
|
||||
@@ -140,7 +172,7 @@
|
||||
$('scope-application').replaceChildren(new Option('范围不可用', ''));
|
||||
$('scope-queue').replaceChildren(new Option('未加载', ''));
|
||||
$('scope-application').disabled = true; $('scope-queue').disabled = true;
|
||||
$('task-table').innerHTML = '<tr><td colspan="6" class="empty-row"><span class="empty-icon">◇</span><b>当前范围未加载</b><small>请检查应用范围,或点击“重置范围”</small></td></tr>';
|
||||
$('task-table').innerHTML = '<tr><td colspan="7" class="empty-row"><span class="empty-icon">◇</span><b>当前范围未加载</b><small>请检查应用范围,或点击“重置范围”</small></td></tr>';
|
||||
text('scope-error', `当前应用范围不可用:${error.message}。可点击“重置范围”返回默认应用。`);
|
||||
}
|
||||
}
|
||||
@@ -401,65 +433,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 `<span class="state-pill ${stateClass(value)}" title="${escapeHTML(value)}">${escapeHTML(label(value))}</span>`; }
|
||||
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 `<button class="text-button resource-name" data-resource-kind="${kind}" data-resource-id="${escapeHTML(item.id)}" title="${escapeHTML(item.id)}">${escapeHTML(name || shortID(item.id))}</button>`; }
|
||||
function detailButton(kind, item) { return resourceButton(kind, item, '详情 →'); }
|
||||
function renderList(kind, items) {
|
||||
const body = $(listBodies[kind]);
|
||||
if (!items.length) { body.innerHTML = `<tr><td colspan="${listColumns[kind]}" class="empty-row"><span class="empty-icon">○</span><b>没有符合条件的记录</b><small>检查应用与队列,或调整筛选条件。</small></td></tr>`; return; }
|
||||
body.innerHTML = items.map(item => {
|
||||
let cells;
|
||||
if (kind === 'tasks') cells = `<td><div class="task-id">${resourceButton(kind,item,shortID(item.id))}<small title="${escapeHTML(item.business_key || '')}">${escapeHTML(item.business_key || '未设置业务键')}</small></div></td><td>${scopeBadge()}</td><td class="task-processor"><span title="${escapeHTML(item.handler || '')}">${escapeHTML(item.handler || '—')}</span><small>${escapeHTML(item.handler_version || '')}</small></td><td>${badge(item.state)}</td><td>${escapeHTML(String(item.attempt_count ?? 0))} 次</td><td>${escapeHTML(formatTime(item.created_at))}</td>`;
|
||||
if (kind === 'schedules') cells = `<td>${resourceButton(kind,item,item.name)}<small>${escapeHTML(shortID(item.id))}</small></td><td>${scopeBadge()}</td><td>${badge(item.paused ? 'PAUSED' : 'ENABLED')}</td><td>${escapeHTML(String(item.current_version || '—'))}</td><td>${escapeHTML(item.paused ? '已暂停' : formatTime(item.next_fire_at))}</td>`;
|
||||
if (kind === 'workflows') cells = `<td>${resourceButton(kind,item,item.business_key || shortID(item.id))}<small>${escapeHTML(shortID(item.id))}</small></td><td>${scopeBadge()}</td><td>${badge(item.state)}${item.paused ? ' '+badge('PAUSED') : ''}</td><td>${escapeHTML(item.version || '—')}</td><td>${escapeHTML(formatTime(item.created_at))}</td>`;
|
||||
if (kind === 'subscriptions') cells = `<td>${resourceButton(kind,item,item.name)}<small>${escapeHTML(shortID(item.id))}</small></td><td>${scopeBadge()}</td><td>${badge(item.retired_at ? 'RETIRED' : item.enabled === false ? 'PAUSED' : 'ENABLED')}</td><td>${escapeHTML(String(item.pending_count ?? 0))} <span class="muted">/ ${escapeHTML(String(item.max_pending ?? '—'))}</span></td><td>${escapeHTML(formatTime(item.created_at))}</td>`;
|
||||
return `<tr class="resource-row">${cells}<td>${detailButton(kind,item)}</td></tr>`;
|
||||
}).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 = `<tr><td colspan="${listColumns[kind]}" class="empty-row">正在读取…</td></tr>`;
|
||||
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 = `<tr><td colspan="${listColumns[kind]}" class="empty-row">本页读取失败</td></tr>`;
|
||||
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 = '<tr><td colspan="6" class="empty-row"><span class="empty-icon">◇</span><b>当前队列没有任务</b><small>提交任务后,这里会自动显示最新状态</small></td></tr>'; return; }
|
||||
body.innerHTML = items.map(item => `<tr class="task-row" data-task-id="${escapeHTML(item.id)}" tabindex="0" title="打开任务详情"><td><div class="task-id"><b>${escapeHTML(shortID(item.id))}</b><small>${escapeHTML(item.business_key || '无业务键')}</small></div></td><td>${scopeBadge()}</td><td class="task-processor">${escapeHTML(item.handler || '—')}<br><small>${escapeHTML(item.handler_version || '')}</small></td><td><span class="state-pill ${stateClass(item.state)}">${escapeHTML(item.state || 'UNKNOWN')}</span></td><td>${escapeHTML(item.revision || '—')}</td><td>${escapeHTML(formatTime(item.created_at))}</td></tr>`).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=>`<tr><td>${resourceButton('tasks',t,shortID(t.id))}<small>${escapeHTML(t.business_key || '未设置业务键')}</small></td><td class="task-processor"><span title="${escapeHTML(t.handler)}">${escapeHTML(t.handler)}</span></td><td>${badge(t.state)}</td><td>${escapeHTML(formatTime(t.created_at))}</td><td>${detailButton('tasks',t)}</td></tr>`).join('') : `<tr><td colspan="5" class="empty-row">${tasks ? '当前队列还没有任务' : '任务暂时无法读取,请检查权限或稍后刷新'}</td></tr>`;
|
||||
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(`<button class="attention-item" data-attention="${kind}" data-filter-state="${state}"><span class="attention-number">${number}</span><span><strong>${title}</strong><small>${note}</small></span><span>→</span></button>`); };
|
||||
add(taskReview,'tasks','任务等待核查','执行结果不明确,需要先核对证据','NEEDS_REVIEW');
|
||||
add(failed,'tasks','任务执行失败','查看失败原因和执行历史','FAILED');
|
||||
add(flowReview,'workflows','流程等待核查','查看流程步骤和恢复记录','NEEDS_REVIEW');
|
||||
add(pending,'subscriptions','结果等待确认','检查消费者状态和交付历史');
|
||||
$('attention-list').innerHTML = attention.join('') || `<div class="attention-clear"><strong>${tasks && workflows && subscriptions ? '当前快照暂无异常' : '部分数据暂不可用'}</strong>${tasks && workflows && subscriptions ? '最近记录中没有待核查任务、失败任务或未确认结果。' : '请根据页面提示检查连接和读取权限。'}</div>`;
|
||||
}
|
||||
function renderSchedules(items) {
|
||||
text('schedule-page-hint', `${items.length} 项 · 点击计划查看详情`);
|
||||
const el = $('schedule-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>◷</span><div><b>当前没有定时计划</b><small>当前应用与队列还没有创建计划</small></div></div>'; return; }
|
||||
el.innerHTML = items.map(item => `<div class="list-row schedule-row" data-schedule-id="${escapeHTML(item.id)}" tabindex="0" title="打开计划详情"><div class="list-avatar">◷</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}</small></div><div class="list-application">${scopeBadge()}</div><div class="list-meta">${escapeHTML(item.paused ? '已暂停' : '运行中')}</div></div>`).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 = '<div class="empty-compact"><span>⌘</span><div><b>当前没有流程实例</b><small>当前应用环境还没有启动流程</small></div></div>'; return; }
|
||||
el.innerHTML = items.map(item => `<div class="list-row workflow-row" data-workflow-id="${escapeHTML(item.id)}" tabindex="0" title="打开流程详情"><div class="list-avatar">⌘</div><div class="list-copy"><b>${escapeHTML(item.business_key || shortID(item.id))}</b><small>${escapeHTML(item.state || '—')} · ${escapeHTML(item.version || '—')}</small></div><div class="list-application">${scopeBadge()}</div><div class="list-meta">${escapeHTML(formatTime(item.created_at))}</div></div>`).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 = '<div class="empty-compact"><span>⌁</span><div><b>当前没有结果订阅</b><small>当前应用与队列还没有创建订阅</small></div></div>'; return; }
|
||||
el.innerHTML = items.map(item => `<div class="list-row subscription-row" data-subscription-id="${escapeHTML(item.id)}" tabindex="0" title="打开订阅详情"><div class="list-avatar">⌁</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-application">${scopeBadge()}</div><div class="list-meta">${item.retired_at ? '已退役' : item.enabled === false ? '已暂停' : '已启用'}</div></div>`).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 +539,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 +570,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 = `<div class="detail-empty">${escapeHTML(emptyText)}</div>`; return; }
|
||||
@@ -509,12 +603,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 +616,20 @@
|
||||
}
|
||||
function renderAttempts(items, error) {
|
||||
text('detail-attempts-count', error ? '权限不足' : `${items.length} 次`);
|
||||
renderDetailList('detail-attempts', items, error ? '当前凭据没有 tasks:read 历史权限。' : '还没有执行尝试。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>第 ${escapeHTML(item.epoch)} 次 · ${escapeHTML(item.state || 'UNKNOWN')}</b><small>${escapeHTML(item.error_code || '无错误')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${item.retryable ? '可重试' : '不可重试'}</em></div>`);
|
||||
renderDetailList('detail-attempts', items, error ? '当前凭据没有 tasks:read 历史权限。' : '还没有执行尝试。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>第 ${escapeHTML(item.epoch)} 次 · ${escapeHTML(label(item.state))}</b><small>${escapeHTML(item.error_code || '无错误')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${item.retryable ? '可重试' : '不可重试'}</em></div>`);
|
||||
}
|
||||
function renderOperations(items, error) {
|
||||
text('detail-operations-count', error ? '权限不足' : `${items.length} 条`);
|
||||
renderDetailList('detail-operations', items, error ? '当前凭据没有 tasks:audit:read 权限。' : '还没有操作审计。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(item.action || '操作')}</b><small>${escapeHTML(item.actor_id || '—')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${escapeHTML(shortID(item.id))}</em></div>`);
|
||||
renderDetailList('detail-operations', items, error ? '当前凭据没有 tasks:audit:read 权限。' : '还没有操作审计。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(label(item.action || '操作'))}</b><small>${escapeHTML(item.actor_id || '—')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${escapeHTML(shortID(item.id))}</em></div>`);
|
||||
}
|
||||
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 = '<div class="detail-loading">正在读取尝试历史…</div>'; $('detail-operations').innerHTML = '<div class="detail-loading">正在读取操作审计…</div>';
|
||||
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 +657,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 => `<div class="detail-list-row"><span class="timeline-dot ${String(item.decision || '').toLowerCase()}"></span><div><b>${escapeHTML(item.decision || 'UNKNOWN')} · ${escapeHTML(formatTime(item.scheduled_at))}</b><small>${escapeHTML(item.reason || '—')} · 版本 ${escapeHTML(item.schedule_version || '—')}</small></div><em>${item.task_id ? escapeHTML(shortID(item.task_id)) : '无任务'}</em></div>`);
|
||||
renderDetailList('schedule-occurrences', items, error ? '当前凭据没有 schedules:read 触发历史权限。' : '还没有触发记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${String(item.decision || '').toLowerCase()}"></span><div><b>${escapeHTML(label(item.decision || 'UNKNOWN'))} · ${escapeHTML(formatTime(item.scheduled_at))}</b><small>${escapeHTML(label(item.reason || '—'))} · 版本 ${escapeHTML(item.schedule_version || '—')}</small></div><em>${item.task_id ? escapeHTML(shortID(item.task_id)) : '无任务'}</em></div>`);
|
||||
}
|
||||
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 = `<b>${escapeHTML(scheduleRuleLabel(rule))}</b><small>漏跑 ${escapeHTML(definition.policy?.misfire || '—')} · 重叠 ${escapeHTML(definition.policy?.overlap || '—')} · 补偿上限 ${escapeHTML(definition.policy?.catchup_limit || '—')}</small>`;
|
||||
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 = `<b>${escapeHTML(scheduleRuleLabel(rule))}</b><small>漏跑 ${escapeHTML(label(definition.policy?.misfire || '—'))} · 重叠 ${escapeHTML(label(definition.policy?.overlap || '—'))} · 补偿上限 ${escapeHTML(definition.policy?.catchup_limit || '—')}</small>`;
|
||||
$('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 = '<div class="detail-loading">正在读取触发记录…</div>';
|
||||
const ticket = showScheduleDrawer(); state.selectedSchedule = null; closeScheduleAction(); text('schedule-detail-title', '正在读取计划…'); text('schedule-detail-id', id); $('schedule-actions').hidden = true; $('schedule-occurrences').innerHTML = '<div class="detail-loading">正在读取触发记录…</div>';
|
||||
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 +693,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 +795,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 +805,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 `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.key || item.id || '未命名步骤')} · ${escapeHTML(item.kind || 'STEP')}</b><small>${escapeHTML(item.state || 'UNKNOWN')} · ${escapeHTML(link)} · 修订 ${escapeHTML(item.revision || '—')}</small></div><em>${item.activation_sequence ? `第 ${escapeHTML(item.activation_sequence)} 代` : '—'}</em></div>`;
|
||||
return `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.key || item.id || '未命名步骤')} · ${escapeHTML(item.kind || 'STEP')}</b><small>${escapeHTML(label(item.state))} · ${escapeHTML(link)} · 修订 ${escapeHTML(item.revision || '—')}</small></div><em>${item.activation_sequence ? `第 ${escapeHTML(item.activation_sequence)} 代` : '—'}</em></div>`;
|
||||
});
|
||||
}
|
||||
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 => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(item.type || '状态事件')} · ${escapeHTML(item.state || '—')}</b><small>${escapeHTML(item.state_scope || item.aggregate_kind || 'WORKFLOW')} ${item.step_key ? `· ${escapeHTML(item.step_key)}` : ''} · ${escapeHTML(formatTime(item.occurred_at))}</small></div><em>修订 ${escapeHTML(item.aggregate_revision || '—')}</em></div>`);
|
||||
renderDetailList('workflow-events', items, error ? '当前凭据没有 workflows:audit:read 事件审计权限。' : '还没有保存的状态事件。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(label(item.type || '状态事件'))} · ${escapeHTML(label(item.state || '—'))}</b><small>${escapeHTML(label(item.state_scope || item.aggregate_kind || 'WORKFLOW'))} ${item.step_key ? `· ${escapeHTML(item.step_key)}` : ''} · ${escapeHTML(formatTime(item.occurred_at))}</small></div><em>修订 ${escapeHTML(item.aggregate_revision || '—')}</em></div>`);
|
||||
}
|
||||
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 = '<div class="detail-loading">正在读取步骤…</div>'; $('workflow-events').innerHTML = '<div class="detail-loading">正在读取事件…</div>';
|
||||
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 = '<div class="detail-loading">正在读取步骤…</div>'; $('workflow-events').innerHTML = '<div class="detail-loading">正在读取事件…</div>';
|
||||
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 +829,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 => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.state || 'UNKNOWN')} · 序号 ${escapeHTML(item.sequence || '—')}</b><small>${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}</small></div>${String(item.state || '') === 'EXPIRED' ? `<button class="detail-row-action" type="button" data-delivery-id="${escapeHTML(item.id)}">重试</button>` : `<em>${escapeHTML(shortID(item.event_id))}</em>`}</div>`);
|
||||
renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(label(item.state))} · 序号 ${escapeHTML(item.sequence || '—')}</b><small>${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}</small></div>${String(item.state || '') === 'EXPIRED' ? `<button class="detail-row-action" type="button" data-delivery-id="${escapeHTML(item.id)}">重试</button>` : `<em>${escapeHTML(shortID(item.event_id))}</em>`}</div>`);
|
||||
$('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 +869,50 @@
|
||||
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 = '<div class="detail-loading">正在读取交付历史…</div>';
|
||||
const ticket = showSubscriptionDrawer(); state.selectedSubscription = null; closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', id); $('subscription-deliveries').innerHTML = '<div class="detail-loading">正在读取交付历史…</div>';
|
||||
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({ background: background === true });
|
||||
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) {
|
||||
const more = form.querySelector('.filter-more');
|
||||
more.addEventListener('click', () => { const dates = $(more.getAttribute('aria-controls')); dates.hidden = !dates.hidden; more.setAttribute('aria-expanded', String(!dates.hidden)); });
|
||||
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; window.OrbitSelect?.sync(form.elements.state); } 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 +926,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 && !window.OrbitSelect?.isOpen() && !document.activeElement?.matches('input,select,textarea') && !(currentView()==='applications' && document.querySelector('#access-detail details[open]'))) refresh(true); }, 10000);
|
||||
})();
|
||||
|
||||
@@ -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) {
|
||||
@@ -36,22 +38,44 @@
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\;&|<>(){}]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址');
|
||||
return `ORBIT_URL=${url.replace(/\/$/, '')}\nORBIT_APPLICATION_ID=${ui.detail?.entry.application_id || ''}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\nORBIT_TOKEN=${token}\n`;
|
||||
}
|
||||
async function load() {
|
||||
let lastScrollAt = -Infinity, detailSignature = '', previousListHTML = '';
|
||||
document.addEventListener('scroll', () => { lastScrollAt = performance.now(); }, true);
|
||||
function editingOrScrolling() {
|
||||
return performance.now() - lastScrollAt < 1500 || document.activeElement?.matches('input,select,textarea') ||
|
||||
!!document.querySelector('dialog[open], #access-detail details[open], #access-retire-form:not([hidden])');
|
||||
}
|
||||
async function load({ background = false } = {}) {
|
||||
if (ui.loading || ui.busy) return;
|
||||
if (background && editingOrScrolling()) return;
|
||||
ui.loading = true; $('access-error').textContent = '';
|
||||
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 => `<button class="access-item ${entry.namespace_id === ui.selected ? 'selected' : ''}" data-access-id="${escape(entry.namespace_id)}"><span class="access-avatar">${escape(entry.application.slice(0, 1).toUpperCase())}</span><span><strong>${escape(entry.application)}</strong><small>${escape(entry.environment)} / ${escape(entry.namespace)} · ${entry.enabled ? '已启用' : '已停用'}</small></span></button>`).join('') : '<p class="panel-description">还没有接入应用,点击右上角新建。</p>';
|
||||
const page = await api(ui.after ? `?after=${encodeURIComponent(ui.after)}` : '');
|
||||
if (background && editingOrScrolling()) return;
|
||||
ui.next = page.next || ''; $('access-next').disabled = !ui.next;
|
||||
const listHTML = page.items.length ? page.items.map(entry => `<button class="access-item ${entry.namespace_id === ui.selected ? 'selected' : ''}" data-access-id="${escape(entry.namespace_id)}"><span class="access-avatar">${escape(entry.application.slice(0, 1).toUpperCase())}</span><span><strong>${escape(entry.application)}</strong><small>${escape(entry.environment)} / ${escape(entry.namespace)} · ${entry.enabled ? '已启用' : '已停用'}</small></span></button>`).join('') : '<p class="panel-description">还没有接入应用,点击右上角新建。</p>';
|
||||
if (previousListHTML !== listHTML) { $('access-list').innerHTML = listHTML; previousListHTML = listHTML; }
|
||||
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);
|
||||
else if (ui.selected && !$('access-dialog').open && !$('access-token-dialog').open) await select(ui.selected, { background });
|
||||
} 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;
|
||||
async function select(namespace, { background = false } = {}) {
|
||||
if (namespace !== ui.selected) {
|
||||
detailSignature = '';
|
||||
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;
|
||||
if (background && editingOrScrolling()) return;
|
||||
const now = Date.now();
|
||||
// 相同快照不重建详情,保留滚动锚点、焦点和折叠状态;到期状态仍按当前时间更新。
|
||||
const signature = JSON.stringify([detail, detail.credentials.map(c => [!!c.expires_at && Date.parse(c.expires_at) <= now, Date.parse(c.previous_valid_until) > now])]);
|
||||
ui.detail = detail;
|
||||
if (signature === detailSignature && !$('access-detail').hidden) return;
|
||||
$('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}`;
|
||||
@@ -64,7 +88,6 @@
|
||||
$('access-copy-template').textContent = availableCredential?.token_stored ? '查看完整接入配置 ↗' : '补存当前 Token ↗';
|
||||
$('access-config-note').textContent = availableCredential?.token_stored ? '选择正确队列后,可查看、复制含当前 Token 的完整配置。' : '当前 Token 尚未补存。请从已保存的新配置导入当前 Token,再查看或复制;过渡期旧 Token 不能用于补存。';
|
||||
$('access-truncated').hidden = !detail.truncated;
|
||||
const now = Date.now();
|
||||
$('access-credentials').innerHTML = detail.credentials.map(c => {
|
||||
const status = c.revoked_at ? '已撤销' : c.expires_at && Date.parse(c.expires_at) <= now ? '已到期' : '有效';
|
||||
const transitioning = !c.revoked_at && Date.parse(c.previous_valid_until) > now;
|
||||
@@ -79,13 +102,19 @@
|
||||
<div class="access-token-summary"><div><strong>接入配置 · 当前 Token</strong><span>${c.revoked_at ? '凭据已撤销' : c.token_stored ? '•••••••••••••••• · 已加密保管' : '历史凭据 · 导入当前 Token 后可随时查看'}</span></div></div>
|
||||
<div class="access-form-actions"><button type="button" class="primary-button compact" data-access-token="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>${c.token_stored ? '查看接入信息' : '导入当前 Token'}</button><button type="button" class="secondary-button compact" data-access-copy-config="${escape(c.id)}" ${c.revoked_at || !c.token_stored ? 'disabled' : ''}>复制接入配置 ↗</button></div>
|
||||
${rotationHelp}
|
||||
<div class="access-form-actions"><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
|
||||
<div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details>
|
||||
<details class="credential-controls"><summary>凭据管理与权限</summary><div class="access-form-actions"><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
|
||||
<div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details></details>
|
||||
</section>`;
|
||||
}).join('') || '<p class="panel-description">该环境暂无凭据。</p>';
|
||||
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); }
|
||||
detailSignature = signature;
|
||||
} 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() {
|
||||
|
||||
98
internal/transport/httpserver/console/console-core.js
Normal file
98
internal/transport/httpserver/console/console-core.js
Normal file
@@ -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 = 10) {
|
||||
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 = 10; 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);
|
||||
147
internal/transport/httpserver/console/console-select.js
Normal file
147
internal/transport/httpserver/console/console-select.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/* 渐进增强原生 select:表单值和 change 事件仍由原控件提供。 */
|
||||
(() => {
|
||||
const controls = new Map();
|
||||
let opened = null;
|
||||
let serial = 0;
|
||||
function enhance(select) {
|
||||
const searchable = select.hasAttribute('data-search');
|
||||
const name = select.getAttribute('aria-label') || '选择选项';
|
||||
const id = `orbit-select-${++serial}`;
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.className = 'select-trigger';
|
||||
button.setAttribute('aria-label', name);
|
||||
button.setAttribute('aria-haspopup', 'listbox');
|
||||
button.setAttribute('aria-controls', id);
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
const caption = document.createElement('span'); caption.className = 'select-caption';
|
||||
const chevron = document.createElement('span'); chevron.className = 'select-chevron'; chevron.setAttribute('aria-hidden', 'true');
|
||||
button.append(caption, chevron);
|
||||
const popup = document.createElement('div'); popup.className = 'select-popover'; popup.hidden = true;
|
||||
const search = document.createElement('input');
|
||||
search.type = 'search'; search.placeholder = select.dataset.search || '搜索已加载选项';
|
||||
search.setAttribute('aria-label', search.placeholder);
|
||||
search.setAttribute('role', 'combobox'); search.setAttribute('aria-autocomplete', 'list');
|
||||
search.setAttribute('aria-controls', id); search.setAttribute('aria-expanded', 'false');
|
||||
if (searchable) {
|
||||
const head = document.createElement('div'); head.className = 'select-search'; head.append(search); popup.append(head);
|
||||
}
|
||||
const list = document.createElement('div'); list.id = id; list.className = 'select-options';
|
||||
list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', name); list.tabIndex = -1;
|
||||
const empty = document.createElement('p'); empty.className = 'select-empty'; empty.setAttribute('role', 'status');
|
||||
empty.textContent = '没有匹配项,请更换关键词'; empty.hidden = true;
|
||||
popup.append(list, empty); document.body.append(popup);
|
||||
select.after(button); select.hidden = true;
|
||||
let rows = [], active = -1;
|
||||
const focusOwner = () => searchable ? search : list;
|
||||
function position() {
|
||||
if (popup.hidden) return;
|
||||
const rect = button.getBoundingClientRect();
|
||||
if (!rect.width || rect.bottom < 0 || rect.top > innerHeight) { close(); return; }
|
||||
const width = Math.min(Math.max(rect.width, searchable ? 280 : 150), innerWidth - 24);
|
||||
popup.style.width = `${width}px`;
|
||||
popup.style.left = `${Math.max(12, Math.min(rect.left, innerWidth - width - 12))}px`;
|
||||
const below = innerHeight - rect.bottom - 16, above = rect.top - 16;
|
||||
const up = below < Math.min(popup.scrollHeight, 300) && above > below;
|
||||
const room = Math.max(80, up ? above : below);
|
||||
list.style.maxHeight = `${Math.min(280, room - (searchable ? 61 : 12))}px`;
|
||||
popup.style.top = `${up ? Math.max(8, rect.top - popup.getBoundingClientRect().height - 6) : rect.bottom + 6}px`;
|
||||
}
|
||||
function highlight(index, scroll = false) {
|
||||
active = index;
|
||||
rows.forEach((row, i) => row.node.classList.toggle('is-active', i === index));
|
||||
if (index < 0) focusOwner().removeAttribute('aria-activedescendant');
|
||||
else {
|
||||
focusOwner().setAttribute('aria-activedescendant', rows[index].node.id);
|
||||
if (scroll) rows[index].node.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
function choose(option) {
|
||||
if (option.disabled || select.disabled) return;
|
||||
const changed = select.value !== option.value;
|
||||
select.value = option.value;
|
||||
close(true);
|
||||
if (changed) select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
// 范围切换可能被未提交表单拦截,以业务监听器确认后的值为准。
|
||||
sync();
|
||||
}
|
||||
function render() {
|
||||
const query = search.value.toLocaleLowerCase();
|
||||
list.replaceChildren(); rows = [];
|
||||
[...select.options].forEach((option, index) => {
|
||||
if (option.hidden || (query && option.value !== '__more__' && !option.textContent.toLocaleLowerCase().includes(query))) return;
|
||||
const node = document.createElement('div'); node.className = 'select-option'; node.id = `${id}-${index}`;
|
||||
node.setAttribute('role', 'option'); node.setAttribute('aria-selected', String(option.selected));
|
||||
node.setAttribute('aria-disabled', String(option.disabled));
|
||||
const copy = document.createElement('span'); copy.className = 'select-option-copy';
|
||||
const title = document.createElement('b'); title.textContent = option.dataset.title || option.textContent; copy.append(title);
|
||||
if (option.dataset.subtitle) { const sub = document.createElement('small'); sub.textContent = option.dataset.subtitle; copy.append(sub); }
|
||||
const check = document.createElement('span'); check.className = 'select-check'; check.textContent = option.selected ? '✓' : ''; check.setAttribute('aria-hidden', 'true');
|
||||
node.append(copy, check);
|
||||
const rowIndex = rows.length;
|
||||
node.addEventListener('pointermove', () => { if (!option.disabled) highlight(rowIndex); });
|
||||
node.addEventListener('mousedown', event => event.preventDefault());
|
||||
node.addEventListener('click', () => choose(option));
|
||||
rows.push({ option, node }); list.append(node);
|
||||
});
|
||||
empty.hidden = rows.length > 0;
|
||||
const selected = rows.findIndex(row => row.option.selected && !row.option.disabled);
|
||||
highlight(selected >= 0 ? selected : rows.findIndex(row => !row.option.disabled));
|
||||
position();
|
||||
}
|
||||
function sync() {
|
||||
caption.textContent = select.selectedOptions[0]?.textContent || '请选择';
|
||||
button.title = caption.textContent; button.disabled = select.disabled;
|
||||
if (select.disabled) close();
|
||||
else if (!popup.hidden) render();
|
||||
}
|
||||
function close(focus = false) {
|
||||
if (popup.hidden) return;
|
||||
popup.hidden = true; button.setAttribute('aria-expanded', 'false'); search.setAttribute('aria-expanded', 'false');
|
||||
list.removeAttribute('aria-activedescendant'); search.removeAttribute('aria-activedescendant');
|
||||
if (opened?.button === button) opened = null;
|
||||
if (focus && !button.disabled) button.focus();
|
||||
}
|
||||
function open() {
|
||||
if (select.disabled) return;
|
||||
opened?.close(); sync(); search.value = ''; popup.hidden = false;
|
||||
opened = { button, popup, close, position };
|
||||
button.setAttribute('aria-expanded', 'true'); search.setAttribute('aria-expanded', 'true');
|
||||
render(); focusOwner().focus(); highlight(active, true);
|
||||
}
|
||||
function keydown(event) {
|
||||
if (event.key === 'Escape' && !popup.hidden) { event.preventDefault(); event.stopPropagation(); close(true); return; }
|
||||
if (event.key === 'Tab' && !popup.hidden) { close(true); return; }
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
// 搜索输入的 Home/End 保留文本编辑语义。
|
||||
if (event.target === search && ['Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
if (popup.hidden) { open(); return; }
|
||||
const enabled = rows.map((row, index) => row.option.disabled ? -1 : index).filter(index => index >= 0);
|
||||
if (!enabled.length) return;
|
||||
const current = enabled.indexOf(active), last = enabled.length - 1;
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? last : Math.max(0, Math.min(last, current + (event.key === 'ArrowDown' ? 1 : -1)));
|
||||
highlight(enabled[next], true);
|
||||
} else if (event.key === 'Enter' || (event.key === ' ' && event.target !== search)) {
|
||||
event.preventDefault();
|
||||
if (popup.hidden) open(); else if (active >= 0) choose(rows[active].option);
|
||||
} else if (event.key.length === 1 && searchable && event.target === button && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
event.preventDefault(); open(); search.value = event.key; render();
|
||||
}
|
||||
}
|
||||
button.addEventListener('click', event => { event.preventDefault(); popup.hidden ? open() : close(); });
|
||||
button.addEventListener('keydown', keydown); search.addEventListener('keydown', keydown); list.addEventListener('keydown', keydown);
|
||||
search.addEventListener('input', render);
|
||||
select.addEventListener('change', () => queueMicrotask(sync));
|
||||
// reset 事件先于浏览器恢复默认值;下一个任务再同步显示。
|
||||
select.form?.addEventListener('reset', () => setTimeout(sync, 0));
|
||||
new MutationObserver(sync).observe(select, { childList: true, subtree: true, attributes: true, characterData: true });
|
||||
controls.set(select, sync); sync();
|
||||
}
|
||||
document.querySelectorAll('select[data-select]').forEach(enhance);
|
||||
document.addEventListener('pointerdown', event => { if (opened && !opened.popup.contains(event.target) && !opened.button.contains(event.target)) opened.close(); });
|
||||
document.addEventListener('focusin', event => { if (opened && !opened.popup.contains(event.target) && !opened.button.contains(event.target)) opened.close(); });
|
||||
document.addEventListener('scroll', event => { if (opened && !opened.popup.contains(event.target)) opened.position(); }, true);
|
||||
window.addEventListener('resize', () => opened?.position());
|
||||
window.addEventListener('hashchange', () => opened?.close());
|
||||
window.OrbitSelect = Object.freeze({ sync: select => controls.get(select)?.(), isOpen: () => !!opened });
|
||||
})();
|
||||
BIN
internal/transport/httpserver/console/favicon.ico
Normal file
BIN
internal/transport/httpserver/console/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
8
internal/transport/httpserver/console/favicon.svg
Normal file
8
internal/transport/httpserver/console/favicon.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 35 35">
|
||||
<rect width="35" height="35" rx="10" fill="#087f75"/>
|
||||
<g fill="#fff">
|
||||
<rect x="8.5" y="12" width="4" height="11" rx="2"/>
|
||||
<rect x="15.5" y="7.5" width="4" height="20" rx="2"/>
|
||||
<rect x="22.5" y="10" width="4" height="15" rx="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 320 B |
@@ -1,31 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" class="console-document">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Orbit Console · 星轨控制台</title>
|
||||
<link rel="icon" href="/console/favicon.ico" sizes="16x16 32x32 48x48">
|
||||
<link rel="icon" type="image/svg+xml" href="/console/favicon.svg" sizes="any">
|
||||
<link rel="stylesheet" href="/console/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="ambient ambient-one"></div>
|
||||
<div class="ambient ambient-two"></div>
|
||||
<body class="console-body">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><span></span><span></span><span></span></div>
|
||||
<div><strong>Orbit</strong><small>星轨控制台</small></div>
|
||||
</div>
|
||||
<div class="workspace-label">CONTROL PLANE</div>
|
||||
<div class="workspace-label">工作台</div>
|
||||
<nav class="nav-list" aria-label="控制台导航">
|
||||
<button class="nav-item active" data-view="overview"><span class="nav-icon">⌂</span><span>总览</span><em>01</em></button>
|
||||
<button class="nav-item" data-view="tasks"><span class="nav-icon">◇</span><span>任务执行</span><em>02</em></button>
|
||||
<button class="nav-item" data-view="schedules"><span class="nav-icon">◷</span><span>定时计划</span><em>03</em></button>
|
||||
<button class="nav-item" data-view="workflows"><span class="nav-icon">⌘</span><span>流程实例</span><em>04</em></button>
|
||||
<button class="nav-item" data-view="subscriptions"><span class="nav-icon">⌁</span><span>结果订阅</span><em>05</em></button>
|
||||
<button class="nav-item" data-view="retention"><span class="nav-icon">⌛</span><span>保留策略</span><em>06</em></button>
|
||||
<button class="nav-item" data-view="archives"><span class="nav-icon">▤</span><span>归档作业</span><em>07</em></button>
|
||||
<button class="nav-item" data-view="applications"><span class="nav-icon">⊞</span><span>应用接入</span><em>08</em></button>
|
||||
<button class="nav-item active" data-view="overview"><span class="nav-icon">⌂</span><span>总览</span></button>
|
||||
<span class="nav-group-label">运行管理</span><button class="nav-item" data-view="tasks"><span class="nav-icon">◇</span><span>任务执行</span></button>
|
||||
<button class="nav-item" data-view="schedules"><span class="nav-icon">◷</span><span>定时计划</span></button>
|
||||
<button class="nav-item" data-view="workflows"><span class="nav-icon">⌘</span><span>流程实例</span></button>
|
||||
<button class="nav-item" data-view="subscriptions"><span class="nav-icon">⌁</span><span>结果订阅</span></button>
|
||||
<span class="nav-group-label">数据管理</span><button class="nav-item" data-view="retention"><span class="nav-icon">◴</span><span>保留策略</span></button>
|
||||
<button class="nav-item" data-view="archives"><span class="nav-icon">▤</span><span>归档作业</span></button>
|
||||
<span class="nav-group-label">接入管理</span><button class="nav-item" data-view="applications"><span class="nav-icon">⊞</span><span>应用接入</span></button>
|
||||
</nav>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="rail-card">
|
||||
@@ -37,115 +38,78 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<main class="main-content" id="main-content" tabindex="-1">
|
||||
<header class="topbar">
|
||||
<div class="crumbs"><span>Orbit</span><b>/</b><strong id="page-title">总览</strong></div>
|
||||
<div class="topbar-actions">
|
||||
<div class="topbar-actions"><label class="auto-refresh"><input type="checkbox" id="auto-refresh" checked>自动刷新</label>
|
||||
<span class="live-chip"><i></i><span id="live-label">本地环境</span></span>
|
||||
<span class="last-updated" id="last-updated">尚未刷新</span>
|
||||
<span class="last-updated" id="last-updated" aria-live="polite">尚未刷新</span>
|
||||
<button class="secondary-button compact" id="login-audit-open" hidden>登录审计</button>
|
||||
<button class="icon-button" id="logout-button" title="退出登录" aria-label="退出登录">↪</button>
|
||||
<button class="icon-button" id="refresh-button" title="刷新数据" aria-label="刷新数据">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p id="refresh-error" class="page-error" role="alert" hidden></p>
|
||||
<section class="application-scope" id="application-scope" aria-label="应用筛选">
|
||||
<div class="scope-heading"><span class="scope-symbol">⊞</span><div><b>应用范围</b><small id="scope-summary">正在读取…</small></div></div>
|
||||
<label class="scope-field"><span>应用 / 环境</span><select id="scope-application" disabled aria-label="选择应用与环境"><option>正在读取…</option></select></label>
|
||||
<label class="scope-field scope-queue"><span>队列</span><select id="scope-queue" disabled aria-label="选择队列"><option>正在读取…</option></select></label>
|
||||
<label class="scope-field"><span>应用 / 环境</span><select id="scope-application" data-select data-search="搜索应用 / 环境" disabled aria-label="选择应用与环境"><option>正在读取…</option></select></label>
|
||||
<label class="scope-field scope-queue"><span>队列</span><select id="scope-queue" data-select data-search="搜索队列" disabled aria-label="选择队列"><option>正在读取…</option></select></label>
|
||||
<div class="scope-note"><small id="scope-hint">任务、计划和订阅按所选队列显示</small><button id="scope-reset" type="button" class="text-button">重置范围 ↗</button></div>
|
||||
<p id="scope-error" class="scope-error" role="status"></p>
|
||||
</section>
|
||||
<div class="page-view active" id="view-overview" data-page="overview">
|
||||
<section class="hero" id="overview">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow"><span class="eyebrow-line"></span> ORBIT CONTROL PLANE</div>
|
||||
<h1>让每一次执行<br><span>都清晰可见。</span></h1>
|
||||
<p>任务、定时、流程和交付状态,在一块屏幕上保持有序运转。</p>
|
||||
<div class="hero-actions">
|
||||
<button class="primary-button" data-view="tasks">查看任务 <span>↗</span></button>
|
||||
<button class="quiet-button" id="logout-hero">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-visual" aria-hidden="true">
|
||||
<div class="orbit-ring ring-one"></div>
|
||||
<div class="orbit-ring ring-two"></div>
|
||||
<div class="orbit-ring ring-three"></div>
|
||||
<div class="orbit-core"><span>O</span></div>
|
||||
<div class="orbit-node node-a"></div><div class="orbit-node node-b"></div><div class="orbit-node node-c"></div>
|
||||
<div class="hero-visual-label"><b>LIVE</b><span>任务流持续观测</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="page-heading"><div><span class="page-eyebrow">WORKSPACE / OVERVIEW</span><h1>运行概览</h1><p>先看运行状态,再处理需要关注的工作。</p></div><button class="secondary-button" data-view="applications">管理应用 <span>↗</span></button></div>
|
||||
<section class="metric-grid" aria-label="运行概览">
|
||||
<article class="metric-card metric-highlight"><div class="metric-top"><span>服务状态</span><span class="metric-icon green">✦</span></div><strong id="metric-health">—</strong><small id="metric-health-sub">等待就绪检查</small><div class="sparkline green-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
||||
<article class="metric-card"><div class="metric-top"><span>任务总览</span><span class="metric-icon purple">◇</span></div><strong id="metric-tasks">—</strong><small id="metric-tasks-sub">当前会话加载</small><div class="mini-bar"><i id="task-bar-succeeded"></i><i id="task-bar-running"></i><i id="task-bar-waiting"></i><i id="task-bar-other"></i></div></article>
|
||||
<article class="metric-card"><div class="metric-top"><span>流程实例</span><span class="metric-icon blue">⌘</span></div><strong id="metric-workflows">—</strong><small id="metric-workflows-sub">等待工作流权限</small><div class="sparkline blue-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
||||
<article class="metric-card"><div class="metric-top"><span>结果订阅</span><span class="metric-icon orange">⌁</span></div><strong id="metric-subscriptions">—</strong><small id="metric-subscriptions-sub">等待事件权限</small><div class="subscription-meter"><span id="subscription-meter-fill"></span></div></article>
|
||||
<article class="metric-card metric-highlight"><div class="metric-top"><span>服务状态</span><span class="status-dot"></span></div><strong id="metric-health">—</strong><small id="metric-health-sub">正在检查服务就绪状态</small></article>
|
||||
<button class="metric-card" data-view="tasks"><div class="metric-top"><span>最近任务</span><span>↗</span></div><strong id="metric-tasks">—</strong><small id="metric-tasks-sub">当前队列 · 最近 20 条</small><div class="mini-bar"><i id="task-bar-succeeded"></i><i id="task-bar-running"></i><i id="task-bar-waiting"></i><i id="task-bar-other"></i></div></button>
|
||||
<button class="metric-card" data-view="workflows"><div class="metric-top"><span>最近流程</span><span>↗</span></div><strong id="metric-workflows">—</strong><small id="metric-workflows-sub">当前应用 · 最近 20 条</small></button>
|
||||
<button class="metric-card" data-view="subscriptions"><div class="metric-top"><span>结果订阅</span><span>↗</span></div><strong id="metric-subscriptions">—</strong><small id="metric-subscriptions-sub">当前队列 · 最近 20 条</small><div class="subscription-meter"><span id="subscription-meter-fill"></span></div></button>
|
||||
</section>
|
||||
|
||||
<section class="content-grid overview-grid" aria-label="运行统计与连接状态">
|
||||
<article class="panel stats-panel" id="runtime-stats">
|
||||
<div class="panel-heading"><div><span class="panel-kicker">RUNTIME / SNAPSHOT</span><h2>运行统计</h2></div><span class="table-count" id="stats-snapshot">等待数据</span></div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-tile"><span>当前窗口成功率</span><strong id="stat-success-rate">—</strong><small id="stat-success-rate-sub">等待任务数据</small></div>
|
||||
<div class="stat-tile"><span>活跃计划</span><strong id="stat-active-schedules">—</strong><small id="stat-active-schedules-sub">等待计划数据</small></div>
|
||||
<div class="stat-tile"><span>待核查流程</span><strong id="stat-review-workflows">—</strong><small id="stat-review-workflows-sub">等待流程数据</small></div>
|
||||
<div class="stat-tile"><span>待交付结果</span><strong id="stat-pending-deliveries">—</strong><small id="stat-pending-deliveries-sub">等待订阅数据</small></div>
|
||||
</div>
|
||||
<div class="distribution-block">
|
||||
<div class="distribution-heading"><span>任务状态分布</span><small id="stat-task-total">—</small></div>
|
||||
<div class="distribution-list">
|
||||
<div class="distribution-row"><span>成功</span><div class="status-track"><i id="stat-bar-succeeded"></i></div><b id="stat-count-succeeded">0</b></div>
|
||||
<div class="distribution-row"><span>执行中</span><div class="status-track"><i id="stat-bar-running"></i></div><b id="stat-count-running">0</b></div>
|
||||
<div class="distribution-row"><span>等待</span><div class="status-track"><i id="stat-bar-waiting"></i></div><b id="stat-count-waiting">0</b></div>
|
||||
<div class="distribution-row"><span>异常</span><div class="status-track"><i id="stat-bar-other"></i></div><b id="stat-count-other">0</b></div>
|
||||
</div>
|
||||
<div class="snapshot-note"><span class="note-dot"></span>以下为各类最近 20 条记录的快照,不代表全部数据。任务、计划、订阅按队列统计,流程按应用统计。<span id="stats-snapshot">等待数据</span></div>
|
||||
<section class="overview-grid">
|
||||
<article class="panel stats-panel" id="runtime-stats"><div class="panel-heading"><div><h2>任务状态分布</h2><p>当前队列的最近任务</p></div><span class="table-count" id="stat-task-total">—</span></div>
|
||||
<div class="distribution-list">
|
||||
<div class="distribution-row"><span>已成功</span><div class="status-track"><i id="stat-bar-succeeded"></i></div><b id="stat-count-succeeded">—</b></div>
|
||||
<div class="distribution-row"><span>执行中</span><div class="status-track"><i id="stat-bar-running"></i></div><b id="stat-count-running">—</b></div>
|
||||
<div class="distribution-row"><span>等待执行</span><div class="status-track"><i id="stat-bar-waiting"></i></div><b id="stat-count-waiting">—</b></div>
|
||||
<div class="distribution-row"><span>失败 / 待核查</span><div class="status-track"><i id="stat-bar-other"></i></div><b id="stat-count-other">—</b></div>
|
||||
<div class="distribution-row"><span>已取消</span><div class="status-track"><i id="stat-bar-cancelled"></i></div><b id="stat-count-cancelled">—</b></div>
|
||||
</div><div class="stat-grid">
|
||||
<div class="stat-tile"><span>窗口成功占比</span><strong id="stat-success-rate">—</strong><small id="stat-success-rate-sub">等待数据</small></div>
|
||||
<div class="stat-tile"><span>启用中的计划</span><strong id="stat-active-schedules">—</strong><small id="stat-active-schedules-sub">等待数据</small></div>
|
||||
<div class="stat-tile"><span>待核查流程</span><strong id="stat-review-workflows">—</strong><small id="stat-review-workflows-sub">等待数据</small></div>
|
||||
<div class="stat-tile"><span>待确认结果</span><strong id="stat-pending-deliveries">—</strong><small id="stat-pending-deliveries-sub">等待数据</small></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="panel connection-summary-panel">
|
||||
<div class="panel-heading"><div><span class="panel-kicker">CONTROL PLANE</span><h2>登录状态</h2></div><span class="connection-state" id="connection-state">未连接</span></div>
|
||||
<div class="connection-summary-copy"><span class="connection-summary-icon">↗</span><div><b id="summary-api">当前页面</b><small id="summary-api-hint">API 地址自动跟随当前页面</small></div></div>
|
||||
<div class="connection-summary-grid"><div><span>队列范围</span><b id="summary-queue">自动配置</b></div><div><span>登录会话</span><b id="summary-credential">未登录</b></div></div>
|
||||
<p class="connection-summary-note" id="summary-note">服务状态会自动检查;需要业务数据时再加载凭据。</p>
|
||||
<button class="quiet-button" id="logout-inline" type="button">退出当前账号 <span>→</span></button>
|
||||
</article>
|
||||
<article class="panel attention-panel"><div class="panel-heading"><div><h2>需要关注</h2><p>来自当前快照的异常与积压</p></div><span class="attention-mark">!</span></div><div id="attention-list" class="attention-list">正在检查…</div><div class="quick-links"><button data-view="applications">应用接入 <span>↗</span></button><button data-view="archives">归档作业 <span>↗</span></button></div></article>
|
||||
</section>
|
||||
<article class="panel recent-panel"><div class="panel-heading"><div><h2>最近任务</h2><p>点击任务查看结果与执行历史</p></div><button class="text-button" data-view="tasks">查看全部 →</button></div><div class="table-wrap"><table><thead><tr><th>任务 / 业务键</th><th>处理器</th><th>状态</th><th>创建时间</th><th><span class="sr-only">操作</span></th></tr></thead><tbody id="overview-task-list"></tbody></table></div></article>
|
||||
<details class="service-details"><summary>服务与会话信息 <span id="connection-state">正在检查</span></summary><div class="service-detail-grid"><div><span>服务地址</span><b id="summary-api">—</b><small id="summary-api-hint"></small></div><div><span>队列</span><b id="summary-queue">—</b></div><div><span>登录方式</span><b id="summary-credential">—</b></div></div><p id="summary-note"></p></details>
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-tasks" data-page="tasks">
|
||||
<section class="content-grid single-column">
|
||||
<article class="panel wide-panel" id="tasks">
|
||||
<div class="panel-heading"><div><span class="panel-kicker">PULSE / TASKS</span><h2>任务执行流</h2></div><div class="panel-tools"><span class="table-count" id="task-count">0 项</span><button class="text-button" data-view="overview">返回总览 <span>←</span></button></div></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>任务</th><th>应用 / 环境</th><th>处理器</th><th>状态</th><th>修订</th><th>创建时间</th></tr></thead><tbody id="task-table"><tr><td colspan="6" class="empty-row"><span class="empty-icon">◇</span><b>还没有任务数据</b><small>登录后按账号权限自动加载</small></td></tr></tbody></table></div>
|
||||
</article>
|
||||
</section>
|
||||
<div class="page-heading"><div><h1>任务执行</h1><p>查询任务状态、查看执行历史,按证据处理失败和重试。</p></div><form class="id-lookup" data-lookup="tasks"><label class="sr-only" for="tasks-lookup">任务 ID</label><input id="tasks-lookup" placeholder="输入任务 ID 定位" pattern="[a-f0-9]{32}" maxlength="32" required autocomplete="off"><button class="secondary-button compact" type="submit">定位</button></form></div>
|
||||
<article class="panel data-panel"><form class="list-filters" id="tasks-filters"><label>状态<select name="state" data-select aria-label="状态"><option value="">全部状态</option><option value="WAITING">等待执行</option><option value="LEASED">已分配</option><option value="RUNNING">执行中</option><option value="NEEDS_REVIEW">待核查</option><option value="SUCCEEDED">已成功</option><option value="FAILED">已失败</option><option value="CANCELLED">已取消</option></select></label><label class="filter-key">业务键<input name="business_key" placeholder="输入完整业务键,精确匹配" maxlength="191" autocomplete="off"></label><div class="filter-actions"><button class="primary-button compact" type="submit">查询</button><button class="secondary-button compact" type="reset">重置</button></div><button type="button" class="filter-more" aria-expanded="false" aria-controls="tasks-dates">创建时间<span aria-hidden="true">⌄</span></button><div class="filter-dates" id="tasks-dates" hidden><label>创建时间从(含)<input name="created_from" type="datetime-local"></label><label>到(不含)<input name="created_before" type="datetime-local"></label><small>按浏览器本地时区查询,设置后点击「查询」。</small></div></form><div class="list-summary"><span id="tasks-summary">正在读取…</span><span class="muted">当前应用 / 队列 · 按创建时间倒序</span></div><p id="tasks-error" class="list-error" role="alert" hidden></p><div class="table-wrap"><table><thead><tr><th>任务 / 业务键</th><th>应用 / 环境</th><th>处理器</th><th>状态</th><th>执行次数</th><th>创建时间</th><th>操作</th></tr></thead><tbody id="task-table"><tr><td colspan="7" class="empty-row">正在读取…</td></tr></tbody></table></div><div class="list-pagination"><label>每页 <select id="tasks-limit" data-select aria-label="任务执行每页条数"><option>10</option><option>20</option><option>50</option><option>100</option></select> 条</label><div><button id="tasks-first" class="text-button" type="button">回到最新</button><button id="tasks-prev" class="secondary-button compact" type="button" disabled>上一页</button><span id="tasks-page">第 1 页</span><button id="tasks-next" class="secondary-button compact" type="button" disabled>下一页</button></div></div></article>
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-schedules" data-page="schedules">
|
||||
<section class="content-grid single-column">
|
||||
<article class="panel" id="schedules"><div class="panel-heading"><div><span class="panel-kicker">PULSE / SCHEDULES</span><h2>定时计划</h2></div><span class="table-count" id="schedule-page-hint">点击计划查看详情</span></div><div class="schedule-list" id="schedule-list"><div class="empty-compact"><span>◷</span><div><b>等待计划数据</b><small>需要 schedules:read 权限</small></div></div></div></article>
|
||||
</section>
|
||||
<div class="page-heading"><div><h1>定时计划</h1><p>查看计划状态与触发历史,暂停或恢复后续调度。</p></div><form class="id-lookup" data-lookup="schedules"><label class="sr-only" for="schedules-lookup">计划 ID</label><input id="schedules-lookup" placeholder="输入计划 ID 定位" pattern="[a-f0-9]{32}" maxlength="32" required autocomplete="off"><button class="secondary-button compact" type="submit">定位</button></form></div>
|
||||
<article class="panel data-panel"><div class="list-summary"><span id="schedules-summary">正在读取…</span><span class="muted">当前应用 / 队列 · 按创建时间倒序</span></div><p id="schedules-error" class="list-error" role="alert" hidden></p><div class="table-wrap"><table><thead><tr><th>计划名称</th><th>应用 / 环境</th><th>状态</th><th>当前版本</th><th>下次触发</th><th>操作</th></tr></thead><tbody id="schedule-list"><tr><td colspan="6" class="empty-row">正在读取…</td></tr></tbody></table></div><div class="list-pagination"><label>每页 <select id="schedules-limit" data-select aria-label="定时计划每页条数"><option>10</option><option>20</option><option>50</option><option>100</option></select> 条</label><div><button id="schedules-first" class="text-button" type="button">回到最新</button><button id="schedules-prev" class="secondary-button compact" type="button" disabled>上一页</button><span id="schedules-page">第 1 页</span><button id="schedules-next" class="secondary-button compact" type="button" disabled>下一页</button></div></div></article>
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-workflows" data-page="workflows">
|
||||
<section class="content-grid single-column">
|
||||
<article class="panel" id="workflows"><div class="panel-heading"><div><span class="panel-kicker">WEAVE / WORKFLOWS</span><h2>流程实例</h2></div><span class="table-count">点击实例查看详情</span></div><div class="workflow-list" id="workflow-list"><div class="empty-compact"><span>⌘</span><div><b>等待流程数据</b><small>需要 workflows:read 权限</small></div></div></div></article>
|
||||
</section>
|
||||
<div class="page-heading"><div><h1>流程实例</h1><p>跟踪每一步进展,查看等待原因与恢复记录。</p></div><form class="id-lookup" data-lookup="workflows"><label class="sr-only" for="workflows-lookup">流程 ID</label><input id="workflows-lookup" placeholder="输入流程 ID 定位" pattern="[a-f0-9]{32}" maxlength="32" required autocomplete="off"><button class="secondary-button compact" type="submit">定位</button></form></div>
|
||||
<article class="panel data-panel"><form class="list-filters" id="workflows-filters"><label>状态<select name="state" data-select aria-label="状态"><option value="">全部状态</option><option value="RUNNING">执行中</option><option value="WAITING">等待中</option><option value="CANCELLING">取消中</option><option value="NEEDS_REVIEW">待核查</option><option value="SUCCEEDED">已成功</option><option value="FAILED">已失败</option><option value="CANCELLED">已取消</option></select></label><label class="filter-key">业务键<input name="business_key" placeholder="输入完整业务键,精确匹配" maxlength="191" autocomplete="off"></label><div class="filter-actions"><button class="primary-button compact" type="submit">查询</button><button class="secondary-button compact" type="reset">重置</button></div><button type="button" class="filter-more" aria-expanded="false" aria-controls="workflows-dates">创建时间<span aria-hidden="true">⌄</span></button><div class="filter-dates" id="workflows-dates" hidden><label>创建时间从(含)<input name="created_from" type="datetime-local"></label><label>到(不含)<input name="created_before" type="datetime-local"></label><small>按浏览器本地时区查询,设置后点击「查询」。</small></div></form><div class="list-summary"><span id="workflows-summary">正在读取…</span><span class="muted">当前应用环境 · 按创建时间倒序</span></div><p id="workflows-error" class="list-error" role="alert" hidden></p><div class="table-wrap"><table><thead><tr><th>流程 / 业务键</th><th>应用 / 环境</th><th>状态</th><th>固定版本</th><th>创建时间</th><th>操作</th></tr></thead><tbody id="workflow-list"><tr><td colspan="6" class="empty-row">正在读取…</td></tr></tbody></table></div><div class="list-pagination"><label>每页 <select id="workflows-limit" data-select aria-label="流程实例每页条数"><option>10</option><option>20</option><option>50</option><option>100</option></select> 条</label><div><button id="workflows-first" class="text-button" type="button">回到最新</button><button id="workflows-prev" class="secondary-button compact" type="button" disabled>上一页</button><span id="workflows-page">第 1 页</span><button id="workflows-next" class="secondary-button compact" type="button" disabled>下一页</button></div></div></article>
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-subscriptions" data-page="subscriptions">
|
||||
<section class="content-grid single-column">
|
||||
<article class="panel" id="subscriptions"><div class="panel-heading"><div><span class="panel-kicker">EVENTS / DELIVERY</span><h2>结果订阅</h2></div><span class="live-label-small"><i></i>自动刷新</span></div><div class="subscription-list" id="subscription-list"><div class="empty-compact"><span>⌁</span><div><b>等待订阅数据</b><small>需要 events:read 权限</small></div></div></div></article>
|
||||
</section>
|
||||
<div class="page-heading"><div><h1>结果订阅</h1><p>查看待确认结果、交付历史和订阅状态。</p></div><form class="id-lookup" data-lookup="subscriptions"><label class="sr-only" for="subscriptions-lookup">订阅 ID</label><input id="subscriptions-lookup" placeholder="输入订阅 ID 定位" pattern="[a-f0-9]{32}" maxlength="32" required autocomplete="off"><button class="secondary-button compact" type="submit">定位</button></form></div>
|
||||
<article class="panel data-panel"><div class="list-summary"><span id="subscriptions-summary">正在读取…</span><span class="muted">当前应用 / 队列 · 按创建时间倒序</span></div><p id="subscriptions-error" class="list-error" role="alert" hidden></p><div class="table-wrap"><table><thead><tr><th>订阅名称</th><th>应用 / 环境</th><th>状态</th><th>待确认 / 上限</th><th>创建时间</th><th>操作</th></tr></thead><tbody id="subscription-list"><tr><td colspan="6" class="empty-row">正在读取…</td></tr></tbody></table></div><div class="list-pagination"><label>每页 <select id="subscriptions-limit" data-select aria-label="结果订阅每页条数"><option>10</option><option>20</option><option>50</option><option>100</option></select> 条</label><div><button id="subscriptions-first" class="text-button" type="button">回到最新</button><button id="subscriptions-prev" class="secondary-button compact" type="button" disabled>上一页</button><span id="subscriptions-page">第 1 页</span><button id="subscriptions-next" class="secondary-button compact" type="button" disabled>下一页</button></div></div></article>
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-archives" data-page="archives">
|
||||
<div class="archive-heading"><div><span class="panel-kicker">DATA / ARCHIVE JOBS</span><h1>让历史数据,有序归档。</h1><p>配置周期,自动分批保存历史;每次执行都有进度与校验记录。</p></div><button class="secondary-button compact" id="archive-refresh">刷新作业 ↻</button></div>
|
||||
<div class="archive-heading"><div><h1>归档作业</h1><p>设置归档周期,查看执行进度和失败原因。</p></div><button class="secondary-button compact" id="archive-refresh">刷新作业 ↻</button></div>
|
||||
<div class="archive-stats"><article><span>周期计划</span><strong id="archive-enabled-count">—</strong><small>已启用 / 7 个类别</small></article><article><span>本页待处理</span><strong id="archive-failed-count">—</strong><small>失败作业保留进度,等待处理</small></article><article><span>本页归档记录</span><strong id="archive-root-count">—</strong><small>数据库已确认的根记录数</small></article></div>
|
||||
<div id="archive-error" class="action-error" role="alert"></div>
|
||||
<section class="archive-grid">
|
||||
<article class="panel archive-config">
|
||||
<div class="panel-heading"><div><span class="panel-kicker">SCHEDULE</span><h2>周期配置</h2></div><span class="connection-state" id="archive-executor">正在读取</span></div>
|
||||
<div class="panel-heading"><div><h2>周期配置</h2></div><span class="connection-state" id="archive-executor">正在读取</span></div>
|
||||
<form id="archive-plan-form">
|
||||
<label class="field-label" for="archive-kind">归档类别</label><select id="archive-kind"><option value="tasks">任务历史</option><option value="workflows">流程历史</option><option value="events">事件日志</option><option value="audit">管理审计</option><option value="deliveries">交付历史</option><option value="inbox">Inbox 历史</option><option value="idempotency">幂等记录</option></select>
|
||||
<div class="retention-switch"><div><b>开启周期归档</b><small>首次启用立即运行,之后按周期执行。</small></div><label class="switch"><input id="archive-enabled" type="checkbox" aria-label="开启周期归档"><span></span></label></div>
|
||||
@@ -160,7 +124,7 @@
|
||||
<button class="text-button" data-view="retention">调整历史保留时间 ↗</button>
|
||||
</article>
|
||||
<article class="panel archive-history">
|
||||
<div class="panel-heading"><div><span class="panel-kicker">EXECUTION HISTORY</span><h2>归档作业</h2></div><span class="table-count" id="archive-job-count">0 项</span></div>
|
||||
<div class="panel-heading"><div><h2>归档作业</h2></div><span class="table-count" id="archive-job-count">0 项</span></div>
|
||||
<p class="panel-description">源数据库记录始终保留。文件写入服务端持久卷,页面展示已确认的批次和 SHA-256 摘要。</p>
|
||||
<div class="archive-job-list" id="archive-job-list"><div class="empty-row">正在读取归档作业…</div></div>
|
||||
<div class="archive-pager"><button class="secondary-button compact" id="archive-first">返回最新</button><button class="secondary-button compact" id="archive-next" disabled>更早作业 ↓</button></div>
|
||||
@@ -175,9 +139,10 @@
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-retention" data-page="retention">
|
||||
<div class="page-heading"><div><h1>保留策略</h1><p>配置历史保留窗口,预览和导出归档候选。</p></div></div>
|
||||
<section class="content-grid single-column">
|
||||
<article class="panel wide-panel retention-panel">
|
||||
<div class="panel-heading"><div><span class="panel-kicker">DATA / RETENTION</span><h2>历史数据保留策略</h2></div><span class="connection-state ready" id="retention-source">默认策略</span></div>
|
||||
<div class="panel-heading"><div><h2>历史数据保留策略</h2></div><span class="connection-state ready" id="retention-source">默认策略</span></div>
|
||||
<p class="panel-description">这里配置各类历史记录的最短保留时间。保存策略只记录配置,不会立即删除数据;预览和导出会核对终态与未确认结果。当前提供只读快照,不自动清理源数据。</p>
|
||||
<div id="retention-scope" class="retention-scope"></div><form id="retention-form" class="retention-form">
|
||||
<div class="retention-switch"><div><b>允许导出只读归档</b><small>默认关闭。开启并保存后,可分批导出已满足保留窗口的历史;未确认结果仍受保护。</small></div><label class="switch"><input id="retention-enabled" type="checkbox" aria-label="允许导出只读归档"><span></span></label></div>
|
||||
@@ -208,40 +173,39 @@
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-applications" data-page="applications">
|
||||
<div class="access-heading"><div><span class="panel-kicker">CONNECT / APPLICATIONS</span><h1>让应用,轻松接入。</h1><p>创建应用、管理凭据,把任务交给正确的队列。</p></div><button class="primary-button compact" id="access-create">新建应用 <span>+</span></button></div>
|
||||
<div class="access-guide"><span><b>01</b> 创建应用与队列</span><i>→</i><span><b>02</b> 复制 SDK 配置</span><i>→</i><span><b>03</b> 注册 Worker 开始执行</span></div>
|
||||
<div class="access-heading"><div><h1>应用接入</h1><p>选择应用和队列,复制配置即可开始接入。</p></div><button class="primary-button compact" id="access-create">新建应用 <span>+</span></button></div>
|
||||
<div id="access-error" class="action-error" role="alert"></div>
|
||||
<section class="access-grid">
|
||||
<article class="panel access-list-panel"><div class="panel-heading"><div><span class="panel-kicker">YOUR APPLICATIONS</span><h2>接入应用</h2></div><button class="text-button" id="access-refresh">刷新 ↻</button></div>
|
||||
<div id="access-list" class="access-list"><p class="panel-description">正在读取应用…</p></div>
|
||||
<article class="panel access-list-panel"><div class="panel-heading"><div><h2>接入应用</h2></div><button class="text-button" id="access-refresh">刷新 ↻</button></div>
|
||||
<label class="access-search">查找应用<input id="access-search" type="search" placeholder="搜索本页应用或环境" autocomplete="off"></label><div id="access-list" class="access-list"><p class="panel-description">正在读取应用…</p></div><p id="access-search-empty" class="panel-description" hidden>本页没有匹配的应用。清空搜索或翻到下一页查找。</p>
|
||||
<div class="access-pager"><button class="text-button" id="access-first">第一页</button><button class="text-button" id="access-next" disabled>下一页 →</button></div>
|
||||
</article>
|
||||
<article class="panel access-detail-panel"><div id="access-empty" class="empty-row">选择应用,查看队列和接入配置。</div>
|
||||
<div id="access-detail" hidden>
|
||||
<div class="panel-heading"><div><span class="panel-kicker">APPLICATION DETAILS</span><h2 id="access-title"></h2></div><span class="connection-state" id="access-status"></span></div>
|
||||
<div class="access-meta"><div><span>应用 ID</span><code id="access-app-id"></code></div><div><span>环境 / 命名空间</span><b id="access-scope"></b></div><div><span>命名空间 ID</span><code id="access-ns-id"></code></div></div>
|
||||
<div class="panel-heading"><div><h2 id="access-title"></h2></div><span class="connection-state" id="access-status"></span></div>
|
||||
<details class="technical-details"><summary>应用标识与环境</summary><div class="access-meta"><div><span>应用 ID</span><code id="access-app-id"></code></div><div><span>环境 / 命名空间</span><b id="access-scope"></b></div><div><span>命名空间 ID</span><code id="access-ns-id"></code></div></div></details>
|
||||
<div class="access-config-grid"><label>服务地址<input id="access-url" type="url" autocomplete="off" spellcheck="false"></label><label>任务队列<select id="access-queue"></select></label></div>
|
||||
<p class="panel-description">本机默认使用当前页面地址;容器内可填写 http://orbit-test:8080,部署后可换成服务域名。</p>
|
||||
<div class="access-credentials" id="access-credentials"></div>
|
||||
<p class="panel-description" id="access-queue-note"></p><p class="panel-description" id="access-capacity"></p><button class="text-button" type="button" id="access-retire-queue">退役所选队列</button>
|
||||
<form id="access-retire-form" hidden><p class="panel-description">退役不可恢复。先暂停关联计划、结清任务与流程,再退役所有订阅;历史和审计继续保留。</p><label>退役原因<input id="access-retire-reason" maxlength="1024" required></label><button class="primary-button compact" type="submit" id="access-retire-submit">确认退役</button><button class="text-button" type="button" id="access-retire-cancel">取消</button><p id="access-retire-error" class="action-error"></p></form>
|
||||
<p class="panel-description" id="access-queue-note"></p><p class="panel-description" id="access-capacity"></p><details class="danger-zone"><summary>队列管理</summary><button class="text-button" type="button" id="access-retire-queue">退役所选队列</button>
|
||||
<form id="access-retire-form" hidden><p class="panel-description">退役不可恢复。先暂停关联计划、结清任务与流程,再退役所有订阅;历史和审计继续保留。</p><label>退役原因<input id="access-retire-reason" maxlength="1024" required></label><button class="primary-button compact" type="submit" id="access-retire-submit">确认退役</button><button class="text-button" type="button" id="access-retire-cancel">取消</button><p id="access-retire-error" class="action-error"></p></form></details>
|
||||
<p class="panel-description" id="access-truncated" hidden>队列或凭据超过 100 项,此页只展示前 100 项。</p>
|
||||
<div class="access-config-heading"><h3>接入配置预览</h3><button class="text-button" id="access-copy-template">查看完整接入配置 ↗</button></div>
|
||||
<details class="technical-details"><summary>配置预览(隐藏 Token)</summary><div class="access-config-heading"><h3>接入配置预览</h3><button class="text-button" id="access-copy-template">查看完整接入配置 ↗</button></div>
|
||||
<p class="panel-description" id="access-config-note">预览默认隐藏 Token。点击「查看完整接入配置」,即可复制包含当前 Token 的配置,或下载 orbit.env。</p>
|
||||
<pre class="access-code" id="access-config"></pre>
|
||||
<pre class="access-code" id="access-config"></pre></details>
|
||||
<div class="access-help">Token 确定应用和权限,队列决定任务去向。业务服务注册相同名称与版本的 handler 后,即可执行任务。</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
||||
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span><span id="refresh-mode-label">每 10 秒自动刷新</span> · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
||||
</main>
|
||||
</div>
|
||||
<div class="drawer-backdrop" id="task-drawer-backdrop" hidden>
|
||||
<aside class="task-drawer" role="dialog" aria-modal="true" aria-labelledby="detail-title">
|
||||
<div class="detail-scope"></div><div class="drawer-header">
|
||||
<div><span class="panel-kicker">PULSE / TASK DETAIL</span><h2 id="detail-title">任务详情</h2><small class="drawer-id" id="detail-id">—</small></div>
|
||||
<div><h2 id="detail-title">任务详情</h2><small class="drawer-id" id="detail-id">—</small><button type="button" class="copy-id text-button" data-copy-id="detail-id" aria-label="复制资源 ID">复制 ID</button></div>
|
||||
<button class="icon-button" id="close-task-drawer" type="button" aria-label="关闭任务详情">×</button>
|
||||
</div>
|
||||
<div class="drawer-status-line"><span class="state-pill" id="detail-state">—</span><span id="detail-updated">—</span></div>
|
||||
@@ -276,7 +240,7 @@
|
||||
<div class="drawer-backdrop" id="schedule-drawer-backdrop" hidden>
|
||||
<aside class="task-drawer schedule-drawer" role="dialog" aria-modal="true" aria-labelledby="schedule-detail-title">
|
||||
<div class="detail-scope"></div><div class="drawer-header">
|
||||
<div><span class="panel-kicker">PULSE / SCHEDULE DETAIL</span><h2 id="schedule-detail-title">定时计划</h2><small class="drawer-id" id="schedule-detail-id">—</small></div>
|
||||
<div><h2 id="schedule-detail-title">定时计划</h2><small class="drawer-id" id="schedule-detail-id">—</small><button type="button" class="copy-id text-button" data-copy-id="schedule-detail-id" aria-label="复制资源 ID">复制 ID</button></div>
|
||||
<button class="icon-button" id="close-schedule-drawer" type="button" aria-label="关闭计划详情">×</button>
|
||||
</div>
|
||||
<div class="drawer-status-line"><span class="state-pill" id="schedule-detail-state">—</span><span id="schedule-detail-updated">—</span></div>
|
||||
@@ -293,7 +257,7 @@
|
||||
<div class="drawer-actions" id="schedule-actions" hidden><button class="quiet-button" id="schedule-control-button" type="button">暂停计划</button></div>
|
||||
<p class="drawer-note" id="schedule-action-note">计划控制需要 schedules:manage 权限;暂停只停止未来触发,已经创建的任务继续执行。</p>
|
||||
<form class="action-form" id="schedule-action-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">SCHEDULE CONTROL</span><h3 id="schedule-action-title">暂停计划</h3></div>
|
||||
<div class="action-form-heading"><h3 id="schedule-action-title">暂停计划</h3></div>
|
||||
<p id="schedule-action-description">暂停后不会再创建新的任务。</p>
|
||||
<label class="field-label" for="schedule-action-reason">操作原因</label><textarea id="schedule-action-reason" rows="3" maxlength="1024" placeholder="说明本次计划控制的原因"></textarea>
|
||||
<div class="action-form-buttons"><button class="quiet-button" id="schedule-action-cancel" type="button">返回</button><button class="primary-button compact" id="schedule-action-submit" type="submit">提交控制 →</button></div>
|
||||
@@ -304,7 +268,7 @@
|
||||
<div class="drawer-backdrop" id="workflow-drawer-backdrop" hidden>
|
||||
<aside class="task-drawer workflow-drawer" role="dialog" aria-modal="true" aria-labelledby="workflow-detail-title">
|
||||
<div class="detail-scope"></div><div class="drawer-header">
|
||||
<div><span class="panel-kicker">WEAVE / WORKFLOW DETAIL</span><h2 id="workflow-detail-title">流程实例</h2><small class="drawer-id" id="workflow-detail-id">—</small></div>
|
||||
<div><h2 id="workflow-detail-title">流程实例</h2><small class="drawer-id" id="workflow-detail-id">—</small><button type="button" class="copy-id text-button" data-copy-id="workflow-detail-id" aria-label="复制资源 ID">复制 ID</button></div>
|
||||
<button class="icon-button" id="close-workflow-drawer" type="button" aria-label="关闭流程详情">×</button>
|
||||
</div>
|
||||
<div class="drawer-status-line"><span class="state-pill" id="workflow-detail-state">—</span><span id="workflow-detail-updated">—</span></div>
|
||||
@@ -327,14 +291,14 @@
|
||||
<div class="drawer-actions" id="workflow-actions" hidden><button class="quiet-button" id="workflow-control-button" type="button">暂停流程</button><button class="quiet-button danger-button" id="workflow-cancel-button" type="button">取消流程</button></div>
|
||||
<p class="drawer-note" id="workflow-detail-note">流程详情只读取当前权威视图;事件审计需要 workflows:audit:read 权限,所有控制操作仍由服务端权限和并发版本校验。</p>
|
||||
<form class="action-form" id="workflow-action-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">WEAVE CONTROL</span><h3 id="workflow-action-title">流程控制</h3></div>
|
||||
<div class="action-form-heading"><h3 id="workflow-action-title">流程控制</h3></div>
|
||||
<p id="workflow-action-description">流程控制会写入不可变审计,并使用当前控制修订进行并发校验。</p>
|
||||
<label class="field-label" for="workflow-action-reason">操作原因</label><textarea id="workflow-action-reason" rows="3" maxlength="1024" placeholder="说明本次流程控制的原因"></textarea>
|
||||
<div class="action-form-buttons"><button class="quiet-button" id="workflow-action-cancel" type="button">返回</button><button class="primary-button compact" id="workflow-action-submit" type="submit">提交控制 →</button></div>
|
||||
<div class="action-error" id="workflow-action-error" role="alert"></div>
|
||||
</form>
|
||||
<form class="action-form recovery-form" id="workflow-recovery-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">WEAVE RECOVERY</span><h3 id="workflow-recovery-title">人工恢复</h3></div>
|
||||
<div class="action-form-heading"><h3 id="workflow-recovery-title">人工恢复</h3></div>
|
||||
<p id="workflow-recovery-description">提交后会写入流程审计,并按当前版本重新读取实例。</p>
|
||||
<div class="recovery-snapshot" id="workflow-recovery-snapshot">当前流程修订 — · 当前步骤 —</div>
|
||||
<label class="field-label" for="workflow-recovery-reason">核查原因</label><textarea id="workflow-recovery-reason" rows="3" maxlength="1024" placeholder="说明你核实了什么事实,以及为什么可以执行该决定"></textarea>
|
||||
@@ -361,7 +325,7 @@
|
||||
<div class="drawer-backdrop" id="subscription-drawer-backdrop" hidden>
|
||||
<aside class="task-drawer subscription-drawer" role="dialog" aria-modal="true" aria-labelledby="subscription-detail-title">
|
||||
<div class="detail-scope"></div><div class="drawer-header">
|
||||
<div><span class="panel-kicker">EVENTS / SUBSCRIPTION DETAIL</span><h2 id="subscription-detail-title">结果订阅</h2><small class="drawer-id" id="subscription-detail-id">—</small></div>
|
||||
<div><h2 id="subscription-detail-title">结果订阅</h2><small class="drawer-id" id="subscription-detail-id">—</small><button type="button" class="copy-id text-button" data-copy-id="subscription-detail-id" aria-label="复制资源 ID">复制 ID</button></div>
|
||||
<button class="icon-button" id="close-subscription-drawer" type="button" aria-label="关闭订阅详情">×</button>
|
||||
</div>
|
||||
<div class="drawer-status-line"><span class="state-pill" id="subscription-detail-state">—</span><span id="subscription-detail-updated">—</span></div>
|
||||
@@ -379,14 +343,14 @@
|
||||
<div class="drawer-actions" id="subscription-actions" hidden><button class="quiet-button" id="subscription-retire-button" type="button">退役订阅</button><button class="quiet-button" id="subscription-control-button" type="button">暂停订阅</button></div>
|
||||
<p class="drawer-note" id="subscription-detail-note">交付详情不展示租约秘密;结果确认与人工重试必须通过受保护的消费或管理接口完成。</p>
|
||||
<form class="action-form" id="subscription-action-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">EVENTS CONTROL</span><h3 id="subscription-action-title">订阅控制</h3></div>
|
||||
<div class="action-form-heading"><h3 id="subscription-action-title">订阅控制</h3></div>
|
||||
<p id="subscription-action-description">订阅控制会保留已有交付和审计记录。</p>
|
||||
<label class="field-label" for="subscription-action-reason">操作原因</label><textarea id="subscription-action-reason" rows="3" maxlength="1024" placeholder="说明本次订阅控制的原因"></textarea>
|
||||
<div class="action-form-buttons"><button class="quiet-button" id="subscription-action-cancel" type="button">返回</button><button class="primary-button compact" id="subscription-action-submit" type="submit">提交控制 →</button></div>
|
||||
<div class="action-error" id="subscription-action-error" role="alert"></div>
|
||||
</form>
|
||||
<form class="action-form" id="subscription-retry-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">EVENTS RECOVERY</span><h3>重试过期交付</h3></div>
|
||||
<div class="action-form-heading"><h3>重试过期交付</h3></div>
|
||||
<p>只允许对 EXPIRED 交付追加次数预算;原交付身份、已用次数和事件不会重置。</p>
|
||||
<label class="field-label" for="subscription-retry-reason">操作原因</label><textarea id="subscription-retry-reason" rows="3" maxlength="1024" placeholder="说明接收方已修复的原因"></textarea>
|
||||
<label class="field-label" for="subscription-retry-attempts">追加次数(1–100)</label><input id="subscription-retry-attempts" type="number" min="1" max="100" step="1" value="1">
|
||||
@@ -396,8 +360,16 @@
|
||||
</aside>
|
||||
</div>
|
||||
<div class="toast" id="toast" role="status"></div>
|
||||
<dialog id="login-audit-dialog" class="access-dialog login-audit-dialog" aria-labelledby="login-audit-title">
|
||||
<div class="access-dialog-top"><button type="button" class="icon-button" id="login-audit-close" aria-label="关闭登录审计">×</button></div>
|
||||
<h2 id="login-audit-title">登录审计</h2>
|
||||
<p class="panel-description">记录登录成功、失败、临时锁定与限流。每页 10 条,时间为当前时区。</p>
|
||||
<p id="login-audit-status" role="status"></p>
|
||||
<div class="table-wrap"><table><thead><tr><th>时间</th><th>账号</th><th>来源 IP</th><th>结果</th><th>可重试时间</th></tr></thead><tbody id="login-audit-rows"></tbody></table></div>
|
||||
<div class="audit-pagination"><button class="secondary-button compact" id="login-audit-first">最新记录</button><span id="login-audit-page">第 1 页</span><button class="secondary-button compact" id="login-audit-next" disabled>下一页 →</button></div>
|
||||
</dialog>
|
||||
<dialog id="access-dialog" class="access-dialog">
|
||||
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / CONNECT</span><button type="button" class="icon-button" id="access-close" aria-label="关闭应用接入">×</button></div>
|
||||
<div class="access-dialog-top"><button type="button" class="icon-button" id="access-close" aria-label="关闭应用接入">×</button></div>
|
||||
<h2 id="access-form-title">新建应用</h2><p class="panel-description" id="access-form-note">为应用创建独立的环境、队列与调用凭据。</p>
|
||||
<p class="access-token-once-note" id="access-token-once-note">Token 会加密保存。管理员可随时查看接入信息、复制完整配置或下载 orbit.env。</p>
|
||||
<form id="access-form">
|
||||
@@ -416,7 +388,7 @@
|
||||
<section id="access-result" hidden><div class="access-success">✓ <span id="access-result-title">应用已创建</span></div><p class="access-token-once-note" id="access-result-note">复制下面的完整接入配置即可用于业务服务。关闭后仍可从凭据卡片的「查看接入信息」重新获取当前 Token。</p><label id="access-result-config-field">完整 SDK 配置<textarea id="access-result-config" readonly rows="7" spellcheck="false"></textarea></label><p class="panel-description" id="access-result-expiry"></p><div class="access-form-actions" id="access-result-actions"><button type="button" class="primary-button compact" id="access-copy">复制配置 ↗</button><button type="button" class="secondary-button compact" id="access-download">下载 .env ↓</button></div></section>
|
||||
</dialog>
|
||||
<dialog id="access-help-dialog" class="access-dialog access-credential-help" aria-labelledby="access-help-title">
|
||||
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / CREDENTIAL</span><button type="button" class="icon-button" id="access-help-close" aria-label="关闭凭据说明">×</button></div>
|
||||
<div class="access-dialog-top"><button type="button" class="icon-button" id="access-help-close" aria-label="关闭凭据说明">×</button></div>
|
||||
<h2 id="access-help-title">凭据与换证说明</h2>
|
||||
<p class="panel-description" id="access-help-scope"></p>
|
||||
<div class="access-rotation-notice"><strong>当前换证状态</strong><p id="access-help-state"></p></div>
|
||||
@@ -427,7 +399,7 @@
|
||||
</dialog>
|
||||
|
||||
<dialog id="access-token-dialog" class="access-dialog" aria-labelledby="access-token-title">
|
||||
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / INTEGRATION</span><button type="button" class="icon-button" id="access-token-close" aria-label="关闭接入信息">×</button></div>
|
||||
<div class="access-dialog-top"><button type="button" class="icon-button" id="access-token-close" aria-label="关闭接入信息">×</button></div>
|
||||
<h2 id="access-token-title">接入信息</h2><p class="panel-description" id="access-token-scope"></p>
|
||||
<form id="access-token-import" hidden>
|
||||
<p class="panel-description">此凭据创建于 Token 保管功能启用之前。导入已保存的当前 Token 后,管理员即可随时查看和复制完整配置。导入不会换证,也不会改变有效期或过渡时间。</p>
|
||||
@@ -442,6 +414,8 @@
|
||||
<p class="action-error" id="access-token-error" role="status"></p>
|
||||
</dialog>
|
||||
|
||||
<script src="/console/console-core.js"></script>
|
||||
<script src="/console/console-select.js"></script>
|
||||
<script src="/console/applications.js"></script>
|
||||
<script src="/console/app.js"></script>
|
||||
</body>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -44,6 +44,20 @@ func RegisterConsoleApplications(server *http.Server, consoleAuth consoleauth.St
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
if auditor, ok := store.(consoleauth.LoginAuditor); ok {
|
||||
mux.HandleFunc("GET /console/auth/audit", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := auditor.ListLoginAudit(r.Context(), token, r.URL.Query().Get("before"))
|
||||
if err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
}
|
||||
mux.HandleFunc("GET /console/applications", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
|
||||
@@ -2,8 +2,11 @@ package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,12 +17,13 @@ import (
|
||||
)
|
||||
|
||||
type fakeConsoleAuth struct {
|
||||
valid bool
|
||||
login consoleauth.LoginResult
|
||||
valid bool
|
||||
login consoleauth.LoginResult
|
||||
loginError error
|
||||
}
|
||||
|
||||
func (f *fakeConsoleAuth) Login(context.Context, string, string, string, string) (consoleauth.LoginResult, error) {
|
||||
return f.login, nil
|
||||
return f.login, f.loginError
|
||||
}
|
||||
func (f *fakeConsoleAuth) ValidateSession(context.Context, auth.Token) error {
|
||||
if !f.valid {
|
||||
@@ -82,3 +86,69 @@ func TestConsoleLoginSetsRootScopedHttpOnlyCookie(t *testing.T) {
|
||||
t.Fatalf("cookie path/httpOnly = %q/%v", cookie.Path, cookie.HttpOnly)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleLoginRejectsWithRetryAndNoCookie(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"password", fault.Unauthorized, 401, "UNAUTHENTICATED"},
|
||||
{"locked", &consoleauth.LoginBlocked{Locked: true, RetryAfter: 1500 * time.Millisecond}, 429, "LOGIN_LOCKED"},
|
||||
{"rate", &consoleauth.LoginBlocked{RetryAfter: 1500 * time.Millisecond}, 429, "LOGIN_RATE_LIMITED"},
|
||||
{"storage", errors.New("private database error"), 503, "UNAVAILABLE"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{loginError: tc.err})
|
||||
req := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(`{"username":"admin","password":"password-password"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(w, req)
|
||||
if w.Code != tc.status || !strings.Contains(w.Body.String(), tc.code) || len(w.Result().Cookies()) != 0 || strings.Contains(w.Body.String(), "private database") {
|
||||
t.Fatalf("response: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Code == 429 && w.Header().Get("Retry-After") != "2" {
|
||||
t.Fatal("missing rounded Retry-After")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type policyConsoleAuth struct {
|
||||
fakeConsoleAuth
|
||||
policy consoleauth.LoginPolicy
|
||||
}
|
||||
|
||||
func (s *policyConsoleAuth) LoginPolicy() consoleauth.LoginPolicy { return s.policy }
|
||||
func TestLoginSourceOnlyTrustsConfiguredProxyChain(t *testing.T) {
|
||||
s := &policyConsoleAuth{policy: consoleauth.DefaultLoginPolicy()}
|
||||
s.policy.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("::1/128")}
|
||||
for _, tc := range []struct{ peer, forward, want string }{
|
||||
{"192.0.2.1:1234", "198.51.100.7", "192.0.2.1"},
|
||||
{"10.0.0.1:1234", "198.51.100.7, 10.0.0.2", "198.51.100.7"},
|
||||
{"10.0.0.1:1234", "203.0.113.99, 198.51.100.7", "198.51.100.7"},
|
||||
{"10.0.0.1:1234", "garbage, 10.0.0.2", "10.0.0.1"},
|
||||
{"[::1]:1234", "::ffff:192.0.2.1", "192.0.2.1"},
|
||||
} {
|
||||
r := httptest.NewRequest("POST", "/console/auth/login", nil)
|
||||
r.RemoteAddr = tc.peer
|
||||
r.Header.Set("X-Forwarded-For", tc.forward)
|
||||
r.Header.Set("X-Real-IP", "203.0.113.123")
|
||||
if got := loginRemoteAddress(r, s); got != tc.want {
|
||||
t.Fatalf("%s / %s: got %s", tc.peer, tc.forward, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleLoginRejectsCrossSite(t *testing.T) {
|
||||
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{})
|
||||
r := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(`{"username":"admin","password":"wrong"}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("Origin", "https://evil.invalid")
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(w, r)
|
||||
if w.Code != 403 || len(w.Result().Cookies()) != 0 {
|
||||
t.Fatalf("cross-site response: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
57
internal/transport/httpserver/console_login.go
Normal file
57
internal/transport/httpserver/console_login.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// loginRemoteAddress 默认只信任 TCP 对端;仅受信反代可提供 X-Forwarded-For。
|
||||
// 从右往左剥离可信跳点,遇到首个非可信地址即停止;不接受任意客户端伪造的首项。
|
||||
func loginRemoteAddress(r *http.Request, store consoleauth.Store) string {
|
||||
peer, err := netip.ParseAddr(remoteAddress(r))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
peer = peer.Unmap()
|
||||
policyStore, ok := store.(interface {
|
||||
LoginPolicy() consoleauth.LoginPolicy
|
||||
})
|
||||
if !ok {
|
||||
return peer.String()
|
||||
}
|
||||
trusted := policyStore.LoginPolicy().TrustedProxies
|
||||
isTrusted := func(ip netip.Addr) bool {
|
||||
for _, n := range trusted {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !isTrusted(peer) {
|
||||
return peer.String()
|
||||
}
|
||||
raw := strings.Join(r.Header.Values("X-Forwarded-For"), ",")
|
||||
if raw == "" || len(raw) > 2048 {
|
||||
return peer.String()
|
||||
}
|
||||
hops := strings.Split(raw, ",")
|
||||
if len(hops) > 32 {
|
||||
return peer.String()
|
||||
}
|
||||
addresses := make([]netip.Addr, len(hops))
|
||||
for i, h := range hops {
|
||||
a, e := netip.ParseAddr(strings.TrimSpace(h))
|
||||
if e != nil {
|
||||
return peer.String()
|
||||
}
|
||||
addresses[i] = a.Unmap()
|
||||
}
|
||||
current := peer
|
||||
for i := len(addresses) - 1; i >= 0 && isTrusted(current); i-- {
|
||||
current = addresses[i]
|
||||
}
|
||||
return current.String()
|
||||
}
|
||||
@@ -8,13 +8,14 @@ E2E_IMAGE=${ORBIT_E2E_IMAGE:-orbit:e2e}
|
||||
E2E_SUFFIX=$$
|
||||
E2E_MYSQL="orbit-e2e-mysql-$E2E_SUFFIX"
|
||||
E2E_ORBIT="orbit-e2e-server-$E2E_SUFFIX"
|
||||
E2E_ORBIT_SECOND="orbit-e2e-login-peer-$E2E_SUFFIX"
|
||||
E2E_DIR=$(mktemp -d "${TMPDIR:-/tmp}/orbit-console-e2e.XXXXXX")
|
||||
E2E_PASSWORD_FILE="$E2E_DIR/console-password"
|
||||
E2E_CREDENTIALS_FILE="$E2E_DIR/credentials.json"
|
||||
|
||||
cleanup() {
|
||||
"$E2E_DOCKER" stop "$E2E_ORBIT" "$E2E_MYSQL" >/dev/null 2>&1 || true
|
||||
"$E2E_DOCKER" container rm "$E2E_ORBIT" "$E2E_MYSQL" >/dev/null 2>&1 || true
|
||||
"$E2E_DOCKER" stop "$E2E_ORBIT_SECOND" "$E2E_ORBIT" "$E2E_MYSQL" >/dev/null 2>&1 || true
|
||||
"$E2E_DOCKER" container rm "$E2E_ORBIT_SECOND" "$E2E_ORBIT" "$E2E_MYSQL" >/dev/null 2>&1 || true
|
||||
if [ -d "$E2E_DIR/archive-job" ]; then
|
||||
for e2e_artifact in "$E2E_DIR/archive-job"/.lock "$E2E_DIR/archive-job"/manifest.json "$E2E_DIR/archive-job"/batch-000001.json; do
|
||||
if [ -f "$e2e_artifact" ]; then unlink "$e2e_artifact"; fi
|
||||
@@ -115,6 +116,9 @@ printf '%s\n' "[4/8] start Orbit container"
|
||||
--env ORBIT_MYSQL_DSN="$E2E_DSN" \
|
||||
--env ORBIT_HTTP_ADDR=0.0.0.0:8080 \
|
||||
--env ORBIT_CONSOLE_QUEUE_ID="$E2E_QUEUE" \
|
||||
--env ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT=3 \
|
||||
--env ORBIT_CONSOLE_LOGIN_LOCK_DURATION=10s \
|
||||
--env ORBIT_CONSOLE_LOGIN_RATE_WINDOW=2s \
|
||||
--env ORBIT_CONSOLE_APPLICATION_ADMIN="$E2E_ADMIN" \
|
||||
--env ORBIT_CREDENTIAL_KEY_FILE=/run/credential-keys.json \
|
||||
--volume "$E2E_DIR/credential-keys.json:/run/credential-keys.json:ro" \
|
||||
@@ -191,7 +195,7 @@ assert status == 200, status
|
||||
status, body = request("/console/auth/session")
|
||||
assert status == 200 and json.loads(body).get("authenticated") is True
|
||||
status, body = request("/console/")
|
||||
assert status == 200 and "运行统计".encode("utf-8") in body, status
|
||||
assert status == 200 and b'id="overview-task-list"' in body and b'id="tasks-filters"' in body and b'/console/console-core.js' in body, status
|
||||
status, body = request("/console/config")
|
||||
assert status == 200 and json.loads(body).get("default_queue_id") == queue
|
||||
status, _ = request("/v1/tasks?" + urllib.parse.urlencode({"queue_id": queue, "limit": 20}))
|
||||
@@ -199,6 +203,22 @@ assert status == 200, status
|
||||
print("console_login=ok protected_page=ok")
|
||||
PY
|
||||
|
||||
printf '%s\n' "[login] cross-node rejection, restart and recovery"
|
||||
"$E2E_DOCKER" run -d --name "$E2E_ORBIT_SECOND" --network bridge --link "$E2E_MYSQL:mysql" \
|
||||
--env ORBIT_MYSQL_DSN="$E2E_DSN" --env ORBIT_HTTP_ADDR=0.0.0.0:8080 \
|
||||
--env ORBIT_CONSOLE_APPLICATION_ADMIN="$E2E_ADMIN" \
|
||||
--env ORBIT_CREDENTIAL_KEY_FILE=/run/credential-keys.json \
|
||||
--env ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT=3 \
|
||||
--env ORBIT_CONSOLE_LOGIN_LOCK_DURATION=10s \
|
||||
--env ORBIT_CONSOLE_LOGIN_RATE_WINDOW=2s \
|
||||
--volume "$E2E_DIR/credential-keys.json:/run/credential-keys.json:ro" \
|
||||
--publish 127.0.0.1::8080 "$E2E_IMAGE" >/dev/null
|
||||
E2E_SECOND_PORT=$("$E2E_DOCKER" port "$E2E_ORBIT_SECOND" 8080/tcp | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p' | head -n 1)
|
||||
ORBIT_E2E_URL="$E2E_URL" ORBIT_E2E_SECOND_URL="http://127.0.0.1:$E2E_SECOND_PORT" \
|
||||
ORBIT_E2E_SECOND_CONTAINER="$E2E_ORBIT_SECOND" ORBIT_E2E_USER="$E2E_ADMIN" \
|
||||
ORBIT_E2E_PASSWORD_FILE="$E2E_PASSWORD_FILE" DOCKER="$E2E_DOCKER" python3 scripts/test-console-login.py
|
||||
"$E2E_DOCKER" stop "$E2E_ORBIT_SECOND" >/dev/null
|
||||
|
||||
printf '%s\n' "[6/8] WebSocket Worker task execution"
|
||||
(cd examples && ORBIT_URL="$E2E_URL" ORBIT_CREDENTIALS_FILE="$E2E_CREDENTIALS_FILE" \
|
||||
"$E2E_GO" run ./worker) | tee "$E2E_DIR/worker.log"
|
||||
|
||||
68
scripts/test-console-login.py
Normal file
68
scripts/test-console-login.py
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""由 console-e2e 调用,在一次性双节点环境核验登录保护;不输出密码或 Cookie。"""
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
base = os.environ['ORBIT_E2E_URL']
|
||||
peer = os.environ['ORBIT_E2E_SECOND_URL']
|
||||
username = os.environ['ORBIT_E2E_USER']
|
||||
password = pathlib.Path(os.environ['ORBIT_E2E_PASSWORD_FILE']).read_text().strip()
|
||||
jar = http.cookiejar.CookieJar()
|
||||
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
|
||||
|
||||
def call(url, path, body=None, headers=None):
|
||||
req = urllib.request.Request(url + path, data=None if body is None else json.dumps(body).encode(), headers={'Content-Type': 'application/json', **(headers or {})})
|
||||
try:
|
||||
with client.open(req, timeout=8) as response:
|
||||
return response.status, json.load(response), response.headers
|
||||
except urllib.error.HTTPError as response:
|
||||
return response.code, json.load(response), response.headers
|
||||
|
||||
|
||||
def ready(url):
|
||||
for _ in range(30):
|
||||
try:
|
||||
if call(url, '/health/ready')[0] == 200:
|
||||
return
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
time.sleep(.2)
|
||||
raise AssertionError('test peer not ready')
|
||||
|
||||
|
||||
ready(peer)
|
||||
assert call(base, '/console/auth/login', {'username': username, 'password': password})[0] == 200
|
||||
for url in (base, peer):
|
||||
status, data, headers = call(url, '/console/auth/login', {'username': username, 'password': 'wrong-test-only'})
|
||||
assert status == 401 and not headers.get('Set-Cookie'), status
|
||||
status, data, headers = call(base, '/console/auth/login', {'username': username, 'password': 'wrong-test-only'})
|
||||
assert status == 429 and data['error']['code'] == 'LOGIN_LOCKED' and int(headers['Retry-After']) > 0
|
||||
assert not headers.get('Set-Cookie')
|
||||
assert call(peer, '/console/auth/session')[1]['authenticated'] is True, 'lock killed existing session'
|
||||
status, body, _ = call(peer, '/console/auth/audit')
|
||||
assert status == 200 and any(e['outcome'] == 'LOCKED' for e in body['data']['items'])
|
||||
until = next(e['retry_at'] for e in body['data']['items'] if e['outcome'] == 'LOCKED')
|
||||
subprocess.run([os.environ.get('DOCKER', 'docker'), 'restart', os.environ['ORBIT_E2E_SECOND_CONTAINER']], check=True, stdout=subprocess.DEVNULL)
|
||||
# 随机发布端口在 Docker 重启后可能重新分配,读取实际端口再验证同一节点。
|
||||
published = subprocess.check_output([os.environ.get('DOCKER', 'docker'), 'port', os.environ['ORBIT_E2E_SECOND_CONTAINER'], '8080/tcp'], text=True).strip().splitlines()[0]
|
||||
peer = 'http://127.0.0.1:' + published.rsplit(':', 1)[1]
|
||||
ready(peer)
|
||||
status, data, headers = call(peer, '/console/auth/login', {'username': username, 'password': password}, {'X-Forwarded-For': '203.0.113.88'})
|
||||
assert status == 429 and data['error']['code'] == 'LOGIN_LOCKED', 'restart bypassed lock'
|
||||
body = call(base, '/console/auth/audit')[1]
|
||||
latest = body['data']['items'][0]
|
||||
assert latest['retry_at'] == until and latest['remote_ip'] != '203.0.113.88', 'lock extended or untrusted header accepted'
|
||||
# 仅按真实返回的截止等待;不修改数据库时间或删除锁定记录。
|
||||
time.sleep(int(headers['Retry-After']) + .1)
|
||||
assert call(peer, '/console/auth/login', {'username': username, 'password': password})[0] == 200
|
||||
body = call(base, '/console/auth/audit')[1]
|
||||
assert body['data']['items'][0]['outcome'] == 'SUCCESS'
|
||||
assert password not in json.dumps(body)
|
||||
print('console_login_security=passed cross_node=locked restart=locked existing_session=valid forged_ip=ignored expiry=success audit=confirmed')
|
||||
57
scripts/test-console-state.cjs
Normal file
57
scripts/test-console-state.cjs
Normal file
@@ -0,0 +1,57 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const context = vm.createContext({ URLSearchParams });
|
||||
vm.runInContext(fs.readFileSync(require('node:path').join(__dirname, '../internal/transport/httpserver/console/console-core.js'), 'utf8'), context);
|
||||
const { listURL, readFilters, ListPage } = context.OrbitConsole;
|
||||
|
||||
test('精确业务键、筛选和游标跨页保持,不静默扩展查询范围', () => {
|
||||
const page = new ListPage();
|
||||
page.reset({ business_key: '订单 +%_ ', state: 'NEEDS_REVIEW', created_from: '2026-09-01T00:00:00.000Z' });
|
||||
assert.ok(page.accept(page.begin(), { next_cursor: 'opaque+/=' }));
|
||||
assert.ok(page.move('next'));
|
||||
const query = new URL(listURL('tasks', 'queue-1', page.filters, page.cursor), 'http://localhost').searchParams;
|
||||
assert.equal(query.get('business_key'), '订单 +%_ ');
|
||||
assert.equal(query.get('state'), 'NEEDS_REVIEW');
|
||||
assert.equal(query.get('queue_id'), 'queue-1');
|
||||
assert.equal(query.get('cursor'), 'opaque+/=');
|
||||
assert.equal(page.limit, 10);
|
||||
assert.equal(query.get('limit'), '10');
|
||||
assert.equal(new URL(listURL('tasks', 'queue-1', page.filters, page.cursor, 50), 'http://localhost').searchParams.get('limit'), '50');
|
||||
assert.ok(page.move('prev'));
|
||||
assert.equal(page.cursor, '');
|
||||
assert.equal(page.filters.state, 'NEEDS_REVIEW');
|
||||
});
|
||||
test('查询重置后迟到的旧页面不会覆盖新条件和翻页状态', () => {
|
||||
const page = new ListPage();
|
||||
const slow = page.begin();
|
||||
page.reset({ state: 'FAILED' });
|
||||
const latest = page.begin();
|
||||
assert.equal(page.accept(slow, { next_cursor: 'wrong' }), false);
|
||||
assert.equal(page.loading, true);
|
||||
assert.equal(page.move('next'), false);
|
||||
assert.equal(page.accept(latest, {}), true);
|
||||
assert.equal(page.next, '');
|
||||
assert.equal(page.filters.state, 'FAILED');
|
||||
});
|
||||
test('失败保留原查询与原页,不自动回到无筛选列表', () => {
|
||||
const page = new ListPage();
|
||||
page.reset({ state: 'RUNNING' });
|
||||
page.accept(page.begin(), { next_cursor: 'page-2' });
|
||||
page.move('next');
|
||||
assert.equal(page.fail(page.begin()), true);
|
||||
assert.equal(page.cursor, 'page-2');
|
||||
assert.equal(page.history.length, 1);
|
||||
assert.equal(page.filters.state, 'RUNNING');
|
||||
assert.equal(page.next, '');
|
||||
});
|
||||
test('时间边界验证、流程范围与无队列保护', () => {
|
||||
assert.throws(() => readFilters({ created_from: '2026-09-29T12:00', created_before: '2026-09-29T11:00' }), /结束时间/);
|
||||
assert.throws(() => readFilters({ created_from: 'not a date' }), /有效/);
|
||||
const f = readFilters({ created_from: '2026-09-29T12:00:00+08:00', business_key: ' key ' });
|
||||
assert.equal(f.created_from, '2026-09-29T04:00:00.000Z');
|
||||
assert.equal(f.business_key, ' key ');
|
||||
assert.throws(() => listURL('tasks', ''), /选择队列/);
|
||||
assert.equal(new URL(listURL('workflows', 'ignored'), 'http://localhost').searchParams.has('queue_id'), false);
|
||||
});
|
||||
Reference in New Issue
Block a user